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 {