From d2dff7f5219c07bc73b59bf992c05dd24f83f3e5 Mon Sep 17 00:00:00 2001 From: "Brandon McGinty (deepseek)" Date: Fri, 14 Aug 2026 12:29:42 -0400 Subject: [PATCH 1/5] Add barnard-ui session logging and wormhole log sharing --- barnard-ui | 166 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 165 insertions(+), 1 deletion(-) diff --git a/barnard-ui b/barnard-ui index 1ddf162..07e5d8d 100755 --- a/barnard-ui +++ b/barnard-ui @@ -38,6 +38,10 @@ configDir="$HOME/.config/barnard" serverFile="$configDir/servers.conf" certFile="$configDir/barnard.pem" logFile="$cacheDir/${0##*/}.log" +logDir="$HOME/barnard-logs" +logPrefsFile="$configDir/logging.conf" +sessionLogFile="" +saveSessionLogs=0 if ! mkdir -p "$cacheDir" "$configDir"; then printf 'Could not create Barnard configuration directories.\n' >&2 @@ -64,6 +68,9 @@ log() { local line while IFS= read -r line ; do printf '%s\n' "$line" >> "$logFile" + if [[ -n "$sessionLogFile" ]]; then + printf '%s\n' "$line" >> "$sessionLogFile" + fi done } @@ -141,6 +148,12 @@ trim() { printf '%s' "$value" } +sanitize_filename() { + local value="$1" + value="${value//[^[:alnum:]_.-]/_}" + printf '%s' "$value" +} + field_is_valid() { local value="$1" [[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] @@ -376,6 +389,139 @@ config_has_nonempty_value() { return 1 } +load_logging_pref() { + local line + local key + local value + saveSessionLogs=0 + + [[ -r "$logPrefsFile" ]] || return 0 + while IFS= read -r line || [[ -n "$line" ]]; do + line="$(trim "$line")" + [[ -z "$line" || "$line" == \#* || "$line" != *=* ]] && continue + key="${line%%=*}" + key="$(trim "$key")" + key="${key,,}" + value="${line#*=}" + value="$(trim "$value")" + case "$key" in + savesessionlogs) + if [[ "$value" == "1" || "$value" == "true" || "$value" == "yes" ]]; then + saveSessionLogs=1 + fi + ;; + esac + done < "$logPrefsFile" +} + +save_logging_pref() { + local tmpFile="$logPrefsFile.tmp" + if ! printf 'saveSessionLogs=%s\n' "$saveSessionLogs" > "$tmpFile"; then + rm -f "$tmpFile" + msgbox "$(gettext "Could not save logging preference.")" + return 1 + fi + chmod 600 "$tmpFile" 2> /dev/null || true + if ! mv "$tmpFile" "$logPrefsFile"; then + rm -f "$tmpFile" + msgbox "$(gettext "Could not save logging preference.")" + return 1 + fi +} + +toggle-session-logging() { + local question + if (( saveSessionLogs )); then + question="$(gettext "Session logging is currently enabled. Disable it?")" + else + question="$(gettext "Session logging is currently disabled. Enable saving logs to the logs directory?")" + fi + if [[ "$(yesno "$question")" == "Yes" ]]; then + if (( saveSessionLogs )); then + saveSessionLogs=0 + else + saveSessionLogs=1 + fi + save_logging_pref + fi +} + +send-logs() { + local bundle + local outputFile + local code + local wormholePid + local status + local i + local detail + + if ! command -v wormhole > /dev/null 2>&1; then + msgbox "$(gettext "Required command not found:") wormhole" + return + fi + if ! command -v tar > /dev/null 2>&1; then + msgbox "$(gettext "Required command not found:") tar" + return + fi + if [[ ! -d "$logDir" ]] || ! compgen -G "$logDir"/*.log > /dev/null 2>&1; then + msgbox "$(gettext "No logs to send. Logs are saved to:") $logDir" + return + fi + + bundle="$cacheDir/barnard-logs-$(date +%Y%m%d-%H%M%S).tar.gz" + if ! tar -czf "$bundle" -C "$logDir" . 2> /dev/null; then + msgbox "$(gettext "Could not create log archive.")" + return + fi + + outputFile="$cacheDir/wormhole-$$.txt" + wormhole send "$bundle" > "$outputFile" 2>&1 & + wormholePid=$! + + code="" + for (( i = 0; i < 40; i++ )); do + sleep 0.25 + code="$(grep -Eo '[0-9]+-[a-z]+-[a-z]+' "$outputFile" 2> /dev/null | head -n1)" + [[ -n "$code" ]] && break + kill -0 "$wormholePid" 2> /dev/null || break + done + + if [[ -n "$code" ]]; then + msgbox "$(gettext "Wormhole code:") $code" + wait "$wormholePid" + status=$? + if (( status == 0 )); then + msgbox "$(gettext "Logs sent successfully.")" + else + msgbox "$(gettext "Log transfer did not complete successfully.")" + fi + else + kill "$wormholePid" 2> /dev/null || true + wait "$wormholePid" 2> /dev/null || true + detail="$(tail -n 3 "$outputFile" 2> /dev/null)" + if [[ -n "$detail" ]]; then + msgbox "$(gettext "Could not start wormhole transfer:") $detail" + else + msgbox "$(gettext "Could not start wormhole transfer.")" + fi + fi + + rm -f "$outputFile" "$bundle" +} + +manage-logs() { + local action + + while : ; do + action="$(menulist "$(gettext "Toggle session logging")" "$(gettext "Send logs with wormhole")" "$(gettext "Go Back")")" || return + case "$action" in + "$(gettext "Toggle session logging")") toggle-session-logging ;; + "$(gettext "Send logs with wormhole")") send-logs ;; + "$(gettext "Go Back")"|"") return ;; + esac + done +} + add-server() { local serverName local serverAddress @@ -424,6 +570,7 @@ add-server() { connect() { local serverName local barnardStatus + local safeServerName local -a names=() local -a barnardArgs=() @@ -440,6 +587,20 @@ connect() { require_command barnard barnard + sessionLogFile="" + if (( saveSessionLogs )); then + safeServerName="$(sanitize_filename "$serverName")" + if ! mkdir -p "$logDir"; then + msgbox "$(gettext "Could not create logs directory:") $logDir" + else + sessionLogFile="$logDir/${safeServerName}-$(date +%F).log" + if ! : >> "$sessionLogFile"; then + msgbox "$(gettext "Could not write log file:") $sessionLogFile" + sessionLogFile="" + fi + fi + fi + barnardArgs=(-server "${serverAddresses[$serverName]}:${serverPorts[$serverName]}") if [[ -n "${serverPasswords[$serverName]}" ]]; then barnardArgs+=(-password "${serverPasswords[$serverName]}") @@ -456,6 +617,7 @@ connect() { command barnard "${barnardArgs[@]}" --fifo "$configDir/cmd" --buffers 16 |& log barnardStatus=${PIPESTATUS[0]} + sessionLogFile="" if (( barnardStatus != 0 )); then msgbox "$(gettext "Barnard exited with status") $barnardStatus. $(gettext "See log:") $logFile" fi @@ -581,14 +743,16 @@ main() { require_command dialog dialog load_servers + load_logging_pref while : ; do - action="$(menulist "$(gettext "Connect")" "$(gettext "Add server")" "$(gettext "Remove server")" "$(gettext "Manage Certificate")" "$(gettext "Exit")")" || exit 0 + action="$(menulist "$(gettext "Connect")" "$(gettext "Add server")" "$(gettext "Remove server")" "$(gettext "Manage Certificate")" "$(gettext "Logs")" "$(gettext "Exit")")" || exit 0 case "$action" in "$(gettext "Connect")") connect ;; "$(gettext "Add server")") add-server ;; "$(gettext "Remove server")") remove-server ;; "$(gettext "Manage Certificate")") manage-certificate ;; + "$(gettext "Logs")") manage-logs ;; "$(gettext "Exit")"|"") exit 0 ;; esac done From 2cdbce8edb65268f9dec7760e4aef303fb024047 Mon Sep 17 00:00:00 2001 From: "Brandon McGinty (deepseek)" Date: Fri, 14 Aug 2026 12:32:41 -0400 Subject: [PATCH 2/5] Show enable/disable logs and pass debug logging to barnard --- barnard-ui | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/barnard-ui b/barnard-ui index 07e5d8d..e2fc4e3 100755 --- a/barnard-ui +++ b/barnard-ui @@ -511,11 +511,17 @@ send-logs() { manage-logs() { local action + local loggingAction while : ; do - action="$(menulist "$(gettext "Toggle session logging")" "$(gettext "Send logs with wormhole")" "$(gettext "Go Back")")" || return + if (( saveSessionLogs )); then + loggingAction="$(gettext "Disable logs")" + else + loggingAction="$(gettext "Enable logs")" + fi + action="$(menulist "$loggingAction" "$(gettext "Send logs with wormhole")" "$(gettext "Go Back")")" || return case "$action" in - "$(gettext "Toggle session logging")") toggle-session-logging ;; + "$loggingAction") toggle-session-logging ;; "$(gettext "Send logs with wormhole")") send-logs ;; "$(gettext "Go Back")"|"") return ;; esac @@ -614,6 +620,9 @@ connect() { if [[ -f "$certFile" ]] && ! config_has_nonempty_value certificate; then barnardArgs+=(-certificate "$certFile") fi + if [[ -n "$sessionLogFile" ]]; then + barnardArgs+=(-log debug -logfile "$sessionLogFile") + fi command barnard "${barnardArgs[@]}" --fifo "$configDir/cmd" --buffers 16 |& log barnardStatus=${PIPESTATUS[0]} From 132df6863ab099e5e39c859cf93faa280e4fc0d6 Mon Sep 17 00:00:00 2001 From: Tyler Spivey Date: Fri, 14 Aug 2026 13:00:06 -0700 Subject: [PATCH 3/5] Resync jitter buffer when a sender restarts frame numbering Mumble destroys and recreates AudioInput when the sender switches audio devices. The destructor sends no terminator, and the replacement resets iFrameCounter to zero, so a sender who never unkeys silently restarts its frame numbering mid-burst. The jitter buffer kept expecting the old sequence and discarded every packet as late until the next unkey. Resync on a sustained run of late packets combined with a backwards jump too large to be network reordering. Both conditions are needed: the run length alone would let a clump of reordered packets drag the expected sequence backwards, and small jumps need no intervention because the restarted stream climbs past the stale expectation on its own. Co-Authored-By: Claude Opus 5 --- gumble/gumbleopenal/stream.go | 38 +++++++++++++++++++ gumble/gumbleopenal/stream_regression_test.go | 19 ++++++++++ 2 files changed, 57 insertions(+) diff --git a/gumble/gumbleopenal/stream.go b/gumble/gumbleopenal/stream.go index b0f2968..166da4b 100644 --- a/gumble/gumbleopenal/stream.go +++ b/gumble/gumbleopenal/stream.go @@ -53,8 +53,32 @@ const recorderOutgoingSource uint32 = ^uint32(0) const ( maxBufferSize = 11520 // Max frame size (2880) * bytes per stereo sample (4) jitterMaxPackets = 50 + // Mumble destroys and recreates AudioInput when the sender switches audio + // devices, which restarts its frame numbering at zero. The destructor + // sends no terminator, so a sender that never unkeys leaves us expecting a + // frame number the new stream will not reach for hours: every packet looks + // permanently late and gets discarded. Detect that and resync. + // + // Two conditions must hold together. A sustained run of late packets + // distinguishes a restarted stream from a clump of reordered packets, + // which is bounded and then recovers on its own. The backwards jump must + // also be too large to be network reordering; a smaller jump needs no + // intervention because the restarted stream climbs back past the stale + // expectation within jitterResyncJump frames anyway. + jitterLateResync = 5 + // Frame numbers are Mumble timestamps in 10 ms units, so this is 1 second + // — far beyond any real reordering window. + jitterResyncJump = 100 ) +// jitterShouldResync reports whether the sender restarted its frame numbering +// rather than merely delivering a few packets out of order. lateRun is the +// number of consecutive late packets and backJump is how far the current +// packet sits below the expected sequence. +func jitterShouldResync(lateRun int, backJump int64) bool { + return lateRun >= jitterLateResync && backJump >= jitterResyncJump +} + // jitterPlaybackReady holds the requested initial playout delay only once. // Requiring the delay on every packet drains and refills the renderer in bursts. func jitterPlaybackReady(started bool, buffered, target time.Duration) bool { @@ -495,6 +519,7 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) { var jitterDuration time.Duration var jitterNextSeq int64 var jitterInit, jitterStarted bool + var jitterLateRun int var jitterDrainLogCounter, jitterAnomalyLogCounter int resetJitter := func() { jitterBuf = nil @@ -502,6 +527,7 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) { jitterNextSeq = 0 jitterInit = false jitterStarted = false + jitterLateRun = 0 } // insertSorted inserts a packet into the jitter buffer sorted @@ -595,6 +621,17 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) { if pkt == nil { if len(jitterBuf) > 0 { if jitterBuf[0].Sequence < jitterNextSeq { + jitterLateRun++ + if jitterShouldResync(jitterLateRun, jitterNextSeq-jitterBuf[0].Sequence) { + // The sender restarted its frame numbering + // mid-burst. Follow it instead of discarding + // every remaining packet until it unkeys. + log.Debug("jitter: sequence restart for %s, resyncing from %d to %d", + e.User.Name, jitterNextSeq, jitterBuf[0].Sequence) + jitterNextSeq = jitterBuf[0].Sequence + jitterLateRun = 0 + continue + } // Late or duplicate: discard so it doesn't // permanently block the drain loop. jitterAnomalyLogCounter++ @@ -622,6 +659,7 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) { } break } + jitterLateRun = 0 jitterDrainLogCounter++ if jitterDrainLogCounter <= 3 || jitterDrainLogCounter%1000 == 0 { log.Debug("jitter: draining seq=%d for %s (buf=%d emptyBufs=%d)", diff --git a/gumble/gumbleopenal/stream_regression_test.go b/gumble/gumbleopenal/stream_regression_test.go index f22be87..e78f076 100644 --- a/gumble/gumbleopenal/stream_regression_test.go +++ b/gumble/gumbleopenal/stream_regression_test.go @@ -62,6 +62,25 @@ func TestJitterPlaybackDelayAppliesOnlyAtStartup(t *testing.T) { } } +func TestJitterResyncsAfterSenderRestartsSequence(t *testing.T) { + // Mumble restarts frame numbering at zero when the sender switches audio + // devices mid-burst, and sends no terminator to announce it. + if !jitterShouldResync(jitterLateResync, 52724) { + t.Fatal("jitter did not resync after the sender restarted its frame numbering") + } + if jitterShouldResync(jitterLateResync-1, 52724) { + t.Fatal("jitter resynced before the late run was conclusive") + } + // A clump of reordered packets is bounded and recovers on its own; it must + // not drag the expected sequence backwards. + if jitterShouldResync(jitterLateResync, jitterResyncJump-1) { + t.Fatal("jitter resynced on a backwards jump small enough to be reordering") + } + if jitterShouldResync(1, 52724) { + t.Fatal("jitter resynced on a single late packet") + } +} + func TestAudioPacketDurationUsesStereoFrameCount(t *testing.T) { packet := &gumble.AudioPacket{AudioBuffer: make(gumble.AudioBuffer, 2*gumble.AudioDefaultFrameSize)} if got := audioPacketDuration(packet); got != 10*time.Millisecond { From 4393739ffa37d96ed96a541a63565afb140264b9 Mon Sep 17 00:00:00 2001 From: Tyler Spivey Date: Fri, 14 Aug 2026 13:16:44 -0700 Subject: [PATCH 4/5] 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 --- README.md | 16 +++++++++++ audio/agc.go | 50 ++++++++++++++++++----------------- client.go | 1 + config/hotkey_config.go | 1 + config/user_config.go | 27 +++++++++++++++++++ config/user_config_test.go | 29 ++++++++++++++++++++ gumble/gumbleopenal/stream.go | 22 ++++++++++++++- ui.go | 35 ++++++++++++++++++++++++ 8 files changed, 156 insertions(+), 25 deletions(-) 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) From f58d9f5dce155fa4f6830972233caba7e5d9dc90 Mon Sep 17 00:00:00 2001 From: Tyler Spivey Date: Fri, 14 Aug 2026 15:29:44 -0700 Subject: [PATCH 5/5] Refresh channel tree after volume changes Tree items render a display string snapshotted at build time, so changing a user's volume updated the gain but left the stale percentage on screen. Rebuild the tree and refresh after volume change and reset, matching what the mute hotkeys already do. Co-Authored-By: Claude Opus 5 --- ui_tree.go | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/ui_tree.go b/ui_tree.go index dea6f1f..04af22e 100644 --- a/ui_tree.go +++ b/ui_tree.go @@ -38,7 +38,7 @@ func (ti TreeItem) TreeItemStyle(fg, bg uiterm.Attribute, active bool) (uiterm.A } func (b *Barnard) changeVolume(users []*gumble.User, change float32) { - b.withStream(func(stream *gumbleopenal.Stream) { + changed := b.withStream(func(stream *gumbleopenal.Stream) { for _, u := range users { var boost uint16 var ng float32 @@ -63,10 +63,13 @@ func (b *Barnard) changeVolume(users []*gumble.User, change float32) { b.AddOutputLine("Volume: could not save setting: " + err.Error()) } }) + if changed { + b.refreshVolumeDisplay() + } } func (b *Barnard) resetVolume(users []*gumble.User) { - b.withStream(func(stream *gumbleopenal.Stream) { + changed := b.withStream(func(stream *gumbleopenal.Stream) { for _, u := range users { // Reset to original volume (1.0) and boost (1) u.SetBoost(uint16(1)) @@ -78,6 +81,16 @@ func (b *Barnard) resetVolume(users []*gumble.User) { b.AddOutputLine("Volume: could not save setting: " + err.Error()) } }) + if changed { + b.refreshVolumeDisplay() + } +} + +// Tree items render a display string snapshotted at build time, so a volume +// change is only visible after the tree is rebuilt. +func (b *Barnard) refreshVolumeDisplay() { + b.RebuildUserChannelTreePreservingSelection() + b.Ui.Refresh() } func makeUsersArray(users gumble.Users) []*gumble.User {