diff --git a/README.md b/README.md
index 6fa13fe..eebe5e6 100644
--- a/README.md
+++ b/README.md
@@ -40,6 +40,20 @@ noisesuppressionenabled = true
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
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.
* talk: Synonym for toggle.
* 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`.
* 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)
- F1: toggle voice transmission
- F9: toggle noise suppression
+- F12: toggle automatic gain control
- F11: open actions menu for the focused tree item
- Ctrl+R: toggle recording
- Ctrl+L: clear chat log
diff --git a/audio/agc.go b/audio/agc.go
index 9dc2119..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.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
- enabled: true, // Enable by default
- compThreshold: 0.85, // Compress signals above 85%
- compRatio: 2.0, // 2:1 compression ratio (gentler)
+ 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
}
@@ -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 9c3a6ec..3d4e7a4 100644
--- a/client.go
+++ b/client.go
@@ -90,6 +90,7 @@ func (b *Barnard) connect(reconnect bool) bool {
stream.SetMicVolume(b.UserConfig.GetMicVolume(), false)
stream.AttachStream(b.Client)
stream.SetNoiseProcessor(b.NoiseSuppressor)
+ stream.SetAGCEnabled(b.UserConfig.GetAGCEnabled())
stream.SetErrorFunc(func(err error) {
if err != nil {
b.AddOutputLine(fmt.Sprintf("Microphone: %s", err.Error()))
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 b46aa92..5cdfb22 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 {
@@ -365,6 +376,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 79a8fb5..0be9623 100644
--- a/config/user_config_test.go
+++ b/config/user_config_test.go
@@ -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
// could panic while merely reading a saved user preference.
func TestMakeHostPortHandlesIPv6AndMalformedAddress(t *testing.T) {
diff --git a/gumble/gumbleopenal/stream.go b/gumble/gumbleopenal/stream.go
index 166da4b..d8ac501 100644
--- a/gumble/gumbleopenal/stream.go
+++ b/gumble/gumbleopenal/stream.go
@@ -288,6 +288,23 @@ func (s *Stream) SetNoiseProcessor(np NoiseProcessor) {
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) {
s.filePlayer = fp
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() {
noiseProcessor.ProcessSamples(samples)
}
- if micAGC != nil {
+ if micAGC != nil && micAGC.IsEnabled() {
micAGC.ProcessSamples(samples)
}
}
@@ -1081,6 +1098,9 @@ func (s *Stream) processChannel(samples []int16, noiseProcessor NoiseProcessor,
func (s *Stream) ensureStereoProcessors() {
if s.micAGCRight == nil {
s.micAGCRight = audio.NewAGC()
+ if s.micAGC != nil {
+ s.micAGCRight.SetEnabled(s.micAGC.IsEnabled())
+ }
}
if s.noiseProcessorRight == nil {
s.noiseProcessorRight = cloneNoiseProcessor(s.noiseProcessor)
diff --git a/ui.go b/ui.go
index 9130ac1..6d2964e 100644
--- a/ui.go
+++ b/ui.go
@@ -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) {
b.postUI(func() {
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) {
// cmd contains just the filename part (everything after "/file ")
filename := strings.TrimSpace(cmd)
@@ -498,6 +529,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":
@@ -595,6 +628,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")
@@ -604,6 +638,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.OnClearPress, b.Hotkeys.ClearOutput)
b.Ui.AddKeyListener(b.OnQuitPress, b.Hotkeys.Exit)