diff --git a/README.md b/README.md
index cb42173..9385383 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,26 @@ If a user is too soft to hear, you can boost their audio.
The audio should drastically increase once you have hit the VolumeUp key over 10 times (from the silent/0 position).
The boost setting is saved per user, just like per user volume.
+## Voice Effects
+
+Barnard includes real-time voice effects that can be applied to your outgoing microphone audio. Press F12 to cycle through the available effects.
+
+### Available Effects
+- **None**: No effect applied (default)
+- **Echo**: Single repeating delay effect with feedback (250ms) - creates distinct repetitions that fade away
+- **Reverb**: Multiple short delays (12.5ms, 20ms, 33ms) without feedback - adds thickness and fullness to your voice
+- **High Pitch**: Chipmunk-style voice using pitch shifting
+- **Low Pitch**: Deep voice using pitch shifting
+- **Robot**: Ring modulation effect for robotic sound
+- **Chorus**: Layered voices with slight pitch variations for a rich, ensemble sound
+
+### Controls
+- **F12 key**: Cycle through voice effects (configurable hotkey)
+- **Configuration**: Your selected effect is saved in `~/.barnard.yaml`
+
+### How It Works
+Voice effects are applied to your outgoing audio in real-time, after noise suppression and automatic gain control. The effects use various digital signal processing techniques including delay lines, pitch shifting with cubic interpolation, and ring modulation.
+
## Noise Suppression
Barnard includes real-time noise suppression for microphone input to filter out background noise such as keyboard typing, computer fans, and other environmental sounds.
@@ -191,6 +211,7 @@ After running the command above, `barnard` will be compiled as `$(go env GOPATH)
- F1: toggle voice transmission
- F9: toggle noise suppression
+- F12: cycle through voice effects
- Ctrl+L: clear chat log
- Tab: toggle focus between chat and user tree
- Page Up: scroll chat up
diff --git a/audio/effects.go b/audio/effects.go
new file mode 100644
index 0000000..81d7abd
--- /dev/null
+++ b/audio/effects.go
@@ -0,0 +1,408 @@
+package audio
+
+import (
+ "math"
+)
+
+// VoiceEffect represents different voice effect types
+type VoiceEffect int
+
+const (
+ EffectNone VoiceEffect = iota
+ EffectEcho
+ EffectReverb
+ EffectHighPitch
+ EffectLowPitch
+ EffectRobot
+ EffectChorus
+ EffectCount // Keep this last for cycling
+)
+
+// String returns the name of the effect
+func (e VoiceEffect) String() string {
+ switch e {
+ case EffectNone:
+ return "None"
+ case EffectEcho:
+ return "Echo"
+ case EffectReverb:
+ return "Reverb"
+ case EffectHighPitch:
+ return "High Pitch"
+ case EffectLowPitch:
+ return "Low Pitch"
+ case EffectRobot:
+ return "Robot"
+ case EffectChorus:
+ return "Chorus"
+ default:
+ return "Unknown"
+ }
+}
+
+// EffectsProcessor handles voice effects processing
+type EffectsProcessor struct {
+ currentEffect VoiceEffect
+ enabled bool
+
+ // Echo parameters
+ echoDelay int // Delay in samples
+ echoFeedback float32 // Echo feedback amount (0-1)
+ echoMix float32 // Mix of echo with original (0-1)
+ echoBuffer []int16 // Circular buffer for echo
+ echoPosition int // Current position in echo buffer
+
+ // Reverb buffer
+ reverseInputBuffer []int16 // Delay line for reverb
+ reverseInputPos int // Write position in buffer
+
+ // Pitch shift parameters
+ pitchRatio float32 // Pitch shift ratio
+ pitchBuffer []int16 // Buffer for pitch shifting
+ pitchPhase float32 // Phase accumulator for resampling
+
+ // Robot voice parameters
+ robotFreq float32 // Modulation frequency
+ robotPhase float32 // Phase accumulator
+ sampleRate float32 // Audio sample rate
+
+ // Chorus parameters
+ chorusDelays []int // Multiple delay times
+ chorusBuffers [][]int16 // Multiple delay buffers
+ chorusPositions []int // Positions in chorus buffers
+ chorusRates []float32 // LFO rates for each chorus voice
+ chorusPhases []float32 // LFO phases
+}
+
+// NewEffectsProcessor creates a new voice effects processor
+func NewEffectsProcessor(sampleRate int) *EffectsProcessor {
+ echoDelay := sampleRate / 4 // 250ms delay
+
+ return &EffectsProcessor{
+ currentEffect: EffectNone,
+ enabled: true,
+ sampleRate: float32(sampleRate),
+
+ // Echo setup
+ echoDelay: echoDelay,
+ echoFeedback: 0.4,
+ echoMix: 0.5,
+ echoBuffer: make([]int16, echoDelay),
+ echoPosition: 0,
+
+ // Reverb setup - 100ms buffer for delay lines
+ reverseInputBuffer: make([]int16, sampleRate/10), // 100ms buffer
+ reverseInputPos: 0,
+
+ // Pitch shift setup
+ pitchRatio: 1.0,
+ pitchBuffer: make([]int16, 4096),
+ pitchPhase: 0.0,
+
+ // Robot voice setup
+ robotFreq: 30.0, // 30 Hz modulation
+ robotPhase: 0.0,
+
+ // Chorus setup (3 voices)
+ chorusDelays: []int{sampleRate/50, sampleRate/40, sampleRate/35}, // ~20-30ms
+ chorusBuffers: make([][]int16, 3),
+ chorusPositions: make([]int, 3),
+ chorusRates: []float32{1.5, 2.0, 2.3}, // LFO rates in Hz
+ chorusPhases: make([]float32, 3),
+ }
+}
+
+// GetCurrentEffect returns the current effect
+func (ep *EffectsProcessor) GetCurrentEffect() VoiceEffect {
+ return ep.currentEffect
+}
+
+// SetEffect sets the current effect
+func (ep *EffectsProcessor) SetEffect(effect VoiceEffect) {
+ if effect >= 0 && effect < EffectCount {
+ ep.currentEffect = effect
+ ep.resetBuffers()
+ }
+}
+
+// CycleEffect cycles to the next effect
+func (ep *EffectsProcessor) CycleEffect() VoiceEffect {
+ ep.currentEffect = (ep.currentEffect + 1) % EffectCount
+ ep.resetBuffers()
+ return ep.currentEffect
+}
+
+// SetEnabled enables or disables effects processing
+func (ep *EffectsProcessor) SetEnabled(enabled bool) {
+ ep.enabled = enabled
+}
+
+// IsEnabled returns whether effects are enabled
+func (ep *EffectsProcessor) IsEnabled() bool {
+ return ep.enabled
+}
+
+// ProcessSamples applies the current voice effect to audio samples
+func (ep *EffectsProcessor) ProcessSamples(samples []int16) {
+ if !ep.enabled || ep.currentEffect == EffectNone || len(samples) == 0 {
+ return
+ }
+
+ switch ep.currentEffect {
+ case EffectEcho:
+ ep.processEcho(samples)
+ case EffectReverb:
+ ep.processReverb(samples)
+ case EffectHighPitch:
+ ep.processPitchShift(samples, 1.5)
+ case EffectLowPitch:
+ ep.processPitchShift(samples, 0.75)
+ case EffectRobot:
+ ep.processRobot(samples)
+ case EffectChorus:
+ ep.processChorus(samples)
+ }
+}
+
+// processEcho applies echo effect
+func (ep *EffectsProcessor) processEcho(samples []int16) {
+ for i := range samples {
+ // Get delayed sample
+ delayedSample := ep.echoBuffer[ep.echoPosition]
+
+ // Mix original with echo
+ outputSample := float32(samples[i])*(1.0-ep.echoMix) +
+ float32(delayedSample)*ep.echoMix
+
+ // Create new echo sample (current + feedback)
+ newEchoSample := float32(samples[i]) + float32(delayedSample)*ep.echoFeedback
+
+ // Store in buffer with clipping
+ if newEchoSample > 32767 {
+ newEchoSample = 32767
+ } else if newEchoSample < -32767 {
+ newEchoSample = -32767
+ }
+ ep.echoBuffer[ep.echoPosition] = int16(newEchoSample)
+
+ // Advance buffer position
+ ep.echoPosition = (ep.echoPosition + 1) % len(ep.echoBuffer)
+
+ // Apply to output with clipping
+ if outputSample > 32767 {
+ outputSample = 32767
+ } else if outputSample < -32767 {
+ outputSample = -32767
+ }
+ samples[i] = int16(outputSample)
+ }
+}
+
+// processReverb applies reverb effect - like echo but with multiple short delays
+func (ep *EffectsProcessor) processReverb(samples []int16) {
+ bufLen := len(ep.reverseInputBuffer)
+
+ // Three quick echoes instead of one long repeating echo
+ delays := []int{
+ bufLen / 8, // ~12.5ms
+ bufLen / 5, // ~20ms
+ bufLen / 3, // ~33ms
+ }
+ gains := []float32{0.3, 0.2, 0.15}
+
+ for i := range samples {
+ // Store current sample
+ ep.reverseInputBuffer[ep.reverseInputPos] = samples[i]
+
+ // Add the three quick echoes
+ reverbSample := float32(0)
+ for j := 0; j < len(delays); j++ {
+ readPos := (ep.reverseInputPos - delays[j] + bufLen) % bufLen
+ reverbSample += float32(ep.reverseInputBuffer[readPos]) * gains[j]
+ }
+
+ // Mix dry and wet signal
+ outputSample := float32(samples[i])*0.7 + reverbSample
+
+ // Advance position
+ ep.reverseInputPos = (ep.reverseInputPos + 1) % bufLen
+
+ // Apply with clipping
+ if outputSample > 32767 {
+ outputSample = 32767
+ } else if outputSample < -32767 {
+ outputSample = -32767
+ }
+
+ samples[i] = int16(outputSample)
+ }
+}
+
+// processPitchShift applies pitch shifting using cubic interpolation
+func (ep *EffectsProcessor) processPitchShift(samples []int16, ratio float32) {
+ if ratio == 1.0 {
+ return
+ }
+
+ bufLen := len(ep.pitchBuffer)
+
+ // Copy samples to pitch buffer (maintaining history)
+ copy(ep.pitchBuffer[bufLen-len(samples):], samples)
+
+ // Resample using cubic interpolation for smoother output
+ for i := range samples {
+ // Calculate source position
+ srcPos := float32(bufLen-len(samples)) + float32(i)*ratio
+
+ // Bounds check with extra padding for cubic interpolation
+ if srcPos >= float32(bufLen-2) {
+ srcPos = float32(bufLen - 3)
+ }
+ if srcPos < 1 {
+ srcPos = 1
+ }
+
+ // Cubic interpolation (Hermite interpolation)
+ idx := int(srcPos)
+ frac := srcPos - float32(idx)
+
+ // Get 4 samples around the target position
+ y0 := float32(ep.pitchBuffer[idx-1])
+ y1 := float32(ep.pitchBuffer[idx])
+ y2 := float32(ep.pitchBuffer[idx+1])
+ y3 := float32(ep.pitchBuffer[idx+2])
+
+ // Cubic Hermite interpolation
+ c0 := y1
+ c1 := 0.5 * (y2 - y0)
+ c2 := y0 - 2.5*y1 + 2.0*y2 - 0.5*y3
+ c3 := 0.5*(y3-y0) + 1.5*(y1-y2)
+
+ interpolated := c0 + c1*frac + c2*frac*frac + c3*frac*frac*frac
+
+ // Soft clipping to reduce harshness
+ if interpolated > 32767 {
+ interpolated = 32767
+ } else if interpolated < -32767 {
+ interpolated = -32767
+ }
+
+ samples[i] = int16(interpolated)
+ }
+
+ // Shift buffer for next frame
+ copy(ep.pitchBuffer, ep.pitchBuffer[len(samples):])
+}
+
+// processRobot applies ring modulation for robot voice
+func (ep *EffectsProcessor) processRobot(samples []int16) {
+ phaseIncrement := 2.0 * math.Pi * ep.robotFreq / ep.sampleRate
+
+ for i := range samples {
+ // Generate carrier wave (sine wave)
+ carrier := float32(math.Sin(float64(ep.robotPhase)))
+
+ // Ring modulation: multiply signal by carrier
+ modulated := float32(samples[i]) * (0.5 + carrier*0.5)
+
+ // Advance phase
+ ep.robotPhase += phaseIncrement
+ if ep.robotPhase >= 2.0*math.Pi {
+ ep.robotPhase -= 2.0 * math.Pi
+ }
+
+ // Apply with clipping
+ if modulated > 32767 {
+ modulated = 32767
+ } else if modulated < -32767 {
+ modulated = -32767
+ }
+
+ samples[i] = int16(modulated)
+ }
+}
+
+// processChorus applies chorus effect with multiple delayed voices
+func (ep *EffectsProcessor) processChorus(samples []int16) {
+ // Initialize chorus buffers if needed
+ for j := range ep.chorusBuffers {
+ if len(ep.chorusBuffers[j]) == 0 {
+ ep.chorusBuffers[j] = make([]int16, ep.chorusDelays[j])
+ }
+ }
+
+ for i := range samples {
+ output := float32(samples[i]) * 0.4 // Original signal at 40%
+
+ // Add multiple chorus voices
+ for j := 0; j < len(ep.chorusDelays); j++ {
+ // LFO modulation for slight pitch variation
+ lfoPhaseInc := 2.0 * math.Pi * ep.chorusRates[j] / ep.sampleRate
+ lfo := float32(math.Sin(float64(ep.chorusPhases[j])))
+ ep.chorusPhases[j] += lfoPhaseInc
+ if ep.chorusPhases[j] >= 2.0*math.Pi {
+ ep.chorusPhases[j] -= 2.0 * math.Pi
+ }
+
+ // Get delayed sample with LFO modulation
+ modDelay := int(float32(ep.chorusDelays[j]) * (1.0 + lfo*0.03))
+ if modDelay >= len(ep.chorusBuffers[j]) {
+ modDelay = len(ep.chorusBuffers[j]) - 1
+ }
+
+ readPos := (ep.chorusPositions[j] - modDelay + len(ep.chorusBuffers[j])) % len(ep.chorusBuffers[j])
+ delayedSample := ep.chorusBuffers[j][readPos]
+
+ // Add this voice to output (20% each)
+ output += float32(delayedSample) * 0.2
+
+ // Store current sample in buffer
+ ep.chorusBuffers[j][ep.chorusPositions[j]] = samples[i]
+ ep.chorusPositions[j] = (ep.chorusPositions[j] + 1) % len(ep.chorusBuffers[j])
+ }
+
+ // Apply with clipping
+ if output > 32767 {
+ output = 32767
+ } else if output < -32767 {
+ output = -32767
+ }
+
+ samples[i] = int16(output)
+ }
+}
+
+// resetBuffers clears all effect buffers
+func (ep *EffectsProcessor) resetBuffers() {
+ // Clear echo buffer
+ for i := range ep.echoBuffer {
+ ep.echoBuffer[i] = 0
+ }
+ ep.echoPosition = 0
+
+ // Clear reverb buffer
+ for i := range ep.reverseInputBuffer {
+ ep.reverseInputBuffer[i] = 0
+ }
+ ep.reverseInputPos = 0
+
+ // Clear pitch buffer
+ for i := range ep.pitchBuffer {
+ ep.pitchBuffer[i] = 0
+ }
+ ep.pitchPhase = 0
+
+ // Reset robot phase
+ ep.robotPhase = 0
+
+ // Clear chorus buffers
+ for j := range ep.chorusBuffers {
+ if len(ep.chorusBuffers[j]) > 0 {
+ for i := range ep.chorusBuffers[j] {
+ ep.chorusBuffers[j][i] = 0
+ }
+ }
+ ep.chorusPositions[j] = 0
+ ep.chorusPhases[j] = 0
+ }
+}
diff --git a/barnard.go b/barnard.go
index a465058..f3e4544 100644
--- a/barnard.go
+++ b/barnard.go
@@ -3,6 +3,7 @@ package main
import (
"crypto/tls"
+ "git.stormux.org/storm/barnard/audio"
"git.stormux.org/storm/barnard/config"
"git.stormux.org/storm/barnard/gumble/gumble"
"git.stormux.org/storm/barnard/gumble/gumbleopenal"
@@ -44,9 +45,12 @@ type Barnard struct {
// Added for channel muting
MutedChannels map[uint32]bool
-
+
// Added for noise suppression
NoiseSuppressor *noise.Suppressor
+
+ // Added for voice effects
+ VoiceEffects *audio.EffectsProcessor
}
func (b *Barnard) StopTransmission() {
diff --git a/client.go b/client.go
index 7a7d92f..d95326f 100644
--- a/client.go
+++ b/client.go
@@ -50,6 +50,7 @@ func (b *Barnard) connect(reconnect bool) bool {
b.Stream = stream
b.Stream.AttachStream(b.Client)
b.Stream.SetNoiseProcessor(b.NoiseSuppressor)
+ b.Stream.SetEffectsProcessor(b.VoiceEffects)
b.Connected = true
return true
}
diff --git a/config/hotkey_config.go b/config/hotkey_config.go
index 1a592b7..2f53c8d 100644
--- a/config/hotkey_config.go
+++ b/config/hotkey_config.go
@@ -19,4 +19,5 @@ type Hotkeys struct {
ScrollToTop *uiterm.Key
ScrollToBottom *uiterm.Key
NoiseSuppressionToggle *uiterm.Key
+ CycleVoiceEffect *uiterm.Key
}
diff --git a/config/user_config.go b/config/user_config.go
index bbbead0..76f0f41 100644
--- a/config/user_config.go
+++ b/config/user_config.go
@@ -28,6 +28,7 @@ type exportableConfig struct {
NotifyCommand *string
NoiseSuppressionEnabled *bool
NoiseSuppressionThreshold *float32
+ VoiceEffect *int
}
type server struct {
@@ -78,6 +79,7 @@ func (c *Config) LoadConfig() {
ScrollUp: key(uiterm.KeyPgup),
ScrollDown: key(uiterm.KeyPgdn),
NoiseSuppressionToggle: key(uiterm.KeyF9),
+ CycleVoiceEffect: key(uiterm.KeyF12),
}
if fileExists(c.fn) {
var data []byte
@@ -123,6 +125,10 @@ func (c *Config) LoadConfig() {
threshold := float32(0.02)
jc.NoiseSuppressionThreshold = &threshold
}
+ if c.config.VoiceEffect == nil {
+ effect := 0 // Default to EffectNone
+ jc.VoiceEffect = &effect
+ }
}
func (c *Config) findServer(address string) *server {
@@ -232,6 +238,18 @@ func (c *Config) SetNoiseSuppressionThreshold(threshold float32) {
c.SaveConfig()
}
+func (c *Config) GetVoiceEffect() int {
+ if c.config.VoiceEffect == nil {
+ return 0
+ }
+ return *c.config.VoiceEffect
+}
+
+func (c *Config) SetVoiceEffect(effect int) {
+ c.config.VoiceEffect = &effect
+ c.SaveConfig()
+}
+
func (c *Config) UpdateUser(u *gumble.User) {
var j *eUser
var uc *gumble.Client
diff --git a/gumble/gumbleopenal/stream.go b/gumble/gumbleopenal/stream.go
index bdf346b..d4c6e68 100644
--- a/gumble/gumbleopenal/stream.go
+++ b/gumble/gumbleopenal/stream.go
@@ -17,6 +17,12 @@ type NoiseProcessor interface {
IsEnabled() bool
}
+// EffectsProcessor interface for voice effects
+type EffectsProcessor interface {
+ ProcessSamples(samples []int16)
+ IsEnabled() bool
+}
+
const (
maxBufferSize = 11520 // Max frame size (2880) * bytes per stereo sample (4)
)
@@ -49,9 +55,10 @@ type Stream struct {
deviceSink *openal.Device
contextSink *openal.Context
-
- noiseProcessor NoiseProcessor
- micAGC *audio.AGC
+
+ noiseProcessor NoiseProcessor
+ micAGC *audio.AGC
+ effectsProcessor EffectsProcessor
}
func New(client *gumble.Client, inputDevice *string, outputDevice *string, test bool) (*Stream, error) {
@@ -112,6 +119,14 @@ func (s *Stream) SetNoiseProcessor(np NoiseProcessor) {
s.noiseProcessor = np
}
+func (s *Stream) SetEffectsProcessor(ep EffectsProcessor) {
+ s.effectsProcessor = ep
+}
+
+func (s *Stream) GetEffectsProcessor() EffectsProcessor {
+ return s.effectsProcessor
+}
+
func (s *Stream) Destroy() {
if s.link != nil {
@@ -342,12 +357,17 @@ func (s *Stream) sourceRoutine(inputDevice *string) {
if s.noiseProcessor != nil && s.noiseProcessor.IsEnabled() {
s.noiseProcessor.ProcessSamples(int16Buffer)
}
-
+
// Apply AGC to outgoing microphone audio (always enabled)
if s.micAGC != nil {
s.micAGC.ProcessSamples(int16Buffer)
}
-
+
+ // Apply voice effects if available and enabled
+ if s.effectsProcessor != nil && s.effectsProcessor.IsEnabled() {
+ s.effectsProcessor.ProcessSamples(int16Buffer)
+ }
+
outgoing <- gumble.AudioBuffer(int16Buffer)
}
}
diff --git a/main.go b/main.go
index 4e2f22f..e96eda0 100644
--- a/main.go
+++ b/main.go
@@ -16,6 +16,7 @@ import (
"crypto/tls"
"flag"
"github.com/alessio/shellescape"
+ "git.stormux.org/storm/barnard/audio"
"git.stormux.org/storm/barnard/config"
"git.stormux.org/storm/barnard/noise"
@@ -162,6 +163,7 @@ func main() {
Address: *server,
MutedChannels: make(map[uint32]bool),
NoiseSuppressor: noise.NewSuppressor(),
+ VoiceEffects: audio.NewEffectsProcessor(gumble.AudioSampleRate),
}
b.Config.Buffers = *buffers
@@ -176,7 +178,10 @@ func main() {
}
b.NoiseSuppressor.SetEnabled(enabled)
b.NoiseSuppressor.SetThreshold(b.UserConfig.GetNoiseSuppressionThreshold())
-
+
+ // Configure voice effects
+ b.VoiceEffects.SetEffect(audio.VoiceEffect(b.UserConfig.GetVoiceEffect()))
+
b.Config.Username = *username
b.Config.Password = *password
diff --git a/ui.go b/ui.go
index 4ff5bbc..d33a9b0 100644
--- a/ui.go
+++ b/ui.go
@@ -99,7 +99,7 @@ func (b *Barnard) OnNoiseSuppressionToggle(ui *uiterm.Ui, key uiterm.Key) {
enabled := !b.UserConfig.GetNoiseSuppressionEnabled()
b.UserConfig.SetNoiseSuppressionEnabled(enabled)
b.NoiseSuppressor.SetEnabled(enabled)
-
+
if enabled {
b.UpdateGeneralStatus("Noise suppression: ON", false)
} else {
@@ -107,6 +107,12 @@ func (b *Barnard) OnNoiseSuppressionToggle(ui *uiterm.Ui, key uiterm.Key) {
}
}
+func (b *Barnard) OnVoiceEffectCycle(ui *uiterm.Ui, key uiterm.Key) {
+ effect := b.VoiceEffects.CycleEffect()
+ b.UserConfig.SetVoiceEffect(int(effect))
+ b.UpdateGeneralStatus(fmt.Sprintf("Voice effect: %s", effect.String()), false)
+}
+
func (b *Barnard) UpdateGeneralStatus(text string, notice bool) {
if notice {
@@ -323,6 +329,7 @@ func (b *Barnard) OnUiInitialize(ui *uiterm.Ui) {
b.Ui.AddKeyListener(b.OnVoiceToggle, b.Hotkeys.Talk)
b.Ui.AddKeyListener(b.OnTimestampToggle, b.Hotkeys.ToggleTimestamps)
b.Ui.AddKeyListener(b.OnNoiseSuppressionToggle, b.Hotkeys.NoiseSuppressionToggle)
+ b.Ui.AddKeyListener(b.OnVoiceEffectCycle, b.Hotkeys.CycleVoiceEffect)
b.Ui.AddKeyListener(b.OnQuitPress, b.Hotkeys.Exit)
b.Ui.AddKeyListener(b.OnScrollOutputUp, b.Hotkeys.ScrollUp)
b.Ui.AddKeyListener(b.OnScrollOutputDown, b.Hotkeys.ScrollDown)