From cb4f91596e6ab2ff01941919d32aaefdc683ae48 Mon Sep 17 00:00:00 2001 From: Brandon McGinty Date: Thu, 20 Aug 2026 14:31:31 -0400 Subject: [PATCH] make microphone AGC less aggressive and switchable Lower the gain ceiling and slow the attack of the automatic gain control. It amplified room noise between words hard enough to be audible as a rising hiss whenever the speaker paused. Add an AGC toggle on F12 and an /agc command. AGC has always been applied unconditionally, which is wrong for a microphone that is already levelled by hardware or by the system mixer. The preference is saved and reapplied on connect, defaulting to on so existing setups are unaffected. Co-Authored-By: Claude Opus 5 --- audio/agc.go | 58 ++++++++++++++++++++------------------ client.go | 1 + config/hotkey_config.go | 1 + config/user_config.go | 27 ++++++++++++++++++ config/user_config_test.go | 32 +++++++++++++++++++++ ui.go | 35 +++++++++++++++++++++++ 6 files changed, 126 insertions(+), 28 deletions(-) diff --git a/audio/agc.go b/audio/agc.go index f4ca407..3559fa7 100644 --- a/audio/agc.go +++ b/audio/agc.go @@ -2,41 +2,43 @@ package audio import ( "math" + "sync/atomic" ) // 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 + 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 atomic.Bool // Whether AGC is enabled; toggled outside the capture goroutine + 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.18, // Target 18% of max amplitude (balanced level) - maxGain: 8.0, // Maximum 8x gain (about 18dB) - minGain: 0.1, // Minimum 0.1x gain (-20dB) - attackTime: 0.005, // Fast attack (5ms) - 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 + agc := &AGC{ + targetLevel: 0.12, // Target 12% of max amplitude (conservative level) + maxGain: 4.0, // Maximum 4x gain (about 12dB) + minGain: 0.25, // Minimum 0.25x gain (-12dB) + attackTime: 0.008, // Fast attack (8ms) + releaseTime: 0.15, // Slower release (150ms) + currentGain: 1.0, // Start with unity gain + envelope: 0.0, // Start with zero envelope + compThreshold: 0.85, // Compress signals above 85% + compRatio: 2.0, // 2:1 compression ratio (gentler) } + agc.enabled.Store(true) // Enable by default + return agc } // ProcessSamples applies AGC processing to audio samples func (agc *AGC) ProcessSamples(samples []int16) { - if !agc.enabled || len(samples) == 0 { + if !agc.enabled.Load() || len(samples) == 0 { return } @@ -106,10 +108,10 @@ func (agc *AGC) ProcessSamples(samples []int16) { } // Soft limiting to prevent clipping - if processed > 0.90 { - processed = 0.90 + (processed-0.90)*0.1 - } else if processed < -0.90 { - processed = -0.90 + (processed+0.90)*0.1 + if processed > 0.95 { + processed = 0.95 + (processed-0.95)*0.2 + } else if processed < -0.95 { + processed = -0.95 + (processed+0.95)*0.2 } // Convert back to int16 @@ -125,12 +127,12 @@ func (agc *AGC) ProcessSamples(samples []int16) { // SetEnabled enables or disables AGC processing func (agc *AGC) SetEnabled(enabled bool) { - agc.enabled = enabled + agc.enabled.Store(enabled) } // IsEnabled returns whether AGC is enabled func (agc *AGC) IsEnabled() bool { - return agc.enabled + return agc.enabled.Load() } // SetTargetLevel sets the target RMS level (0.0-1.0) diff --git a/client.go b/client.go index df4f31a..9a03da1 100644 --- a/client.go +++ b/client.go @@ -53,6 +53,7 @@ func (b *Barnard) connect(reconnect bool) bool { b.Stream = stream b.Stream.AttachStream(b.Client) b.Stream.SetNoiseProcessor(b.NoiseSuppressor) + b.Stream.SetAGCEnabled(b.UserConfig.GetAGCEnabled()) // Initialize stereo encoder for file playback b.Client.AudioEncoderStereo = opus.NewStereoEncoder() diff --git a/config/hotkey_config.go b/config/hotkey_config.go index c4244a1..2eaf883 100644 --- a/config/hotkey_config.go +++ b/config/hotkey_config.go @@ -21,4 +21,5 @@ type Hotkeys struct { ScrollToBottom *uiterm.Key AdminMenu *uiterm.Key NoiseSuppressionToggle *uiterm.Key + AGCToggle *uiterm.Key } diff --git a/config/user_config.go b/config/user_config.go index 3fdfdc4..dd21265 100644 --- a/config/user_config.go +++ b/config/user_config.go @@ -31,6 +31,7 @@ type exportableConfig struct { Username *string NotifyCommand *string NoiseSuppressionEnabled *bool + AGCEnabled *bool Certificate *string RecordingFormat *string RecordingDirectory *string @@ -109,6 +110,7 @@ func (c *Config) LoadConfig() { ScrollToBottom: key(uiterm.KeyEnd), AdminMenu: key(uiterm.KeyF11), NoiseSuppressionToggle: key(uiterm.KeyF9), + AGCToggle: key(uiterm.KeyF12), } if fileExists(c.fn) { var data []byte @@ -155,6 +157,11 @@ func (c *Config) LoadConfig() { enabled := false jc.NoiseSuppressionEnabled = &enabled } + if c.config.AGCEnabled == nil { + // AGC has always been active for the microphone, so keep it on by default. + enabled := true + jc.AGCEnabled = &enabled + } if c.config.Certificate == nil { cert := string("") jc.Certificate = &cert @@ -190,6 +197,7 @@ func (c *Config) ensureHotkeys() { ScrollToBottom: key(uiterm.KeyEnd), AdminMenu: key(uiterm.KeyF11), NoiseSuppressionToggle: key(uiterm.KeyF9), + AGCToggle: key(uiterm.KeyF12), } hotkeys := c.config.Hotkeys if hotkeys.Talk == nil { @@ -240,6 +248,9 @@ func (c *Config) ensureHotkeys() { if hotkeys.NoiseSuppressionToggle == nil { hotkeys.NoiseSuppressionToggle = defaults.NoiseSuppressionToggle } + if hotkeys.AGCToggle == nil { + hotkeys.AGCToggle = defaults.AGCToggle + } } func (c *Config) findServer(address string) *server { @@ -356,6 +367,22 @@ func (c *Config) SetNoiseSuppressionEnabled(enabled bool) error { return c.saveConfigLocked() } +func (c *Config) GetAGCEnabled() bool { + c.mu.Lock() + defer c.mu.Unlock() + if c.config.AGCEnabled == nil { + return true + } + return *c.config.AGCEnabled +} + +func (c *Config) SetAGCEnabled(enabled bool) error { + c.mu.Lock() + defer c.mu.Unlock() + c.config.AGCEnabled = &enabled + return c.saveConfigLocked() +} + func (c *Config) GetRecordingFormat() string { c.mu.Lock() defer c.mu.Unlock() diff --git a/config/user_config_test.go b/config/user_config_test.go index 2b01c81..f37b3cf 100644 --- a/config/user_config_test.go +++ b/config/user_config_test.go @@ -73,6 +73,38 @@ func TestConfigBackfillsRecordingDefaults(t *testing.T) { t.Fatalf("expected scroll to bottom end, got %s", got) } } + +func TestAGCDefaultsOnAndPersists(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "barnard.toml") + if err := os.WriteFile(configPath, []byte("[hotkeys]\ntalk = \"f1\"\n"), 0600); err != nil { + t.Fatal(err) + } + + cfg := NewConfig(&configPath) + if !cfg.GetAGCEnabled() { + t.Fatal("expected AGC to default to enabled") + } + if cfg.GetHotkeys().AGCToggle == nil { + t.Fatal("expected AGC toggle hotkey to be backfilled") + } + if got := *cfg.GetHotkeys().AGCToggle; got != uiterm.KeyF12 { + t.Fatalf("expected AGC toggle f12, got %s", got) + } + + if err := cfg.SetAGCEnabled(false); err != nil { + t.Fatal(err) + } + reloaded := NewConfig(&configPath) + if reloaded.GetAGCEnabled() { + t.Fatal("expected disabled AGC setting to persist") + } + if got := *reloaded.GetHotkeys().AGCToggle; got != uiterm.KeyF12 { + t.Fatalf("expected saved AGC toggle to reload as f12, got %s", got) + } +} + +// Regression: malformed and IPv6 addresses were split at every colon and +// could panic while merely reading a saved user preference. func TestMakeHostPortHandlesIPv6AndMalformedAddress(t *testing.T) { host, port := makeHostPort("[2001:db8::1]:64739") if host != "2001:db8::1" || port != 64739 { diff --git a/ui.go b/ui.go index ac7b9c0..21f9ae3 100644 --- a/ui.go +++ b/ui.go @@ -111,6 +111,29 @@ func (b *Barnard) OnNoiseSuppressionToggle(ui *uiterm.Ui, key uiterm.Key) { } } +func (b *Barnard) OnAGCToggle(ui *uiterm.Ui, key uiterm.Key) { + enabled := b.toggleAGC() + + if enabled { + b.UpdateGeneralStatus("AGC: ON", false) + } else { + b.UpdateGeneralStatus("AGC: OFF", false) + } +} + +// toggleAGC flips the saved AGC preference and applies it to the active +// stream, returning the new state. +func (b *Barnard) toggleAGC() bool { + enabled := !b.UserConfig.GetAGCEnabled() + if err := b.UserConfig.SetAGCEnabled(enabled); err != nil { + b.AddOutputLine("AGC: could not save setting: " + err.Error()) + } + if b.Stream != nil { + b.Stream.SetAGCEnabled(enabled) + } + return enabled +} + func (b *Barnard) UpdateGeneralStatus(text string, notice bool) { b.statusText = text b.statusNotice = notice @@ -175,6 +198,14 @@ func (b *Barnard) CommandNoiseSuppressionToggle(ui *uiterm.Ui, cmd string) { } } +func (b *Barnard) CommandAGCToggle(ui *uiterm.Ui, cmd string) { + if b.toggleAGC() { + b.AddOutputLine("AGC enabled") + } else { + b.AddOutputLine("AGC disabled") + } +} + func (b *Barnard) CommandPlayFile(ui *uiterm.Ui, cmd string) { // cmd contains just the filename part (everything after "/file ") filename := strings.TrimSpace(cmd) @@ -397,6 +428,8 @@ func (b *Barnard) OnTextInput(ui *uiterm.Ui, textbox *uiterm.Textbox, text strin b.CommandStatus(ui, cmdArgs) case "noise": b.CommandNoiseSuppressionToggle(ui, cmdArgs) + case "agc": + b.CommandAGCToggle(ui, cmdArgs) case "record": b.CommandRecord(ui, cmdArgs) case "admin": @@ -493,6 +526,7 @@ func (b *Barnard) OnUiInitialize(ui *uiterm.Ui) { b.Ui.AddCommandListener(b.CommandExit, "exit") b.Ui.AddCommandListener(b.CommandStatus, "status") b.Ui.AddCommandListener(b.CommandNoiseSuppressionToggle, "noise") + b.Ui.AddCommandListener(b.CommandAGCToggle, "agc") b.Ui.AddCommandListener(b.CommandPlayFile, "file") b.Ui.AddCommandListener(b.CommandStopFile, "stop") b.Ui.AddCommandListener(b.CommandRecord, "record") @@ -502,6 +536,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.OnAGCToggle, b.Hotkeys.AGCToggle) b.Ui.AddKeyListener(b.OnRecordingToggle, b.Hotkeys.RecordToggle) b.Ui.AddKeyListener(b.OnQuitPress, b.Hotkeys.Exit) b.Ui.AddKeyListener(b.OnScrollOutputUp, b.Hotkeys.ScrollUp)