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:
co-authored by
Claude Opus 5
parent
132df6863a
commit
4393739ffa
@@ -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)
|
||||
|
||||
- <kbd>F1</kbd>: toggle voice transmission
|
||||
- <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>Ctrl+R</kbd>: toggle recording
|
||||
- <kbd>Ctrl+L</kbd>: clear chat log
|
||||
|
||||
+8
-6
@@ -2,6 +2,7 @@ package audio
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// AGC (Automatic Gain Control) processor for voice normalization
|
||||
@@ -13,14 +14,14 @@ type AGC struct {
|
||||
releaseTime float32 // Release time coefficient
|
||||
currentGain float32 // Current gain value
|
||||
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
|
||||
compRatio float32 // Compression ratio
|
||||
}
|
||||
|
||||
// NewAGC creates a new AGC processor with sensible defaults for voice
|
||||
func NewAGC() *AGC {
|
||||
return &AGC{
|
||||
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)
|
||||
@@ -28,15 +29,16 @@ func NewAGC() *AGC {
|
||||
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.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)
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -21,4 +21,5 @@ type Hotkeys struct {
|
||||
ScrollToBottom *uiterm.Key
|
||||
AdminMenu *uiterm.Key
|
||||
NoiseSuppressionToggle *uiterm.Key
|
||||
AGCToggle *uiterm.Key
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user