Add F12 hotkey to toggle microphone AGC

Automatic gain control was always on with no way to turn it off. Toggle
it with F12, the /agc command, or the agc FIFO command, and persist the
choice in the configuration file the same way noise suppression does.

AgcEnabled defaults to true so existing setups keep their current
behavior, and the saved value is applied to the stream on connect. The
enabled flag becomes an atomic.Bool because the capture goroutine reads
it while the UI goroutine writes it, and the lazily created right
channel AGC now inherits the left channel's state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tyler Spivey
2026-08-14 13:16:44 -07:00
co-authored by Claude Opus 5
parent 132df6863a
commit 4393739ffa
8 changed files with 156 additions and 25 deletions
+16
View File
@@ -40,6 +40,20 @@ noisesuppressionenabled = true
RNNoise is a required build and runtime dependency. RNNoise is a required build and runtime dependency.
## Automatic Gain Control
Barnard normalizes the level of your outgoing microphone audio with automatic gain control (AGC), which boosts quiet speech and compresses loud peaks. AGC is enabled by default.
### Controls
- **F12 key**: Toggle AGC on/off (configurable hotkey)
- **FIFO command**: Send `agc` command to toggle during runtime
- **Configuration**: Set `agcenabled` in `~/.barnard.toml`
### Configuration Example
```toml
agcenabled = true
```
## FIFO Control ## FIFO Control
If you pass the --fifo option to Barnard, a FIFO pipe will be created. If you pass the --fifo option to Barnard, a FIFO pipe will be created.
@@ -54,6 +68,7 @@ Current Commands:
* toggle: Toggle your transmission state. * toggle: Toggle your transmission state.
* talk: Synonym for toggle. * talk: Synonym for toggle.
* noise: Toggle noise suppression on/off for microphone input. * noise: Toggle noise suppression on/off for microphone input.
* agc: Toggle automatic gain control on/off for microphone input.
* record: Toggle recording. You may also use `record start` or `record stop`. * record: Toggle recording. You may also use `record start` or `record stop`.
* exit: Exit Barnard, just like when you press your quit key. * exit: Exit Barnard, just like when you press your quit key.
@@ -278,6 +293,7 @@ After running the command above, `barnard` will be compiled as `$(go env GOPATH)
- <kbd>F1</kbd>: toggle voice transmission - <kbd>F1</kbd>: toggle voice transmission
- <kbd>F9</kbd>: toggle noise suppression - <kbd>F9</kbd>: toggle noise suppression
- <kbd>F12</kbd>: toggle automatic gain control
- <kbd>F11</kbd>: open actions menu for the focused tree item - <kbd>F11</kbd>: open actions menu for the focused tree item
- <kbd>Ctrl+R</kbd>: toggle recording - <kbd>Ctrl+R</kbd>: toggle recording
- <kbd>Ctrl+L</kbd>: clear chat log - <kbd>Ctrl+L</kbd>: clear chat log
+26 -24
View File
@@ -2,41 +2,43 @@ package audio
import ( import (
"math" "math"
"sync/atomic"
) )
// AGC (Automatic Gain Control) processor for voice normalization // AGC (Automatic Gain Control) processor for voice normalization
type AGC struct { type AGC struct {
targetLevel float32 // Target RMS level (0.0-1.0) targetLevel float32 // Target RMS level (0.0-1.0)
maxGain float32 // Maximum gain multiplier maxGain float32 // Maximum gain multiplier
minGain float32 // Minimum gain multiplier minGain float32 // Minimum gain multiplier
attackTime float32 // Attack time coefficient attackTime float32 // Attack time coefficient
releaseTime float32 // Release time coefficient releaseTime float32 // Release time coefficient
currentGain float32 // Current gain value currentGain float32 // Current gain value
envelope float32 // Signal envelope envelope float32 // Signal envelope
enabled bool // Whether AGC is enabled enabled atomic.Bool // Whether AGC is enabled; toggled outside the capture goroutine
compThreshold float32 // Compression threshold compThreshold float32 // Compression threshold
compRatio float32 // Compression ratio compRatio float32 // Compression ratio
} }
// NewAGC creates a new AGC processor with sensible defaults for voice // NewAGC creates a new AGC processor with sensible defaults for voice
func NewAGC() *AGC { func NewAGC() *AGC {
return &AGC{ agc := &AGC{
targetLevel: 0.12, // Target 12% of max amplitude (conservative level) targetLevel: 0.12, // Target 12% of max amplitude (conservative level)
maxGain: 4.0, // Maximum 4x gain (about 12dB) maxGain: 4.0, // Maximum 4x gain (about 12dB)
minGain: 0.25, // Minimum 0.25x gain (-12dB) minGain: 0.25, // Minimum 0.25x gain (-12dB)
attackTime: 0.008, // Fast attack (8ms) attackTime: 0.008, // Fast attack (8ms)
releaseTime: 0.15, // Slower release (150ms) releaseTime: 0.15, // Slower release (150ms)
currentGain: 1.0, // Start with unity gain currentGain: 1.0, // Start with unity gain
envelope: 0.0, // Start with zero envelope envelope: 0.0, // Start with zero envelope
enabled: true, // Enable by default compThreshold: 0.85, // Compress signals above 85%
compThreshold: 0.85, // Compress signals above 85% compRatio: 2.0, // 2:1 compression ratio (gentler)
compRatio: 2.0, // 2:1 compression ratio (gentler)
} }
agc.enabled.Store(true) // Enable by default
return agc
} }
// ProcessSamples applies AGC processing to audio samples // ProcessSamples applies AGC processing to audio samples
func (agc *AGC) ProcessSamples(samples []int16) { func (agc *AGC) ProcessSamples(samples []int16) {
if !agc.enabled || len(samples) == 0 { if !agc.enabled.Load() || len(samples) == 0 {
return return
} }
@@ -125,12 +127,12 @@ func (agc *AGC) ProcessSamples(samples []int16) {
// SetEnabled enables or disables AGC processing // SetEnabled enables or disables AGC processing
func (agc *AGC) SetEnabled(enabled bool) { func (agc *AGC) SetEnabled(enabled bool) {
agc.enabled = enabled agc.enabled.Store(enabled)
} }
// IsEnabled returns whether AGC is enabled // IsEnabled returns whether AGC is enabled
func (agc *AGC) IsEnabled() bool { func (agc *AGC) IsEnabled() bool {
return agc.enabled return agc.enabled.Load()
} }
// SetTargetLevel sets the target RMS level (0.0-1.0) // SetTargetLevel sets the target RMS level (0.0-1.0)
+1
View File
@@ -90,6 +90,7 @@ func (b *Barnard) connect(reconnect bool) bool {
stream.SetMicVolume(b.UserConfig.GetMicVolume(), false) stream.SetMicVolume(b.UserConfig.GetMicVolume(), false)
stream.AttachStream(b.Client) stream.AttachStream(b.Client)
stream.SetNoiseProcessor(b.NoiseSuppressor) stream.SetNoiseProcessor(b.NoiseSuppressor)
stream.SetAGCEnabled(b.UserConfig.GetAGCEnabled())
stream.SetErrorFunc(func(err error) { stream.SetErrorFunc(func(err error) {
if err != nil { if err != nil {
b.AddOutputLine(fmt.Sprintf("Microphone: %s", err.Error())) b.AddOutputLine(fmt.Sprintf("Microphone: %s", err.Error()))
+1
View File
@@ -21,4 +21,5 @@ type Hotkeys struct {
ScrollToBottom *uiterm.Key ScrollToBottom *uiterm.Key
AdminMenu *uiterm.Key AdminMenu *uiterm.Key
NoiseSuppressionToggle *uiterm.Key NoiseSuppressionToggle *uiterm.Key
AGCToggle *uiterm.Key
} }
+27
View File
@@ -31,6 +31,7 @@ type exportableConfig struct {
Username *string Username *string
NotifyCommand *string NotifyCommand *string
NoiseSuppressionEnabled *bool NoiseSuppressionEnabled *bool
AGCEnabled *bool
Certificate *string Certificate *string
RecordingFormat *string RecordingFormat *string
RecordingDirectory *string RecordingDirectory *string
@@ -109,6 +110,7 @@ func (c *Config) LoadConfig() {
ScrollToBottom: key(uiterm.KeyEnd), ScrollToBottom: key(uiterm.KeyEnd),
AdminMenu: key(uiterm.KeyF11), AdminMenu: key(uiterm.KeyF11),
NoiseSuppressionToggle: key(uiterm.KeyF9), NoiseSuppressionToggle: key(uiterm.KeyF9),
AGCToggle: key(uiterm.KeyF12),
} }
if fileExists(c.fn) { if fileExists(c.fn) {
var data []byte var data []byte
@@ -155,6 +157,11 @@ func (c *Config) LoadConfig() {
enabled := false enabled := false
jc.NoiseSuppressionEnabled = &enabled 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 { if c.config.Certificate == nil {
cert := string("") cert := string("")
jc.Certificate = &cert jc.Certificate = &cert
@@ -190,6 +197,7 @@ func (c *Config) ensureHotkeys() {
ScrollToBottom: key(uiterm.KeyEnd), ScrollToBottom: key(uiterm.KeyEnd),
AdminMenu: key(uiterm.KeyF11), AdminMenu: key(uiterm.KeyF11),
NoiseSuppressionToggle: key(uiterm.KeyF9), NoiseSuppressionToggle: key(uiterm.KeyF9),
AGCToggle: key(uiterm.KeyF12),
} }
hotkeys := c.config.Hotkeys hotkeys := c.config.Hotkeys
if hotkeys.Talk == nil { if hotkeys.Talk == nil {
@@ -240,6 +248,9 @@ func (c *Config) ensureHotkeys() {
if hotkeys.NoiseSuppressionToggle == nil { if hotkeys.NoiseSuppressionToggle == nil {
hotkeys.NoiseSuppressionToggle = defaults.NoiseSuppressionToggle hotkeys.NoiseSuppressionToggle = defaults.NoiseSuppressionToggle
} }
if hotkeys.AGCToggle == nil {
hotkeys.AGCToggle = defaults.AGCToggle
}
} }
func (c *Config) findServer(address string) *server { func (c *Config) findServer(address string) *server {
@@ -365,6 +376,22 @@ func (c *Config) SetNoiseSuppressionEnabled(enabled bool) error {
return c.saveConfigLocked() 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 { func (c *Config) GetRecordingFormat() string {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
+29
View File
@@ -74,6 +74,35 @@ func TestConfigBackfillsRecordingDefaults(t *testing.T) {
} }
} }
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 // Regression: malformed and IPv6 addresses were split at every colon and
// could panic while merely reading a saved user preference. // could panic while merely reading a saved user preference.
func TestMakeHostPortHandlesIPv6AndMalformedAddress(t *testing.T) { func TestMakeHostPortHandlesIPv6AndMalformedAddress(t *testing.T) {
+21 -1
View File
@@ -288,6 +288,23 @@ func (s *Stream) SetNoiseProcessor(np NoiseProcessor) {
s.noiseProcessorRight = cloneNoiseProcessor(np) s.noiseProcessorRight = cloneNoiseProcessor(np)
} }
// SetAGCEnabled turns microphone automatic gain control on or off. The AGC
// objects themselves are created up front, so this only flips their flag and is
// safe to call while capture is running.
func (s *Stream) SetAGCEnabled(enabled bool) {
if s.micAGC != nil {
s.micAGC.SetEnabled(enabled)
}
if s.micAGCRight != nil {
s.micAGCRight.SetEnabled(enabled)
}
}
// IsAGCEnabled reports whether microphone automatic gain control is active.
func (s *Stream) IsAGCEnabled() bool {
return s.micAGC != nil && s.micAGC.IsEnabled()
}
func (s *Stream) SetFilePlayer(fp FilePlayer) { func (s *Stream) SetFilePlayer(fp FilePlayer) {
s.filePlayer = fp s.filePlayer = fp
if player, ok := fp.(interface{ SetLocalPlayback(func([]byte)) }); ok { if player, ok := fp.(interface{ SetLocalPlayback(func([]byte)) }); ok {
@@ -1073,7 +1090,7 @@ func (s *Stream) processChannel(samples []int16, noiseProcessor NoiseProcessor,
if noiseProcessor != nil && noiseProcessor.IsEnabled() { if noiseProcessor != nil && noiseProcessor.IsEnabled() {
noiseProcessor.ProcessSamples(samples) noiseProcessor.ProcessSamples(samples)
} }
if micAGC != nil { if micAGC != nil && micAGC.IsEnabled() {
micAGC.ProcessSamples(samples) micAGC.ProcessSamples(samples)
} }
} }
@@ -1081,6 +1098,9 @@ func (s *Stream) processChannel(samples []int16, noiseProcessor NoiseProcessor,
func (s *Stream) ensureStereoProcessors() { func (s *Stream) ensureStereoProcessors() {
if s.micAGCRight == nil { if s.micAGCRight == nil {
s.micAGCRight = audio.NewAGC() s.micAGCRight = audio.NewAGC()
if s.micAGC != nil {
s.micAGCRight.SetEnabled(s.micAGC.IsEnabled())
}
} }
if s.noiseProcessorRight == nil { if s.noiseProcessorRight == nil {
s.noiseProcessorRight = cloneNoiseProcessor(s.noiseProcessor) s.noiseProcessorRight = cloneNoiseProcessor(s.noiseProcessor)
+35
View File
@@ -141,6 +141,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())
}
b.withStream(func(stream *gumbleopenal.Stream) {
stream.SetAGCEnabled(enabled)
})
return enabled
}
func (b *Barnard) UpdateGeneralStatus(text string, notice bool) { func (b *Barnard) UpdateGeneralStatus(text string, notice bool) {
b.postUI(func() { b.postUI(func() {
b.statusText = text b.statusText = text
@@ -212,6 +235,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) { func (b *Barnard) CommandPlayFile(ui *uiterm.Ui, cmd string) {
// cmd contains just the filename part (everything after "/file ") // cmd contains just the filename part (everything after "/file ")
filename := strings.TrimSpace(cmd) filename := strings.TrimSpace(cmd)
@@ -498,6 +529,8 @@ func (b *Barnard) OnTextInput(ui *uiterm.Ui, textbox *uiterm.Textbox, text strin
b.CommandStatus(ui, cmdArgs) b.CommandStatus(ui, cmdArgs)
case "noise": case "noise":
b.CommandNoiseSuppressionToggle(ui, cmdArgs) b.CommandNoiseSuppressionToggle(ui, cmdArgs)
case "agc":
b.CommandAGCToggle(ui, cmdArgs)
case "record": case "record":
b.CommandRecord(ui, cmdArgs) b.CommandRecord(ui, cmdArgs)
case "admin": case "admin":
@@ -595,6 +628,7 @@ func (b *Barnard) OnUiInitialize(ui *uiterm.Ui) {
b.Ui.AddCommandListener(b.CommandExit, "exit") b.Ui.AddCommandListener(b.CommandExit, "exit")
b.Ui.AddCommandListener(b.CommandStatus, "status") b.Ui.AddCommandListener(b.CommandStatus, "status")
b.Ui.AddCommandListener(b.CommandNoiseSuppressionToggle, "noise") b.Ui.AddCommandListener(b.CommandNoiseSuppressionToggle, "noise")
b.Ui.AddCommandListener(b.CommandAGCToggle, "agc")
b.Ui.AddCommandListener(b.CommandPlayFile, "file") b.Ui.AddCommandListener(b.CommandPlayFile, "file")
b.Ui.AddCommandListener(b.CommandStopFile, "stop") b.Ui.AddCommandListener(b.CommandStopFile, "stop")
b.Ui.AddCommandListener(b.CommandRecord, "record") b.Ui.AddCommandListener(b.CommandRecord, "record")
@@ -604,6 +638,7 @@ func (b *Barnard) OnUiInitialize(ui *uiterm.Ui) {
b.Ui.AddKeyListener(b.OnVoiceToggle, b.Hotkeys.Talk) b.Ui.AddKeyListener(b.OnVoiceToggle, b.Hotkeys.Talk)
b.Ui.AddKeyListener(b.OnTimestampToggle, b.Hotkeys.ToggleTimestamps) b.Ui.AddKeyListener(b.OnTimestampToggle, b.Hotkeys.ToggleTimestamps)
b.Ui.AddKeyListener(b.OnNoiseSuppressionToggle, b.Hotkeys.NoiseSuppressionToggle) 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.OnRecordingToggle, b.Hotkeys.RecordToggle)
b.Ui.AddKeyListener(b.OnClearPress, b.Hotkeys.ClearOutput) b.Ui.AddKeyListener(b.OnClearPress, b.Hotkeys.ClearOutput)
b.Ui.AddKeyListener(b.OnQuitPress, b.Hotkeys.Exit) b.Ui.AddKeyListener(b.OnQuitPress, b.Hotkeys.Exit)