6 Commits
Author SHA1 Message Date
b9fbc1800a Merge latest Barnard fork improvements
Merge the newest logging, jitter recovery, AGC hotkey, and channel tree refresh work from bmmcginty/barnard-stormdragon-ai-fork dev.

Co-authored-by: Brandon McGinty <git@bmcginty.us>

Co-authored-by: Tyler Spivey <tspivey@pcdesk.net>
2026-08-14 20:14:46 -04:00
Tyler SpiveyandClaude Opus 5 f58d9f5dce 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 <noreply@anthropic.com>
2026-08-14 15:29:44 -07:00
Tyler SpiveyandClaude Opus 5 4393739ffa 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>
2026-08-14 13:16:44 -07:00
Tyler SpiveyandClaude Opus 5 132df6863a 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 <noreply@anthropic.com>
2026-08-14 13:00:06 -07:00
Brandon McGinty (deepseek) 2cdbce8edb Show enable/disable logs and pass debug logging to barnard 2026-08-14 12:32:41 -04:00
Brandon McGinty (deepseek) d2dff7f521 Add barnard-ui session logging and wormhole log sharing 2026-08-14 12:29:42 -04:00
11 changed files with 402 additions and 28 deletions
+16
View File
@@ -40,6 +40,20 @@ noisesuppressionenabled = true
RNNoise is a required build and runtime dependency. 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 ## FIFO Control
If you pass the --fifo option to Barnard, a FIFO pipe will be created. 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. * toggle: Toggle your transmission state.
* talk: Synonym for toggle. * talk: Synonym for toggle.
* noise: Toggle noise suppression on/off for microphone input. * 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`. * record: Toggle recording. You may also use `record start` or `record stop`.
* exit: Exit Barnard, just like when you press your quit key. * 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>F1</kbd>: toggle voice transmission
- <kbd>F9</kbd>: toggle noise suppression - <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>F11</kbd>: open actions menu for the focused tree item
- <kbd>Ctrl+R</kbd>: toggle recording - <kbd>Ctrl+R</kbd>: toggle recording
- <kbd>Ctrl+L</kbd>: clear chat log - <kbd>Ctrl+L</kbd>: clear chat log
+8 -6
View File
@@ -2,6 +2,7 @@ package audio
import ( import (
"math" "math"
"sync/atomic"
) )
// AGC (Automatic Gain Control) processor for voice normalization // AGC (Automatic Gain Control) processor for voice normalization
@@ -13,14 +14,14 @@ type AGC struct {
releaseTime float32 // Release time coefficient releaseTime float32 // Release time coefficient
currentGain float32 // Current gain value currentGain float32 // Current gain value
envelope float32 // Signal envelope 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 compThreshold float32 // Compression threshold
compRatio float32 // Compression ratio compRatio float32 // Compression ratio
} }
// NewAGC creates a new AGC processor with sensible defaults for voice // NewAGC creates a new AGC processor with sensible defaults for voice
func NewAGC() *AGC { func NewAGC() *AGC {
return &AGC{ agc := &AGC{
targetLevel: 0.12, // Target 12% of max amplitude (conservative level) targetLevel: 0.12, // Target 12% of max amplitude (conservative level)
maxGain: 4.0, // Maximum 4x gain (about 12dB) maxGain: 4.0, // Maximum 4x gain (about 12dB)
minGain: 0.25, // Minimum 0.25x gain (-12dB) minGain: 0.25, // Minimum 0.25x gain (-12dB)
@@ -28,15 +29,16 @@ func NewAGC() *AGC {
releaseTime: 0.15, // Slower release (150ms) releaseTime: 0.15, // Slower release (150ms)
currentGain: 1.0, // Start with unity gain currentGain: 1.0, // Start with unity gain
envelope: 0.0, // Start with zero envelope envelope: 0.0, // Start with zero envelope
enabled: true, // Enable by default
compThreshold: 0.85, // Compress signals above 85% compThreshold: 0.85, // Compress signals above 85%
compRatio: 2.0, // 2:1 compression ratio (gentler) compRatio: 2.0, // 2:1 compression ratio (gentler)
} }
agc.enabled.Store(true) // Enable by default
return agc
} }
// ProcessSamples applies AGC processing to audio samples // ProcessSamples applies AGC processing to audio samples
func (agc *AGC) ProcessSamples(samples []int16) { func (agc *AGC) ProcessSamples(samples []int16) {
if !agc.enabled || len(samples) == 0 { if !agc.enabled.Load() || len(samples) == 0 {
return return
} }
@@ -125,12 +127,12 @@ func (agc *AGC) ProcessSamples(samples []int16) {
// SetEnabled enables or disables AGC processing // SetEnabled enables or disables AGC processing
func (agc *AGC) SetEnabled(enabled bool) { func (agc *AGC) SetEnabled(enabled bool) {
agc.enabled = enabled agc.enabled.Store(enabled)
} }
// IsEnabled returns whether AGC is enabled // IsEnabled returns whether AGC is enabled
func (agc *AGC) IsEnabled() bool { func (agc *AGC) IsEnabled() bool {
return agc.enabled return agc.enabled.Load()
} }
// SetTargetLevel sets the target RMS level (0.0-1.0) // SetTargetLevel sets the target RMS level (0.0-1.0)
+174 -1
View File
@@ -38,6 +38,10 @@ configDir="$HOME/.config/barnard"
serverFile="$configDir/servers.conf" serverFile="$configDir/servers.conf"
certFile="$configDir/barnard.pem" certFile="$configDir/barnard.pem"
logFile="$cacheDir/${0##*/}.log" logFile="$cacheDir/${0##*/}.log"
logDir="$HOME/barnard-logs"
logPrefsFile="$configDir/logging.conf"
sessionLogFile=""
saveSessionLogs=0
if ! mkdir -p "$cacheDir" "$configDir"; then if ! mkdir -p "$cacheDir" "$configDir"; then
printf 'Could not create Barnard configuration directories.\n' >&2 printf 'Could not create Barnard configuration directories.\n' >&2
@@ -64,6 +68,9 @@ log() {
local line local line
while IFS= read -r line ; do while IFS= read -r line ; do
printf '%s\n' "$line" >> "$logFile" printf '%s\n' "$line" >> "$logFile"
if [[ -n "$sessionLogFile" ]]; then
printf '%s\n' "$line" >> "$sessionLogFile"
fi
done done
} }
@@ -141,6 +148,12 @@ trim() {
printf '%s' "$value" printf '%s' "$value"
} }
sanitize_filename() {
local value="$1"
value="${value//[^[:alnum:]_.-]/_}"
printf '%s' "$value"
}
field_is_valid() { field_is_valid() {
local value="$1" local value="$1"
[[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] [[ "$value" != *$'\n'* && "$value" != *$'\r'* ]]
@@ -376,6 +389,145 @@ config_has_nonempty_value() {
return 1 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() { add-server() {
local serverName local serverName
local serverAddress local serverAddress
@@ -424,6 +576,7 @@ add-server() {
connect() { connect() {
local serverName local serverName
local barnardStatus local barnardStatus
local safeServerName
local -a names=() local -a names=()
local -a barnardArgs=() local -a barnardArgs=()
@@ -440,6 +593,20 @@ connect() {
require_command barnard barnard 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]}") barnardArgs=(-server "${serverAddresses[$serverName]}:${serverPorts[$serverName]}")
if [[ -n "${serverPasswords[$serverName]}" ]]; then if [[ -n "${serverPasswords[$serverName]}" ]]; then
barnardArgs+=(-password "${serverPasswords[$serverName]}") barnardArgs+=(-password "${serverPasswords[$serverName]}")
@@ -453,9 +620,13 @@ connect() {
if [[ -f "$certFile" ]] && ! config_has_nonempty_value certificate; then if [[ -f "$certFile" ]] && ! config_has_nonempty_value certificate; then
barnardArgs+=(-certificate "$certFile") barnardArgs+=(-certificate "$certFile")
fi fi
if [[ -n "$sessionLogFile" ]]; then
barnardArgs+=(-log debug -logfile "$sessionLogFile")
fi
command barnard "${barnardArgs[@]}" --fifo "$configDir/cmd" --buffers 16 |& log command barnard "${barnardArgs[@]}" --fifo "$configDir/cmd" --buffers 16 |& log
barnardStatus=${PIPESTATUS[0]} barnardStatus=${PIPESTATUS[0]}
sessionLogFile=""
if (( barnardStatus != 0 )); then if (( barnardStatus != 0 )); then
msgbox "$(gettext "Barnard exited with status") $barnardStatus. $(gettext "See log:") $logFile" msgbox "$(gettext "Barnard exited with status") $barnardStatus. $(gettext "See log:") $logFile"
fi fi
@@ -581,14 +752,16 @@ main() {
require_command dialog dialog require_command dialog dialog
load_servers load_servers
load_logging_pref
while : ; do 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 case "$action" in
"$(gettext "Connect")") connect ;; "$(gettext "Connect")") connect ;;
"$(gettext "Add server")") add-server ;; "$(gettext "Add server")") add-server ;;
"$(gettext "Remove server")") remove-server ;; "$(gettext "Remove server")") remove-server ;;
"$(gettext "Manage Certificate")") manage-certificate ;; "$(gettext "Manage Certificate")") manage-certificate ;;
"$(gettext "Logs")") manage-logs ;;
"$(gettext "Exit")"|"") exit 0 ;; "$(gettext "Exit")"|"") exit 0 ;;
esac esac
done done
+1
View File
@@ -90,6 +90,7 @@ func (b *Barnard) connect(reconnect bool) bool {
stream.SetMicVolume(b.UserConfig.GetMicVolume(), false) stream.SetMicVolume(b.UserConfig.GetMicVolume(), false)
stream.AttachStream(b.Client) stream.AttachStream(b.Client)
stream.SetNoiseProcessor(b.NoiseSuppressor) stream.SetNoiseProcessor(b.NoiseSuppressor)
stream.SetAGCEnabled(b.UserConfig.GetAGCEnabled())
stream.SetErrorFunc(func(err error) { stream.SetErrorFunc(func(err error) {
if err != nil { if err != nil {
b.AddOutputLine(fmt.Sprintf("Microphone: %s", err.Error())) b.AddOutputLine(fmt.Sprintf("Microphone: %s", err.Error()))
+1
View File
@@ -21,4 +21,5 @@ type Hotkeys struct {
ScrollToBottom *uiterm.Key ScrollToBottom *uiterm.Key
AdminMenu *uiterm.Key AdminMenu *uiterm.Key
NoiseSuppressionToggle *uiterm.Key NoiseSuppressionToggle *uiterm.Key
AGCToggle *uiterm.Key
} }
+27
View File
@@ -31,6 +31,7 @@ type exportableConfig struct {
Username *string Username *string
NotifyCommand *string NotifyCommand *string
NoiseSuppressionEnabled *bool NoiseSuppressionEnabled *bool
AGCEnabled *bool
Certificate *string Certificate *string
RecordingFormat *string RecordingFormat *string
RecordingDirectory *string RecordingDirectory *string
@@ -109,6 +110,7 @@ func (c *Config) LoadConfig() {
ScrollToBottom: key(uiterm.KeyEnd), ScrollToBottom: key(uiterm.KeyEnd),
AdminMenu: key(uiterm.KeyF11), AdminMenu: key(uiterm.KeyF11),
NoiseSuppressionToggle: key(uiterm.KeyF9), NoiseSuppressionToggle: key(uiterm.KeyF9),
AGCToggle: key(uiterm.KeyF12),
} }
if fileExists(c.fn) { if fileExists(c.fn) {
var data []byte var data []byte
@@ -155,6 +157,11 @@ func (c *Config) LoadConfig() {
enabled := false enabled := false
jc.NoiseSuppressionEnabled = &enabled 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 { if c.config.Certificate == nil {
cert := string("") cert := string("")
jc.Certificate = &cert jc.Certificate = &cert
@@ -190,6 +197,7 @@ func (c *Config) ensureHotkeys() {
ScrollToBottom: key(uiterm.KeyEnd), ScrollToBottom: key(uiterm.KeyEnd),
AdminMenu: key(uiterm.KeyF11), AdminMenu: key(uiterm.KeyF11),
NoiseSuppressionToggle: key(uiterm.KeyF9), NoiseSuppressionToggle: key(uiterm.KeyF9),
AGCToggle: key(uiterm.KeyF12),
} }
hotkeys := c.config.Hotkeys hotkeys := c.config.Hotkeys
if hotkeys.Talk == nil { if hotkeys.Talk == nil {
@@ -240,6 +248,9 @@ func (c *Config) ensureHotkeys() {
if hotkeys.NoiseSuppressionToggle == nil { if hotkeys.NoiseSuppressionToggle == nil {
hotkeys.NoiseSuppressionToggle = defaults.NoiseSuppressionToggle hotkeys.NoiseSuppressionToggle = defaults.NoiseSuppressionToggle
} }
if hotkeys.AGCToggle == nil {
hotkeys.AGCToggle = defaults.AGCToggle
}
} }
func (c *Config) findServer(address string) *server { func (c *Config) findServer(address string) *server {
@@ -365,6 +376,22 @@ func (c *Config) SetNoiseSuppressionEnabled(enabled bool) error {
return c.saveConfigLocked() 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 { func (c *Config) GetRecordingFormat() string {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
+29
View File
@@ -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 // Regression: malformed and IPv6 addresses were split at every colon and
// could panic while merely reading a saved user preference. // could panic while merely reading a saved user preference.
func TestMakeHostPortHandlesIPv6AndMalformedAddress(t *testing.T) { func TestMakeHostPortHandlesIPv6AndMalformedAddress(t *testing.T) {
+59 -1
View File
@@ -53,8 +53,32 @@ const recorderOutgoingSource uint32 = ^uint32(0)
const ( const (
maxBufferSize = 11520 // Max frame size (2880) * bytes per stereo sample (4) maxBufferSize = 11520 // Max frame size (2880) * bytes per stereo sample (4)
jitterMaxPackets = 50 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. // jitterPlaybackReady holds the requested initial playout delay only once.
// Requiring the delay on every packet drains and refills the renderer in bursts. // Requiring the delay on every packet drains and refills the renderer in bursts.
func jitterPlaybackReady(started bool, buffered, target time.Duration) bool { func jitterPlaybackReady(started bool, buffered, target time.Duration) bool {
@@ -264,6 +288,23 @@ func (s *Stream) SetNoiseProcessor(np NoiseProcessor) {
s.noiseProcessorRight = cloneNoiseProcessor(np) 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) { func (s *Stream) SetFilePlayer(fp FilePlayer) {
s.filePlayer = fp s.filePlayer = fp
if player, ok := fp.(interface{ SetLocalPlayback(func([]byte)) }); ok { 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 jitterDuration time.Duration
var jitterNextSeq int64 var jitterNextSeq int64
var jitterInit, jitterStarted bool var jitterInit, jitterStarted bool
var jitterLateRun int
var jitterDrainLogCounter, jitterAnomalyLogCounter int var jitterDrainLogCounter, jitterAnomalyLogCounter int
resetJitter := func() { resetJitter := func() {
jitterBuf = nil jitterBuf = nil
@@ -502,6 +544,7 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
jitterNextSeq = 0 jitterNextSeq = 0
jitterInit = false jitterInit = false
jitterStarted = false jitterStarted = false
jitterLateRun = 0
} }
// insertSorted inserts a packet into the jitter buffer sorted // insertSorted inserts a packet into the jitter buffer sorted
@@ -595,6 +638,17 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
if pkt == nil { if pkt == nil {
if len(jitterBuf) > 0 { if len(jitterBuf) > 0 {
if jitterBuf[0].Sequence < jitterNextSeq { 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 // Late or duplicate: discard so it doesn't
// permanently block the drain loop. // permanently block the drain loop.
jitterAnomalyLogCounter++ jitterAnomalyLogCounter++
@@ -622,6 +676,7 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
} }
break break
} }
jitterLateRun = 0
jitterDrainLogCounter++ jitterDrainLogCounter++
if jitterDrainLogCounter <= 3 || jitterDrainLogCounter%1000 == 0 { if jitterDrainLogCounter <= 3 || jitterDrainLogCounter%1000 == 0 {
log.Debug("jitter: draining seq=%d for %s (buf=%d emptyBufs=%d)", 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() { if noiseProcessor != nil && noiseProcessor.IsEnabled() {
noiseProcessor.ProcessSamples(samples) noiseProcessor.ProcessSamples(samples)
} }
if micAGC != nil { if micAGC != nil && micAGC.IsEnabled() {
micAGC.ProcessSamples(samples) micAGC.ProcessSamples(samples)
} }
} }
@@ -1043,6 +1098,9 @@ func (s *Stream) processChannel(samples []int16, noiseProcessor NoiseProcessor,
func (s *Stream) ensureStereoProcessors() { func (s *Stream) ensureStereoProcessors() {
if s.micAGCRight == nil { if s.micAGCRight == nil {
s.micAGCRight = audio.NewAGC() s.micAGCRight = audio.NewAGC()
if s.micAGC != nil {
s.micAGCRight.SetEnabled(s.micAGC.IsEnabled())
}
} }
if s.noiseProcessorRight == nil { if s.noiseProcessorRight == nil {
s.noiseProcessorRight = cloneNoiseProcessor(s.noiseProcessor) s.noiseProcessorRight = cloneNoiseProcessor(s.noiseProcessor)
@@ -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) { func TestAudioPacketDurationUsesStereoFrameCount(t *testing.T) {
packet := &gumble.AudioPacket{AudioBuffer: make(gumble.AudioBuffer, 2*gumble.AudioDefaultFrameSize)} packet := &gumble.AudioPacket{AudioBuffer: make(gumble.AudioBuffer, 2*gumble.AudioDefaultFrameSize)}
if got := audioPacketDuration(packet); got != 10*time.Millisecond { if got := audioPacketDuration(packet); got != 10*time.Millisecond {
+35
View File
@@ -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) { func (b *Barnard) UpdateGeneralStatus(text string, notice bool) {
b.postUI(func() { b.postUI(func() {
b.statusText = text 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) { func (b *Barnard) CommandPlayFile(ui *uiterm.Ui, cmd string) {
// cmd contains just the filename part (everything after "/file ") // cmd contains just the filename part (everything after "/file ")
filename := strings.TrimSpace(cmd) filename := strings.TrimSpace(cmd)
@@ -498,6 +529,8 @@ func (b *Barnard) OnTextInput(ui *uiterm.Ui, textbox *uiterm.Textbox, text strin
b.CommandStatus(ui, cmdArgs) b.CommandStatus(ui, cmdArgs)
case "noise": case "noise":
b.CommandNoiseSuppressionToggle(ui, cmdArgs) b.CommandNoiseSuppressionToggle(ui, cmdArgs)
case "agc":
b.CommandAGCToggle(ui, cmdArgs)
case "record": case "record":
b.CommandRecord(ui, cmdArgs) b.CommandRecord(ui, cmdArgs)
case "admin": case "admin":
@@ -595,6 +628,7 @@ func (b *Barnard) OnUiInitialize(ui *uiterm.Ui) {
b.Ui.AddCommandListener(b.CommandExit, "exit") b.Ui.AddCommandListener(b.CommandExit, "exit")
b.Ui.AddCommandListener(b.CommandStatus, "status") b.Ui.AddCommandListener(b.CommandStatus, "status")
b.Ui.AddCommandListener(b.CommandNoiseSuppressionToggle, "noise") b.Ui.AddCommandListener(b.CommandNoiseSuppressionToggle, "noise")
b.Ui.AddCommandListener(b.CommandAGCToggle, "agc")
b.Ui.AddCommandListener(b.CommandPlayFile, "file") b.Ui.AddCommandListener(b.CommandPlayFile, "file")
b.Ui.AddCommandListener(b.CommandStopFile, "stop") b.Ui.AddCommandListener(b.CommandStopFile, "stop")
b.Ui.AddCommandListener(b.CommandRecord, "record") 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.OnVoiceToggle, b.Hotkeys.Talk)
b.Ui.AddKeyListener(b.OnTimestampToggle, b.Hotkeys.ToggleTimestamps) b.Ui.AddKeyListener(b.OnTimestampToggle, b.Hotkeys.ToggleTimestamps)
b.Ui.AddKeyListener(b.OnNoiseSuppressionToggle, b.Hotkeys.NoiseSuppressionToggle) 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.OnRecordingToggle, b.Hotkeys.RecordToggle)
b.Ui.AddKeyListener(b.OnClearPress, b.Hotkeys.ClearOutput) b.Ui.AddKeyListener(b.OnClearPress, b.Hotkeys.ClearOutput)
b.Ui.AddKeyListener(b.OnQuitPress, b.Hotkeys.Exit) b.Ui.AddKeyListener(b.OnQuitPress, b.Hotkeys.Exit)
+15 -2
View File
@@ -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) { 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 { for _, u := range users {
var boost uint16 var boost uint16
var ng float32 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()) b.AddOutputLine("Volume: could not save setting: " + err.Error())
} }
}) })
if changed {
b.refreshVolumeDisplay()
}
} }
func (b *Barnard) resetVolume(users []*gumble.User) { func (b *Barnard) resetVolume(users []*gumble.User) {
b.withStream(func(stream *gumbleopenal.Stream) { changed := b.withStream(func(stream *gumbleopenal.Stream) {
for _, u := range users { for _, u := range users {
// Reset to original volume (1.0) and boost (1) // Reset to original volume (1.0) and boost (1)
u.SetBoost(uint16(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()) 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 { func makeUsersArray(users gumble.Users) []*gumble.User {