From 82b308000d76b867120dda57841fc99a900111b7 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sun, 31 Aug 2025 15:33:18 -0400 Subject: [PATCH] Add automatic gain control for outgoing microphone audio. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This addresses low microphone volume issues by automatically normalizing outgoing audio levels with dynamic range compression and soft limiting. The AGC is always enabled and applies voice-optimized parameters to ensure consistent audio levels are sent to other users while preserving manual volume control for incoming audio. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- audio/agc.go | 159 ++++++++++++++++++++++++++++++++++ gumble/gumbleopenal/stream.go | 9 ++ ui.go | 2 + 3 files changed, 170 insertions(+) create mode 100644 audio/agc.go diff --git a/audio/agc.go b/audio/agc.go new file mode 100644 index 0000000..237dbfa --- /dev/null +++ b/audio/agc.go @@ -0,0 +1,159 @@ +package audio + +import ( + "math" +) + +// AGC (Automatic Gain Control) processor for voice normalization +type AGC struct { + targetLevel float32 // Target RMS level (0.0-1.0) + maxGain float32 // Maximum gain multiplier + minGain float32 // Minimum gain multiplier + attackTime float32 // Attack time coefficient + releaseTime float32 // Release time coefficient + currentGain float32 // Current gain value + envelope float32 // Signal envelope + enabled bool // Whether AGC is enabled + compThreshold float32 // Compression threshold + compRatio float32 // Compression ratio +} + +// NewAGC creates a new AGC processor with sensible defaults for voice +func NewAGC() *AGC { + return &AGC{ + targetLevel: 0.15, // Target 15% of max amplitude + maxGain: 6.0, // Maximum 6x gain (about 15.5dB) + minGain: 0.1, // Minimum 0.1x gain (-20dB) + attackTime: 0.001, // Fast attack (1ms) + releaseTime: 0.1, // Slower release (100ms) + currentGain: 1.0, // Start with unity gain + envelope: 0.0, // Start with zero envelope + enabled: true, // Enable by default + compThreshold: 0.7, // Compress signals above 70% + compRatio: 3.0, // 3:1 compression ratio + } +} + +// ProcessSamples applies AGC processing to audio samples +func (agc *AGC) ProcessSamples(samples []int16) { + if !agc.enabled || len(samples) == 0 { + return + } + + // Convert samples to float32 for processing + floatSamples := make([]float32, len(samples)) + for i, sample := range samples { + floatSamples[i] = float32(sample) / 32768.0 + } + + // Calculate RMS level for gain control + rmsSum := float32(0.0) + for _, sample := range floatSamples { + rmsSum += sample * sample + } + rms := float32(math.Sqrt(float64(rmsSum / float32(len(floatSamples))))) + + // Update envelope with peak detection for compression + peak := float32(0.0) + for _, sample := range floatSamples { + absample := float32(math.Abs(float64(sample))) + if absample > peak { + peak = absample + } + } + + // Envelope following + if peak > agc.envelope { + agc.envelope += (peak - agc.envelope) * agc.attackTime + } else { + agc.envelope += (peak - agc.envelope) * agc.releaseTime + } + + // Calculate desired gain based on RMS + var desiredGain float32 + if rms > 0.001 { // Avoid division by zero for very quiet signals + desiredGain = agc.targetLevel / rms + } else { + desiredGain = agc.maxGain // Boost very quiet signals + } + + // Apply gain limits + if desiredGain > agc.maxGain { + desiredGain = agc.maxGain + } + if desiredGain < agc.minGain { + desiredGain = agc.minGain + } + + // Smooth gain changes + if desiredGain > agc.currentGain { + agc.currentGain += (desiredGain - agc.currentGain) * agc.attackTime * 0.1 + } else { + agc.currentGain += (desiredGain - agc.currentGain) * agc.releaseTime + } + + // Apply AGC gain to samples + for i, sample := range floatSamples { + processed := sample * agc.currentGain + + // Apply compression for loud signals + if agc.envelope > agc.compThreshold { + // Calculate compression amount + overage := agc.envelope - agc.compThreshold + compAmount := overage / agc.compRatio + compGain := (agc.compThreshold + compAmount) / agc.envelope + processed *= compGain + } + + // Soft limiting to prevent clipping + if processed > 0.95 { + processed = 0.95 + (processed-0.95)*0.1 + } else if processed < -0.95 { + processed = -0.95 + (processed+0.95)*0.1 + } + + // Convert back to int16 + intSample := int32(processed * 32767.0) + if intSample > 32767 { + intSample = 32767 + } else if intSample < -32767 { + intSample = -32767 + } + samples[i] = int16(intSample) + } +} + +// SetEnabled enables or disables AGC processing +func (agc *AGC) SetEnabled(enabled bool) { + agc.enabled = enabled +} + +// IsEnabled returns whether AGC is enabled +func (agc *AGC) IsEnabled() bool { + return agc.enabled +} + +// SetTargetLevel sets the target RMS level (0.0-1.0) +func (agc *AGC) SetTargetLevel(level float32) { + if level > 0.0 && level < 1.0 { + agc.targetLevel = level + } +} + +// SetMaxGain sets the maximum gain multiplier +func (agc *AGC) SetMaxGain(gain float32) { + if gain > 1.0 && gain <= 10.0 { + agc.maxGain = gain + } +} + +// GetCurrentGain returns the current gain being applied +func (agc *AGC) GetCurrentGain() float32 { + return agc.currentGain +} + +// Reset resets the AGC state +func (agc *AGC) Reset() { + agc.currentGain = 1.0 + agc.envelope = 0.0 +} \ No newline at end of file diff --git a/gumble/gumbleopenal/stream.go b/gumble/gumbleopenal/stream.go index d03426f..bdf346b 100644 --- a/gumble/gumbleopenal/stream.go +++ b/gumble/gumbleopenal/stream.go @@ -6,6 +6,7 @@ import ( "os/exec" "time" + "git.stormux.org/storm/barnard/audio" "git.stormux.org/storm/barnard/gumble/gumble" "git.stormux.org/storm/barnard/gumble/go-openal/openal" ) @@ -50,6 +51,7 @@ type Stream struct { contextSink *openal.Context noiseProcessor NoiseProcessor + micAGC *audio.AGC } func New(client *gumble.Client, inputDevice *string, outputDevice *string, test bool) (*Stream, error) { @@ -80,6 +82,7 @@ func New(client *gumble.Client, inputDevice *string, outputDevice *string, test client: client, sourceFrameSize: frmsz, micVolume: 1.0, + micAGC: audio.NewAGC(), // Always enable AGC for outgoing mic } s.deviceSource = idev @@ -109,6 +112,7 @@ func (s *Stream) SetNoiseProcessor(np NoiseProcessor) { s.noiseProcessor = np } + func (s *Stream) Destroy() { if s.link != nil { s.link.Detach() @@ -339,6 +343,11 @@ func (s *Stream) sourceRoutine(inputDevice *string) { s.noiseProcessor.ProcessSamples(int16Buffer) } + // Apply AGC to outgoing microphone audio (always enabled) + if s.micAGC != nil { + s.micAGC.ProcessSamples(int16Buffer) + } + outgoing <- gumble.AudioBuffer(int16Buffer) } } diff --git a/ui.go b/ui.go index 6037756..4ff5bbc 100644 --- a/ui.go +++ b/ui.go @@ -107,6 +107,7 @@ func (b *Barnard) OnNoiseSuppressionToggle(ui *uiterm.Ui, key uiterm.Key) { } } + func (b *Barnard) UpdateGeneralStatus(text string, notice bool) { if notice { b.UiStatus.Fg = uiterm.ColorWhite | uiterm.AttrBold @@ -151,6 +152,7 @@ func (b *Barnard) CommandNoiseSuppressionToggle(ui *uiterm.Ui, cmd string) { } } + func (b *Barnard) setTransmit(ui *uiterm.Ui, val int) { if b.Tx && val == 1 { return