18 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
Storm DragonandBrandon McGinty 8a8cfde01d Merge Brandon McGinty's UDP and reliability work
Merge the full history from bmmcginty/barnard-stormdragon-ai-fork dev, including UDP audio transport, jitter handling, concurrency fixes, and regression coverage.

Co-authored-by: Brandon McGinty <git@bmcginty.us>
2026-08-14 11:52:00 -04:00
Brandon McGinty (chatgpt) c0ef036934 Add configurable incoming audio jitter buffer 2026-08-13 17:54:30 -04:00
Brandon McGinty (chatgpt) d338925da6 Add outgoing audio packet interval option 2026-08-13 16:00:30 -04:00
Brandon McGinty (chatgpt) 893908f9a1 Reset audio jitter state at talk burst end 2026-08-13 13:37:47 -04:00
Brandon McGinty (chatgpt) 05dc6e4e0e Set TLS server name from Mumble address 2026-08-13 12:53:35 -04:00
Brandon McGinty (chatgpt) 883f7250f5 Avoid debug logging induced UDP loss 2026-08-12 17:38:04 -04:00
Brandon McGinty (chatgpt) 4510c25350 Recover UDP crypto after packet loss 2026-08-12 16:30:38 -04:00
Storm Dragon d4f8d56c8e Reverted because of audio problems. Will try again when things are fixed. 2026-08-11 20:55:25 -04:00
Storm Dragon c77d4bac3e Finally tracked down and fixed the problems with buffers. 2026-08-11 20:50:58 -04:00
Storm Dragon 0f34831c5b Attempt to fix a bug that can randomly drop audio streams. 2026-08-11 20:15:49 -04:00
Storm Dragon 4c5a54c2dd Fixed wording for notifications so it sounds more natural. 2026-08-11 19:51:25 -04:00
Storm Dragon eae1d8b99a Attempt to improve over all voice code. 2026-08-11 19:47:01 -04:00
22 changed files with 785 additions and 59 deletions
+45
View File
@@ -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.
@@ -140,6 +155,35 @@ If you modify the config file while Barnard is running, your changes may be over
You can set username and defaultserver in your config file, and they will be used if none is specified when launching barnard.
(Note that the default username (an empty string) and the default server name (localhost:64738) have been the defaults for barnard up to this point, and have been left that way for compatibility.)
## Audio Packet Duration
Barnard sends 10 ms audio packets by default. On a slow or unstable connection,
using larger packets can reduce packet overhead and make short dropouts less
noticeable, at the cost of additional voice latency. Start Barnard with one of
the supported durations:
```sh
barnard --audio-interval 20
```
Supported values are `10`, `20`, `40`, and `60` milliseconds. Try `20` ms
first; use `40` ms only if the connection remains unreliable.
## Incoming Audio Jitter Buffer
Barnard holds 40 ms of audio separately for each speaker before starting
playback. This prevents brief delayed UDP packets from draining OpenAL's audio
queue, which otherwise produces clicks or pops. To adjust this tradeoff between
resilience and added incoming latency:
```sh
barnard --jitter-buffer 60
```
Supported values are `0`, `20`, `40` (default), and `60` milliseconds. Try
`60` ms for a lossy or jittery connection. Use `0` only when minimizing latency
is more important than avoiding playback underruns.
## Audio Devices
You can set the default input and output devices in the config file as well.
@@ -249,6 +293,7 @@ After running the command above, `barnard` will be compiled as `$(go env GOPATH)
- <kbd>F1</kbd>: toggle voice transmission
- <kbd>F9</kbd>: toggle noise suppression
- <kbd>F12</kbd>: toggle automatic gain control
- <kbd>F11</kbd>: open actions menu for the focused tree item
- <kbd>Ctrl+R</kbd>: toggle recording
- <kbd>Ctrl+L</kbd>: clear chat log
+26 -24
View File
@@ -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)
+174 -1
View File
@@ -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
+1
View File
@@ -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()))
+32
View File
@@ -70,6 +70,38 @@ func TestNotificationExpansionIsSinglePassAndNotifyDoesNotBlock(t *testing.T) {
}
}
func TestAudioIntervalDuration(t *testing.T) {
for _, milliseconds := range []int{10, 20, 40, 60} {
got, err := audioIntervalDuration(milliseconds)
if err != nil {
t.Errorf("audioIntervalDuration(%d): %v", milliseconds, err)
continue
}
if got != time.Duration(milliseconds)*time.Millisecond {
t.Errorf("audioIntervalDuration(%d) = %v", milliseconds, got)
}
}
if _, err := audioIntervalDuration(30); err == nil {
t.Fatal("audioIntervalDuration accepted unsupported duration")
}
}
func TestJitterBufferDuration(t *testing.T) {
for _, milliseconds := range []int{0, 20, 40, 60} {
got, err := jitterBufferDuration(milliseconds)
if err != nil {
t.Errorf("jitterBufferDuration(%d): %v", milliseconds, err)
continue
}
if got != time.Duration(milliseconds)*time.Millisecond {
t.Errorf("jitterBufferDuration(%d) = %v", milliseconds, got)
}
}
if _, err := jitterBufferDuration(10); err == nil {
t.Fatal("jitterBufferDuration accepted unsupported duration")
}
}
func TestServerAddressDefaultsPortWithoutBreakingIPv6(t *testing.T) {
for input, want := range map[string]string{
"server": "server:64738",
+1
View File
@@ -21,4 +21,5 @@ type Hotkeys struct {
ScrollToBottom *uiterm.Key
AdminMenu *uiterm.Key
NoiseSuppressionToggle *uiterm.Key
AGCToggle *uiterm.Key
}
+27
View File
@@ -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()
+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
// could panic while merely reading a saved user preference.
func TestMakeHostPortHandlesIPv6AndMalformedAddress(t *testing.T) {
+4
View File
@@ -92,6 +92,10 @@ type AudioPacket struct {
AudioBuffer
// Terminator marks the final packet in a talk burst. Audio listeners use
// it to discard ordering state before the sender starts a new burst.
Terminator bool
HasPosition bool
X, Y, Z float32
VolumeAdjustment float32
+31
View File
@@ -3,6 +3,7 @@ package gumble
import (
"crypto/tls"
"errors"
"fmt"
"math"
"net"
"runtime"
@@ -101,6 +102,19 @@ func Dial(config *Config) (*Client, error) {
return DialWithDialer(new(net.Dialer), config, nil)
}
// tlsServerName returns the hostname portion of a Mumble server address for
// TLS certificate verification and SNI.
func tlsServerName(address string) (string, error) {
host, _, err := net.SplitHostPort(address)
if err != nil {
return "", fmt.Errorf("gumble: derive TLS server name from %q: %w", address, err)
}
if host == "" {
return "", fmt.Errorf("gumble: derive TLS server name from %q: empty host", address)
}
return host, nil
}
// DialWithDialer connects to the Mumble server at the address given in config.
//
// The function returns after the connection has been established, the initial
@@ -120,6 +134,23 @@ func DialWithDialer(dialer *net.Dialer, config *Config, tlsConfig *tls.Config) (
if err != nil {
return nil, err
}
// tls.Client cannot infer a server name from an already-open connection.
// Clone the caller's configuration before deriving it so reconnects and
// concurrent clients do not mutate a shared configuration.
if tlsConfig == nil {
tlsConfig = &tls.Config{}
} else {
tlsConfig = tlsConfig.Clone()
}
if tlsConfig.ServerName == "" {
serverName, err := tlsServerName(config.Address)
if err != nil {
rawConn.Close()
return nil, err
}
tlsConfig.ServerName = serverName
}
conn := tls.Client(rawConn, tlsConfig)
// net.Dialer.Timeout covers only the TCP dial. Apply the same bounded
// deadline to TLS negotiation so a peer that accepts but never responds
+28
View File
@@ -0,0 +1,28 @@
package gumble
import "testing"
func TestTLSServerNameUsesAddressHost(t *testing.T) {
for _, test := range []struct {
address string
want string
}{
{"mumble.example:64738", "mumble.example"},
{"[2001:db8::1]:64738", "2001:db8::1"},
} {
got, err := tlsServerName(test.address)
if err != nil {
t.Errorf("tlsServerName(%q): %v", test.address, err)
continue
}
if got != test.want {
t.Errorf("tlsServerName(%q) = %q, want %q", test.address, got, test.want)
}
}
}
func TestTLSServerNameRejectsAddressWithoutHost(t *testing.T) {
if _, err := tlsServerName(":64738"); err == nil {
t.Fatal("tlsServerName accepted an empty host")
}
}
+10 -3
View File
@@ -25,6 +25,9 @@ type Config struct {
AudioInterval time.Duration
// AudioDataBytes is the number of bytes that an audio frame can use.
AudioDataBytes int
// IncomingAudioBuffer is the amount of per-speaker audio retained before
// playback starts, absorbing jitter in incoming UDP packet delivery.
IncomingAudioBuffer time.Duration
// DisableUDP forces all audio to use the TCP tunnel instead of UDP.
DisableUDP bool
@@ -38,9 +41,10 @@ type Config struct {
// NewConfig returns a new Config struct with default values set.
func NewConfig() *Config {
return &Config{
Buffers: 8,
AudioInterval: AudioDefaultInterval,
AudioDataBytes: AudioDefaultDataBytes,
Buffers: 8,
AudioInterval: AudioDefaultInterval,
AudioDataBytes: AudioDefaultDataBytes,
IncomingAudioBuffer: 40 * time.Millisecond,
}
}
@@ -54,6 +58,9 @@ func (c *Config) Validate() error {
if c.AudioDataBytes <= 0 {
return fmt.Errorf("gumble: AudioDataBytes must be positive")
}
if c.IncomingAudioBuffer < 0 {
return fmt.Errorf("gumble: IncomingAudioBuffer must not be negative")
}
if c.Buffers <= 0 {
return fmt.Errorf("gumble: Buffers must be positive")
}
+5
View File
@@ -223,6 +223,11 @@ func (c *Client) handleUDPTunnel(buffer []byte) error {
}
c.dispatchAudio(user, &event)
if isFinal {
decoder.Reset()
user.audioSequenceValid = false
c.dispatchAudio(user, &AudioPacket{Client: c, Sender: user, Terminator: true})
}
return nil
}
+4 -1
View File
@@ -59,7 +59,10 @@ func (c *Client) udpReadRoutine() {
return
}
packetCount++
if log.Enabled(log.LevelDebug) {
// A synchronous log write for every UDP datagram can itself make the
// reader fall behind and lose voice packets. Keep enough samples to
// diagnose framing while avoiding work on the audio hot path.
if log.Enabled(log.LevelDebug) && (packetCount <= 3 || packetCount%1000 == 0) {
log.Debug("UDP recv #%d: %d bytes from %s hex=%s",
packetCount, n, addr, hex.EncodeToString(buf[:n]))
}
+37 -9
View File
@@ -292,10 +292,13 @@ func (cs *cryptState15) decrypt15(packet []byte) ([]byte, error) {
backupIV(cs.decryptIV[:])
restore = true
} else if ivByte > cs.decryptIV[0] && diff > 0 {
// We missed packets; catch up. Already handled above.
// We missed packets; move the low IV byte forward.
cs.decryptIV[0] = ivByte
} else if ivByte < cs.decryptIV[0] && diff > 0 {
// Wrapped forward; advance and catch up.
advanceIV(cs.decryptIV[:])
// We missed packets across a low-byte wrap. The IV's higher
// bytes must advance even though the received low byte is set
// below rather than incremented.
advanceIVHighBytes(cs.decryptIV[:])
cs.decryptIV[0] = ivByte
} else {
return nil, errors.New("gumble: OCB IV too far off")
@@ -340,6 +343,16 @@ func advanceIV(iv []byte) {
}
}
// advanceIVHighBytes advances all but the low IV byte as a little-endian integer.
func advanceIVHighBytes(iv []byte) {
for i := 1; i < len(iv); i++ {
iv[i]++
if iv[i] != 0 {
break
}
}
}
// backupIV decrements a 16-byte IV as a little-endian integer.
func backupIV(iv []byte) {
for i := 0; i < len(iv); i++ {
@@ -626,8 +639,10 @@ func (c *Client) WriteAudioUDP15(format byte, target uint32, sequence int64, dat
return false, err
}
log.Debug("UDP15 send: frame=%d opus_len=%d enc_len=%d final=%v",
frameNum, len(data), len(encrypted), final)
if log.Enabled(log.LevelDebug) && (frameNum < 3 || frameNum%1000 == 0 || final) {
log.Debug("UDP15 send: frame=%d opus_len=%d enc_len=%d final=%v",
frameNum, len(data), len(encrypted), final)
}
_, err = udpConn.Write(encrypted)
if err != nil {
@@ -662,7 +677,9 @@ func (c *Client) HandleUDPPacket15(packet []byte, pktNum uint64) {
return
}
log.Debug("UDP15 #%d: decrypt OK, plaintext_len=%d", pktNum, len(plaintext))
if log.Enabled(log.LevelDebug) && (pktNum <= 3 || pktNum%1000 == 0) {
log.Debug("UDP15 #%d: decrypt OK, plaintext_len=%d", pktNum, len(plaintext))
}
c.markUDPActive()
// Check type byte (0x00 = Audio, 0x01 = Ping)
@@ -735,6 +752,9 @@ func (c *Client) dispatchOpus15(pktNum uint64, session uint32, frameNum int64, o
decoder.Reset()
user.audioSequenceValid = false
user.audioFrameStep = 0
// The audio stream remains open between talk bursts. Deliver the
// terminator so listeners can reset their own packet ordering state.
c.dispatchAudio(user, &AudioPacket{Client: c, Sender: user, Terminator: true})
log.Info("UDP15 #%d: terminator for %s, decoder reset", pktNum, user.Name)
return
}
@@ -743,7 +763,7 @@ func (c *Client) dispatchOpus15(pktNum uint64, session uint32, frameNum int64, o
return
}
c.decodeAndDispatch(pktNum, user, decoder, frameNum, opusData, context, position, volumeAdjustment)
c.decodeAndDispatch(pktNum, user, decoder, frameNum, opusData, terminator, context, position, volumeAdjustment)
}
// handleLegacyUDPVoice parses the legacy UDPVoice format (type byte 0x80)
@@ -792,7 +812,7 @@ func (c *Client) handleLegacyUDPVoice(pktNum uint64, data []byte) {
}
// decodeAndDispatch decodes an Opus frame and dispatches PCM to audio listeners.
func (c *Client) decodeAndDispatch(pktNum uint64, user *User, decoder AudioDecoder, frameNum int64, opusData []byte, context uint32, position *[3]float32, volumeAdjustment float32) {
func (c *Client) decodeAndDispatch(pktNum uint64, user *User, decoder AudioDecoder, frameNum int64, opusData []byte, terminator bool, context uint32, position *[3]float32, volumeAdjustment float32) {
// Frame numbers are timestamps in 10 ms units, not packet counters. For
// example, a standard 20 ms Opus packet advances its frame number by two.
// Only generate PLC for complete missing packets; treating every timestamp
@@ -828,7 +848,9 @@ func (c *Client) decodeAndDispatch(pktNum uint64, user *User, decoder AudioDecod
return
}
log.Debug("UDP15 #%d: Opus OK for %s, pcm_samples=%d", pktNum, user.Name, len(pcm))
if log.Enabled(log.LevelDebug) && (pktNum <= 3 || pktNum%1000 == 0) {
log.Debug("UDP15 #%d: Opus OK for %s, pcm_samples=%d", pktNum, user.Name, len(pcm))
}
user.audioSequence = frameNum
user.audioSequenceValid = true
user.audioFrameStep = audioFrameStep(len(pcm))
@@ -846,6 +868,12 @@ func (c *Client) decodeAndDispatch(pktNum uint64, user *User, decoder AudioDecod
event.X, event.Y, event.Z = position[0], position[1], position[2]
}
c.dispatchAudio(user, &event)
if terminator {
decoder.Reset()
user.audioSequenceValid = false
user.audioFrameStep = 0
c.dispatchAudio(user, &AudioPacket{Client: c, Sender: user, Terminator: true})
}
}
// missingAudioPackets returns the number of whole packets absent from a
+37
View File
@@ -0,0 +1,37 @@
package gumble
import "testing"
type terminatorDecoder struct{ resets int }
func (d *terminatorDecoder) ID() int { return audioCodecIDOpus }
func (d *terminatorDecoder) Decode([]byte, int) ([]int16, error) { return nil, nil }
func (d *terminatorDecoder) Reset() { d.resets++ }
type terminatorListener struct{ packets chan *AudioPacket }
func (l *terminatorListener) OnAudioStream(e *AudioStreamEvent) {
go func() { l.packets <- <-e.C }()
}
func TestUDP15EmptyTerminatorResetsAudioListeners(t *testing.T) {
decoder := &terminatorDecoder{}
listener := &terminatorListener{packets: make(chan *AudioPacket, 1)}
config := NewConfig()
config.AttachAudio(listener)
user := &User{Session: 1, Name: "speaker", decoder: decoder, audioSequenceValid: true}
client := &Client{Config: config, Users: Users{user.Session: user}}
client.dispatchOpus15(1, user.Session, 0, nil, true, 0, nil, 0)
packet := <-listener.packets
if !packet.Terminator {
t.Fatal("empty UDP terminator was not delivered to audio listeners")
}
if packet.AudioBuffer != nil {
t.Fatalf("terminator carried unexpected audio: %v", packet.AudioBuffer)
}
if decoder.resets != 1 || user.audioSequenceValid {
t.Fatalf("terminator did not reset decoder state: resets=%d valid=%v", decoder.resets, user.audioSequenceValid)
}
}
+69
View File
@@ -32,6 +32,75 @@ func TestAdvanceIV(t *testing.T) {
}
// Regression coverage for the native IV carry path at the 255->256 wrap.
func TestCryptState15DecryptsAfterMissedPackets(t *testing.T) {
key := mustDecodeHex("93360b0f86a926c4561563469026eb94")
nonce := mustDecodeHex("10000000000000000000000000000000")
out, in := &cryptState15{}, &cryptState15{}
if err := out.setup15(key, nonce, nonce); err != nil {
t.Fatal(err)
}
if err := in.setup15(key, nonce, nonce); err != nil {
t.Fatal(err)
}
first, err := out.encrypt15([]byte("first"))
if err != nil {
t.Fatal(err)
}
if _, err := in.decrypt15(first); err != nil {
t.Fatal(err)
}
for i := 0; i < 3; i++ {
if _, err := out.encrypt15([]byte("dropped")); err != nil {
t.Fatal(err)
}
}
last, err := out.encrypt15([]byte("after loss"))
if err != nil {
t.Fatal(err)
}
plain, err := in.decrypt15(last)
if err != nil || !bytes.Equal(plain, []byte("after loss")) {
t.Fatalf("decrypt after missed packets = %q, %v", plain, err)
}
}
func TestCryptState15DecryptsAfterMissedPacketsAcrossIVByteWrap(t *testing.T) {
key := mustDecodeHex("93360b0f86a926c4561563469026eb94")
nonce := mustDecodeHex("fa000000000000000000000000000000")
out, in := &cryptState15{}, &cryptState15{}
if err := out.setup15(key, nonce, nonce); err != nil {
t.Fatal(err)
}
if err := in.setup15(key, nonce, nonce); err != nil {
t.Fatal(err)
}
first, err := out.encrypt15([]byte("before wrap"))
if err != nil {
t.Fatal(err)
}
if _, err := in.decrypt15(first); err != nil {
t.Fatal(err)
}
for i := 0; i < 6; i++ {
if _, err := out.encrypt15([]byte("dropped")); err != nil {
t.Fatal(err)
}
}
last, err := out.encrypt15([]byte("after wrap"))
if err != nil {
t.Fatal(err)
}
plain, err := in.decrypt15(last)
if err != nil || !bytes.Equal(plain, []byte("after wrap")) {
t.Fatalf("decrypt after missed packets across IV wrap = %q, %v", plain, err)
}
if in.decryptIV[0] != 2 || in.decryptIV[1] != 1 {
t.Fatalf("unexpected IV after wrapped loss: %x", in.decryptIV[:2])
}
}
func TestCryptState15DecryptsAcrossIVByteWrap(t *testing.T) {
key := mustDecodeHex("93360b0f86a926c4561563469026eb94")
clientNonce := mustDecodeHex("ff000000000000000000000000000000")
+107 -16
View File
@@ -52,14 +52,46 @@ const recorderOutgoingSource uint32 = ^uint32(0)
const (
maxBufferSize = 11520 // Max frame size (2880) * bytes per stereo sample (4)
jitterMinPackets = 3
jitterMaxPackets = 10
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
)
// jitterPlaybackReady holds the initial playout delay only once. Requiring
// the minimum on every packet drains and refills the renderer in bursts.
func jitterPlaybackReady(started bool, buffered int) bool {
return started || buffered >= jitterMinPackets
// 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 {
return started || buffered >= target
}
func audioPacketDuration(packet *gumble.AudioPacket) time.Duration {
if packet == nil || len(packet.AudioBuffer) == 0 {
return 0
}
// Opus decoders deliver interleaved stereo PCM to this renderer.
frames := len(packet.AudioBuffer) / gumble.AudioChannels
return time.Duration(frames) * time.Second / gumble.AudioSampleRate
}
var (
@@ -256,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 {
@@ -484,8 +533,19 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
// Jitter buffer: collects incoming packets, reorders by
// sequence number, and releases them after a small initial delay.
var jitterBuf []*gumble.AudioPacket
var jitterDuration time.Duration
var jitterNextSeq int64
var jitterInit, jitterStarted bool
var jitterLateRun int
var jitterDrainLogCounter, jitterAnomalyLogCounter int
resetJitter := func() {
jitterBuf = nil
jitterDuration = 0
jitterNextSeq = 0
jitterInit = false
jitterStarted = false
jitterLateRun = 0
}
// insertSorted inserts a packet into the jitter buffer sorted
// by sequence number.
@@ -506,6 +566,7 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
jitterBuf = append(jitterBuf, nil)
copy(jitterBuf[i+1:], jitterBuf[i:])
jitterBuf[i] = p
jitterDuration += audioPacketDuration(p)
}
// popNext removes and returns the packet with the expected next
@@ -516,6 +577,7 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
}
p := jitterBuf[0]
jitterBuf = jitterBuf[1:]
jitterDuration -= audioPacketDuration(p)
// Frame numbers are Mumble timestamps in 10 ms units.
// Compute the actual step from the PCM sample count so we
// never skip a legitimate gap.
@@ -540,6 +602,14 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
}
for packet := range e.C {
// A talk burst may restart its frame numbers from zero. Reset before
// testing local mute so an unmute cannot retain the previous burst's
// timestamp and discard the new burst as permanently late.
if packet.Terminator {
resetJitter()
continue
}
// Skip processing if user is locally muted
if e.User.LocallyMuted() {
continue
@@ -556,31 +626,48 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
// Hold only the initial packets. Once playback starts, drain every
// ready packet so the renderer is fed continuously rather than in
// bursts of jitterMinPackets packets.
if !jitterPlaybackReady(jitterStarted, len(jitterBuf)) {
// bursts of packets.
if !jitterPlaybackReady(jitterStarted, jitterDuration, e.Client.Config.IncomingAudioBuffer) {
continue
}
jitterStarted = true
// Drain all packets that are ready (in sequence order)
drainedCount := 0
for {
pkt := popNext()
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.
log.Debug("jitter: discarding late seq=%d for %s (next=%d buf=%d)",
jitterBuf[0].Sequence, e.User.Name, jitterNextSeq, len(jitterBuf))
jitterAnomalyLogCounter++
if jitterAnomalyLogCounter <= 3 || jitterAnomalyLogCounter%1000 == 0 {
log.Debug("jitter: discarding late seq=%d for %s (next=%d buf=%d)",
jitterBuf[0].Sequence, e.User.Name, jitterNextSeq, len(jitterBuf))
}
jitterDuration -= audioPacketDuration(jitterBuf[0])
jitterBuf = jitterBuf[1:]
continue
}
if jitterBuf[0].Sequence > jitterNextSeq {
// Gap in sequence: skip ahead so we don't
// wait forever for a lost packet.
log.Debug("jitter: seq gap for %s, skipping from %d to %d (buf=%d)",
e.User.Name, jitterNextSeq, jitterBuf[0].Sequence, len(jitterBuf))
jitterAnomalyLogCounter++
if jitterAnomalyLogCounter <= 3 || jitterAnomalyLogCounter%1000 == 0 {
log.Debug("jitter: seq gap for %s, skipping from %d to %d (buf=%d)",
e.User.Name, jitterNextSeq, jitterBuf[0].Sequence, len(jitterBuf))
}
jitterNextSeq = jitterBuf[0].Sequence
continue
}
@@ -589,8 +676,9 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
}
break
}
drainedCount++
if drainedCount <= 3 || drainedCount%50 == 0 {
jitterLateRun = 0
jitterDrainLogCounter++
if jitterDrainLogCounter <= 3 || jitterDrainLogCounter%1000 == 0 {
log.Debug("jitter: draining seq=%d for %s (buf=%d emptyBufs=%d)",
pkt.Sequence, e.User.Name, len(jitterBuf), len(emptyBufs))
}
@@ -1002,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)
}
}
@@ -1010,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)
+31 -3
View File
@@ -4,8 +4,10 @@ import (
"errors"
"strings"
"testing"
"time"
"git.stormux.org/storm/barnard/gumble/go-openal/openal"
"git.stormux.org/storm/barnard/gumble/gumble"
)
// Regression: audio cleanup could send a final render command after Destroy
@@ -49,17 +51,43 @@ func TestStopSourceWaitsForWorker(t *testing.T) {
}
func TestJitterPlaybackDelayAppliesOnlyAtStartup(t *testing.T) {
if jitterPlaybackReady(false, jitterMinPackets-1) {
if jitterPlaybackReady(false, 20*time.Millisecond, 40*time.Millisecond) {
t.Fatal("jitter playback started before initial buffer filled")
}
if !jitterPlaybackReady(false, jitterMinPackets) {
if !jitterPlaybackReady(false, 40*time.Millisecond, 40*time.Millisecond) {
t.Fatal("jitter playback did not start after initial buffer filled")
}
if !jitterPlaybackReady(true, 1) {
if !jitterPlaybackReady(true, 0, 40*time.Millisecond) {
t.Fatal("jitter playback paused while refilling after startup")
}
}
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 {
t.Fatalf("audioPacketDuration = %v, want 10ms", got)
}
}
func TestRenderRejectsWorkAfterShutdown(t *testing.T) {
s := &Stream{renderClosed: true}
called := false
+37
View File
@@ -11,6 +11,7 @@ import (
"net/http"
"os"
"strings"
"time"
barnlog "git.stormux.org/storm/barnard/log"
@@ -84,6 +85,8 @@ func main() {
configSet := false
certificateSet := false
buffers := flag.Int("buffers", 16, "number of audio buffers to use")
audioInterval := flag.Int("audio-interval", 10, "outgoing audio packet duration in ms (10, 20, 40, or 60)")
jitterBuffer := flag.Int("jitter-buffer", 40, "incoming per-user audio buffer in ms (0, 20, 40, or 60)")
profile := flag.Bool("profile", false, "add http server to serve profiles")
noiseSuppressionEnabled := flag.Bool("noise-suppression", false, "enable noise suppression for microphone input")
autoTransmit := flag.Bool("auto-transmit", false, "start transmitting immediately on connect")
@@ -94,6 +97,14 @@ func main() {
logFile := flag.String("logfile", "", "write logs to this file (logging is disabled when omitted)")
flag.Parse()
selectedAudioInterval, err := audioIntervalDuration(*audioInterval)
if err != nil {
handle_raw_error(err)
}
selectedJitterBuffer, err := jitterBufferDuration(*jitterBuffer)
if err != nil {
handle_raw_error(err)
}
// Set up logging
var level barnlog.Level
@@ -192,6 +203,8 @@ func main() {
NoiseSuppressor: noise.NewSuppressor(),
}
b.Config.Buffers = *buffers
b.Config.AudioInterval = selectedAudioInterval
b.Config.IncomingAudioBuffer = selectedJitterBuffer
b.Config.DisableUDP = *tcpOnly
b.Hotkeys = b.UserConfig.GetHotkeys()
@@ -238,6 +251,30 @@ func main() {
handle_error(&b)
}
// audioIntervalDuration converts the packet duration requested at startup to
// one of the Opus durations supported by Mumble.
func audioIntervalDuration(milliseconds int) (time.Duration, error) {
interval := time.Duration(milliseconds) * time.Millisecond
switch interval {
case 10 * time.Millisecond, 20 * time.Millisecond, 40 * time.Millisecond, 60 * time.Millisecond:
return interval, nil
default:
return 0, fmt.Errorf("audio interval must be 10, 20, 40, or 60 ms, got %d", milliseconds)
}
}
// jitterBufferDuration converts the requested incoming playout delay to a
// supported duration. Zero starts playback without an initial safety buffer.
func jitterBufferDuration(milliseconds int) (time.Duration, error) {
interval := time.Duration(milliseconds) * time.Millisecond
switch interval {
case 0, 20 * time.Millisecond, 40 * time.Millisecond, 60 * time.Millisecond:
return interval, nil
default:
return 0, fmt.Errorf("jitter buffer must be 0, 20, 40, or 60 ms, got %d", milliseconds)
}
}
// serverAddress adds Mumble's default port without corrupting an IPv6 literal.
func serverAddress(address string) string {
if _, port, err := net.SplitHostPort(address); err == nil && port != "" {
+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) {
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)
+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) {
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 {