Add automatic gain control for outgoing microphone audio.
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 <noreply@anthropic.com>
This commit is contained in:
		
							
								
								
									
										159
									
								
								audio/agc.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										159
									
								
								audio/agc.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
|  | } | ||||||
| @@ -6,6 +6,7 @@ import ( | |||||||
|     "os/exec" |     "os/exec" | ||||||
|     "time" |     "time" | ||||||
|  |  | ||||||
|  |     "git.stormux.org/storm/barnard/audio" | ||||||
|     "git.stormux.org/storm/barnard/gumble/gumble" |     "git.stormux.org/storm/barnard/gumble/gumble" | ||||||
|     "git.stormux.org/storm/barnard/gumble/go-openal/openal" |     "git.stormux.org/storm/barnard/gumble/go-openal/openal" | ||||||
| ) | ) | ||||||
| @@ -50,6 +51,7 @@ type Stream struct { | |||||||
|     contextSink *openal.Context |     contextSink *openal.Context | ||||||
|      |      | ||||||
|     noiseProcessor NoiseProcessor |     noiseProcessor NoiseProcessor | ||||||
|  |     micAGC         *audio.AGC | ||||||
| } | } | ||||||
|  |  | ||||||
| func New(client *gumble.Client, inputDevice *string, outputDevice *string, test bool) (*Stream, error) { | 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, |         client:          client, | ||||||
|         sourceFrameSize: frmsz, |         sourceFrameSize: frmsz, | ||||||
|         micVolume:      1.0, |         micVolume:      1.0, | ||||||
|  |         micAGC:         audio.NewAGC(), // Always enable AGC for outgoing mic | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     s.deviceSource = idev |     s.deviceSource = idev | ||||||
| @@ -109,6 +112,7 @@ func (s *Stream) SetNoiseProcessor(np NoiseProcessor) { | |||||||
|     s.noiseProcessor = np |     s.noiseProcessor = np | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| func (s *Stream) Destroy() { | func (s *Stream) Destroy() { | ||||||
|     if s.link != nil { |     if s.link != nil { | ||||||
|         s.link.Detach() |         s.link.Detach() | ||||||
| @@ -339,6 +343,11 @@ func (s *Stream) sourceRoutine(inputDevice *string) { | |||||||
|                 s.noiseProcessor.ProcessSamples(int16Buffer) |                 s.noiseProcessor.ProcessSamples(int16Buffer) | ||||||
|             } |             } | ||||||
|              |              | ||||||
|  |             // Apply AGC to outgoing microphone audio (always enabled) | ||||||
|  |             if s.micAGC != nil { | ||||||
|  |                 s.micAGC.ProcessSamples(int16Buffer) | ||||||
|  |             } | ||||||
|  |              | ||||||
|             outgoing <- gumble.AudioBuffer(int16Buffer) |             outgoing <- gumble.AudioBuffer(int16Buffer) | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|   | |||||||
							
								
								
									
										2
									
								
								ui.go
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								ui.go
									
									
									
									
									
								
							| @@ -107,6 +107,7 @@ func (b *Barnard) OnNoiseSuppressionToggle(ui *uiterm.Ui, key uiterm.Key) { | |||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| func (b *Barnard) UpdateGeneralStatus(text string, notice bool) { | func (b *Barnard) UpdateGeneralStatus(text string, notice bool) { | ||||||
|     if notice { |     if notice { | ||||||
|         b.UiStatus.Fg = uiterm.ColorWhite | uiterm.AttrBold |         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) { | func (b *Barnard) setTransmit(ui *uiterm.Ui, val int) { | ||||||
|     if b.Tx && val == 1 { |     if b.Tx && val == 1 { | ||||||
|         return |         return | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user