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/barnard-ui b/barnard-ui index 1ddf162..e2fc4e3 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,145 @@ 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 + local loggingAction + + while : ; do + 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 + "$loggingAction") toggle-session-logging ;; + "$(gettext "Send logs with wormhole")") send-logs ;; + "$(gettext "Go Back")"|"") return ;; + esac + done +} + add-server() { local serverName local serverAddress @@ -424,6 +576,7 @@ add-server() { connect() { local serverName local barnardStatus + local safeServerName local -a names=() local -a barnardArgs=() @@ -440,6 +593,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]}") @@ -453,9 +620,13 @@ 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]} + sessionLogFile="" if (( barnardStatus != 0 )); then msgbox "$(gettext "Barnard exited with status") $barnardStatus. $(gettext "See log:") $logFile" fi @@ -581,14 +752,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 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 b0f2968..d8ac501 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 { @@ -264,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 { @@ -495,6 +536,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 +544,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 +638,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 +676,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)", @@ -1035,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) } } @@ -1043,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/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 { 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) 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 {