Compare commits
18
Commits
90f8a1ca5f
...
bmmcginty
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9fbc1800a | ||
|
|
f58d9f5dce | ||
|
|
4393739ffa | ||
|
|
132df6863a | ||
|
|
2cdbce8edb | ||
|
|
d2dff7f521 | ||
|
|
8a8cfde01d | ||
|
|
c0ef036934 | ||
|
|
d338925da6 | ||
|
|
893908f9a1 | ||
|
|
05dc6e4e0e | ||
|
|
883f7250f5 | ||
|
|
4510c25350 | ||
|
|
d4f8d56c8e | ||
|
|
c77d4bac3e | ||
|
|
0f34831c5b | ||
|
|
4c5a54c2dd | ||
|
|
eae1d8b99a |
@@ -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.
|
||||||
|
|
||||||
@@ -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.
|
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.)
|
(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
|
## Audio Devices
|
||||||
|
|
||||||
You can set the default input and output devices in the config file as well.
|
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>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
|
||||||
|
|||||||
+26
-24
@@ -2,41 +2,43 @@ package audio
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
|
"sync/atomic"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AGC (Automatic Gain Control) processor for voice normalization
|
// AGC (Automatic Gain Control) processor for voice normalization
|
||||||
type AGC struct {
|
type AGC struct {
|
||||||
targetLevel float32 // Target RMS level (0.0-1.0)
|
targetLevel float32 // Target RMS level (0.0-1.0)
|
||||||
maxGain float32 // Maximum gain multiplier
|
maxGain float32 // Maximum gain multiplier
|
||||||
minGain float32 // Minimum gain multiplier
|
minGain float32 // Minimum gain multiplier
|
||||||
attackTime float32 // Attack time coefficient
|
attackTime float32 // Attack time coefficient
|
||||||
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)
|
||||||
attackTime: 0.008, // Fast attack (8ms)
|
attackTime: 0.008, // Fast attack (8ms)
|
||||||
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
@@ -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
|
||||||
|
|||||||
@@ -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()))
|
||||||
|
|||||||
@@ -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) {
|
func TestServerAddressDefaultsPortWithoutBreakingIPv6(t *testing.T) {
|
||||||
for input, want := range map[string]string{
|
for input, want := range map[string]string{
|
||||||
"server": "server:64738",
|
"server": "server:64738",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ type AudioPacket struct {
|
|||||||
|
|
||||||
AudioBuffer
|
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
|
HasPosition bool
|
||||||
X, Y, Z float32
|
X, Y, Z float32
|
||||||
VolumeAdjustment float32
|
VolumeAdjustment float32
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package gumble
|
|||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -101,6 +102,19 @@ func Dial(config *Config) (*Client, error) {
|
|||||||
return DialWithDialer(new(net.Dialer), config, nil)
|
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.
|
// DialWithDialer connects to the Mumble server at the address given in config.
|
||||||
//
|
//
|
||||||
// The function returns after the connection has been established, the initial
|
// 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 {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
conn := tls.Client(rawConn, tlsConfig)
|
||||||
// net.Dialer.Timeout covers only the TCP dial. Apply the same bounded
|
// net.Dialer.Timeout covers only the TCP dial. Apply the same bounded
|
||||||
// deadline to TLS negotiation so a peer that accepts but never responds
|
// deadline to TLS negotiation so a peer that accepts but never responds
|
||||||
|
|||||||
@@ -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
@@ -25,6 +25,9 @@ type Config struct {
|
|||||||
AudioInterval time.Duration
|
AudioInterval time.Duration
|
||||||
// AudioDataBytes is the number of bytes that an audio frame can use.
|
// AudioDataBytes is the number of bytes that an audio frame can use.
|
||||||
AudioDataBytes int
|
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 forces all audio to use the TCP tunnel instead of UDP.
|
||||||
DisableUDP bool
|
DisableUDP bool
|
||||||
@@ -38,9 +41,10 @@ type Config struct {
|
|||||||
// NewConfig returns a new Config struct with default values set.
|
// NewConfig returns a new Config struct with default values set.
|
||||||
func NewConfig() *Config {
|
func NewConfig() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
Buffers: 8,
|
Buffers: 8,
|
||||||
AudioInterval: AudioDefaultInterval,
|
AudioInterval: AudioDefaultInterval,
|
||||||
AudioDataBytes: AudioDefaultDataBytes,
|
AudioDataBytes: AudioDefaultDataBytes,
|
||||||
|
IncomingAudioBuffer: 40 * time.Millisecond,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +58,9 @@ func (c *Config) Validate() error {
|
|||||||
if c.AudioDataBytes <= 0 {
|
if c.AudioDataBytes <= 0 {
|
||||||
return fmt.Errorf("gumble: AudioDataBytes must be positive")
|
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 {
|
if c.Buffers <= 0 {
|
||||||
return fmt.Errorf("gumble: Buffers must be positive")
|
return fmt.Errorf("gumble: Buffers must be positive")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -223,6 +223,11 @@ func (c *Client) handleUDPTunnel(buffer []byte) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.dispatchAudio(user, &event)
|
c.dispatchAudio(user, &event)
|
||||||
|
if isFinal {
|
||||||
|
decoder.Reset()
|
||||||
|
user.audioSequenceValid = false
|
||||||
|
c.dispatchAudio(user, &AudioPacket{Client: c, Sender: user, Terminator: true})
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,10 @@ func (c *Client) udpReadRoutine() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
packetCount++
|
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",
|
log.Debug("UDP recv #%d: %d bytes from %s hex=%s",
|
||||||
packetCount, n, addr, hex.EncodeToString(buf[:n]))
|
packetCount, n, addr, hex.EncodeToString(buf[:n]))
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-9
@@ -292,10 +292,13 @@ func (cs *cryptState15) decrypt15(packet []byte) ([]byte, error) {
|
|||||||
backupIV(cs.decryptIV[:])
|
backupIV(cs.decryptIV[:])
|
||||||
restore = true
|
restore = true
|
||||||
} else if ivByte > cs.decryptIV[0] && diff > 0 {
|
} 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 {
|
} else if ivByte < cs.decryptIV[0] && diff > 0 {
|
||||||
// Wrapped forward; advance and catch up.
|
// We missed packets across a low-byte wrap. The IV's higher
|
||||||
advanceIV(cs.decryptIV[:])
|
// bytes must advance even though the received low byte is set
|
||||||
|
// below rather than incremented.
|
||||||
|
advanceIVHighBytes(cs.decryptIV[:])
|
||||||
cs.decryptIV[0] = ivByte
|
cs.decryptIV[0] = ivByte
|
||||||
} else {
|
} else {
|
||||||
return nil, errors.New("gumble: OCB IV too far off")
|
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.
|
// backupIV decrements a 16-byte IV as a little-endian integer.
|
||||||
func backupIV(iv []byte) {
|
func backupIV(iv []byte) {
|
||||||
for i := 0; i < len(iv); i++ {
|
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
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Debug("UDP15 send: frame=%d opus_len=%d enc_len=%d final=%v",
|
if log.Enabled(log.LevelDebug) && (frameNum < 3 || frameNum%1000 == 0 || final) {
|
||||||
frameNum, len(data), len(encrypted), 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)
|
_, err = udpConn.Write(encrypted)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -662,7 +677,9 @@ func (c *Client) HandleUDPPacket15(packet []byte, pktNum uint64) {
|
|||||||
return
|
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()
|
c.markUDPActive()
|
||||||
|
|
||||||
// Check type byte (0x00 = Audio, 0x01 = Ping)
|
// Check type byte (0x00 = Audio, 0x01 = Ping)
|
||||||
@@ -735,6 +752,9 @@ func (c *Client) dispatchOpus15(pktNum uint64, session uint32, frameNum int64, o
|
|||||||
decoder.Reset()
|
decoder.Reset()
|
||||||
user.audioSequenceValid = false
|
user.audioSequenceValid = false
|
||||||
user.audioFrameStep = 0
|
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)
|
log.Info("UDP15 #%d: terminator for %s, decoder reset", pktNum, user.Name)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -743,7 +763,7 @@ func (c *Client) dispatchOpus15(pktNum uint64, session uint32, frameNum int64, o
|
|||||||
return
|
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)
|
// 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.
|
// 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
|
// 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.
|
// example, a standard 20 ms Opus packet advances its frame number by two.
|
||||||
// Only generate PLC for complete missing packets; treating every timestamp
|
// 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
|
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.audioSequence = frameNum
|
||||||
user.audioSequenceValid = true
|
user.audioSequenceValid = true
|
||||||
user.audioFrameStep = audioFrameStep(len(pcm))
|
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]
|
event.X, event.Y, event.Z = position[0], position[1], position[2]
|
||||||
}
|
}
|
||||||
c.dispatchAudio(user, &event)
|
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
|
// missingAudioPackets returns the number of whole packets absent from a
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,75 @@ func TestAdvanceIV(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Regression coverage for the native IV carry path at the 255->256 wrap.
|
// 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) {
|
func TestCryptState15DecryptsAcrossIVByteWrap(t *testing.T) {
|
||||||
key := mustDecodeHex("93360b0f86a926c4561563469026eb94")
|
key := mustDecodeHex("93360b0f86a926c4561563469026eb94")
|
||||||
clientNonce := mustDecodeHex("ff000000000000000000000000000000")
|
clientNonce := mustDecodeHex("ff000000000000000000000000000000")
|
||||||
|
|||||||
+107
-16
@@ -52,14 +52,46 @@ 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)
|
||||||
jitterMinPackets = 3
|
jitterMaxPackets = 50
|
||||||
jitterMaxPackets = 10
|
// 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
|
// jitterShouldResync reports whether the sender restarted its frame numbering
|
||||||
// the minimum on every packet drains and refills the renderer in bursts.
|
// rather than merely delivering a few packets out of order. lateRun is the
|
||||||
func jitterPlaybackReady(started bool, buffered int) bool {
|
// number of consecutive late packets and backJump is how far the current
|
||||||
return started || buffered >= jitterMinPackets
|
// 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 (
|
var (
|
||||||
@@ -256,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 {
|
||||||
@@ -484,8 +533,19 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
|
|||||||
// Jitter buffer: collects incoming packets, reorders by
|
// Jitter buffer: collects incoming packets, reorders by
|
||||||
// sequence number, and releases them after a small initial delay.
|
// sequence number, and releases them after a small initial delay.
|
||||||
var jitterBuf []*gumble.AudioPacket
|
var jitterBuf []*gumble.AudioPacket
|
||||||
|
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
|
||||||
|
resetJitter := func() {
|
||||||
|
jitterBuf = nil
|
||||||
|
jitterDuration = 0
|
||||||
|
jitterNextSeq = 0
|
||||||
|
jitterInit = false
|
||||||
|
jitterStarted = false
|
||||||
|
jitterLateRun = 0
|
||||||
|
}
|
||||||
|
|
||||||
// insertSorted inserts a packet into the jitter buffer sorted
|
// insertSorted inserts a packet into the jitter buffer sorted
|
||||||
// by sequence number.
|
// by sequence number.
|
||||||
@@ -506,6 +566,7 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
|
|||||||
jitterBuf = append(jitterBuf, nil)
|
jitterBuf = append(jitterBuf, nil)
|
||||||
copy(jitterBuf[i+1:], jitterBuf[i:])
|
copy(jitterBuf[i+1:], jitterBuf[i:])
|
||||||
jitterBuf[i] = p
|
jitterBuf[i] = p
|
||||||
|
jitterDuration += audioPacketDuration(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
// popNext removes and returns the packet with the expected next
|
// popNext removes and returns the packet with the expected next
|
||||||
@@ -516,6 +577,7 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
|
|||||||
}
|
}
|
||||||
p := jitterBuf[0]
|
p := jitterBuf[0]
|
||||||
jitterBuf = jitterBuf[1:]
|
jitterBuf = jitterBuf[1:]
|
||||||
|
jitterDuration -= audioPacketDuration(p)
|
||||||
// Frame numbers are Mumble timestamps in 10 ms units.
|
// Frame numbers are Mumble timestamps in 10 ms units.
|
||||||
// Compute the actual step from the PCM sample count so we
|
// Compute the actual step from the PCM sample count so we
|
||||||
// never skip a legitimate gap.
|
// never skip a legitimate gap.
|
||||||
@@ -540,6 +602,14 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for packet := range e.C {
|
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
|
// Skip processing if user is locally muted
|
||||||
if e.User.LocallyMuted() {
|
if e.User.LocallyMuted() {
|
||||||
continue
|
continue
|
||||||
@@ -556,31 +626,48 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
|
|||||||
|
|
||||||
// Hold only the initial packets. Once playback starts, drain every
|
// Hold only the initial packets. Once playback starts, drain every
|
||||||
// ready packet so the renderer is fed continuously rather than in
|
// ready packet so the renderer is fed continuously rather than in
|
||||||
// bursts of jitterMinPackets packets.
|
// bursts of packets.
|
||||||
if !jitterPlaybackReady(jitterStarted, len(jitterBuf)) {
|
if !jitterPlaybackReady(jitterStarted, jitterDuration, e.Client.Config.IncomingAudioBuffer) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
jitterStarted = true
|
jitterStarted = true
|
||||||
|
|
||||||
// Drain all packets that are ready (in sequence order)
|
// Drain all packets that are ready (in sequence order)
|
||||||
drainedCount := 0
|
|
||||||
for {
|
for {
|
||||||
pkt := popNext()
|
pkt := popNext()
|
||||||
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.
|
||||||
log.Debug("jitter: discarding late seq=%d for %s (next=%d buf=%d)",
|
jitterAnomalyLogCounter++
|
||||||
jitterBuf[0].Sequence, e.User.Name, jitterNextSeq, len(jitterBuf))
|
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:]
|
jitterBuf = jitterBuf[1:]
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if jitterBuf[0].Sequence > jitterNextSeq {
|
if jitterBuf[0].Sequence > jitterNextSeq {
|
||||||
// Gap in sequence: skip ahead so we don't
|
// Gap in sequence: skip ahead so we don't
|
||||||
// wait forever for a lost packet.
|
// wait forever for a lost packet.
|
||||||
log.Debug("jitter: seq gap for %s, skipping from %d to %d (buf=%d)",
|
jitterAnomalyLogCounter++
|
||||||
e.User.Name, jitterNextSeq, jitterBuf[0].Sequence, len(jitterBuf))
|
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
|
jitterNextSeq = jitterBuf[0].Sequence
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -589,8 +676,9 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
drainedCount++
|
jitterLateRun = 0
|
||||||
if drainedCount <= 3 || drainedCount%50 == 0 {
|
jitterDrainLogCounter++
|
||||||
|
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)",
|
||||||
pkt.Sequence, e.User.Name, len(jitterBuf), len(emptyBufs))
|
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() {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1010,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)
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.stormux.org/storm/barnard/gumble/go-openal/openal"
|
"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
|
// 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) {
|
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")
|
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")
|
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")
|
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) {
|
func TestRenderRejectsWorkAfterShutdown(t *testing.T) {
|
||||||
s := &Stream{renderClosed: true}
|
s := &Stream{renderClosed: true}
|
||||||
called := false
|
called := false
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
barnlog "git.stormux.org/storm/barnard/log"
|
barnlog "git.stormux.org/storm/barnard/log"
|
||||||
|
|
||||||
@@ -84,6 +85,8 @@ func main() {
|
|||||||
configSet := false
|
configSet := false
|
||||||
certificateSet := false
|
certificateSet := false
|
||||||
buffers := flag.Int("buffers", 16, "number of audio buffers to use")
|
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")
|
profile := flag.Bool("profile", false, "add http server to serve profiles")
|
||||||
noiseSuppressionEnabled := flag.Bool("noise-suppression", false, "enable noise suppression for microphone input")
|
noiseSuppressionEnabled := flag.Bool("noise-suppression", false, "enable noise suppression for microphone input")
|
||||||
autoTransmit := flag.Bool("auto-transmit", false, "start transmitting immediately on connect")
|
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)")
|
logFile := flag.String("logfile", "", "write logs to this file (logging is disabled when omitted)")
|
||||||
|
|
||||||
flag.Parse()
|
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
|
// Set up logging
|
||||||
var level barnlog.Level
|
var level barnlog.Level
|
||||||
@@ -192,6 +203,8 @@ func main() {
|
|||||||
NoiseSuppressor: noise.NewSuppressor(),
|
NoiseSuppressor: noise.NewSuppressor(),
|
||||||
}
|
}
|
||||||
b.Config.Buffers = *buffers
|
b.Config.Buffers = *buffers
|
||||||
|
b.Config.AudioInterval = selectedAudioInterval
|
||||||
|
b.Config.IncomingAudioBuffer = selectedJitterBuffer
|
||||||
b.Config.DisableUDP = *tcpOnly
|
b.Config.DisableUDP = *tcpOnly
|
||||||
|
|
||||||
b.Hotkeys = b.UserConfig.GetHotkeys()
|
b.Hotkeys = b.UserConfig.GetHotkeys()
|
||||||
@@ -238,6 +251,30 @@ func main() {
|
|||||||
handle_error(&b)
|
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.
|
// serverAddress adds Mumble's default port without corrupting an IPv6 literal.
|
||||||
func serverAddress(address string) string {
|
func serverAddress(address string) string {
|
||||||
if _, port, err := net.SplitHostPort(address); err == nil && port != "" {
|
if _, port, err := net.SplitHostPort(address); err == nil && port != "" {
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user