Add a per-user jitter buffer with restart resync.
Incoming audio was rendered straight to OpenAL as packets arrived, so
normal network jitter produced gaps and the renderer starved between
frames. Buffer each user's decoded frames for a configurable playout
delay (-jitter-buffer, default 40ms) and let the render thread pull
from that buffer instead.
Drop late packets, and use packet duration to set the expected frame
number.
A packet below the expected frame number is dropped rather than
stalling delivery, and the expected frame is stepped by the packet's
real duration instead of a fixed 10ms.
After detecting a large backward jump in frame numbering, along with a
sustained run of late packets, resync to the new frame numbering.
When a sender switches audio devices, Mumble destroys and recreates
their audio stream. This resets their frame number to zero without
sending a terminator packet, which causes a potentially endless hang
while we wait for a frame number that will not arrive for hours.
Own the OpenAL context on one dedicated render thread.
Contexts are current to an OS thread, so creating sources and buffers
from whichever goroutine happened to be running could operate on no
context at all. Every source and buffer is now created, filled, and
deleted on that thread, and file playback renders through it too.
Report device and capture failures with the device name.
Startup errors said only that a device could not be opened, and a
capture stall was reported as a microphone failure even though it is
normal scheduler timing. Fatal microphone errors now exit instead of
leaving a client that cannot transmit.
Release queued buffers and deactivate the context on shutdown.
Streams were torn down while buffers were still queued on a source,
and the context was destroyed while still current.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Accumulate incoming frames per source and consume fixed-size chunks.
Each speaker's frames were queued whole and one frame was taken per
tick, so a frame that did not match the recorder's frame size was
truncated or padded and the recording drifted out of time with the
audio. Frames of any size now append to a per-source buffer that is
drained in exact chunks.
Tell the recorder whether a frame is stereo instead of guessing from
its length.
Mono microphone frames were being interpreted as stereo, which halved
their duration and produced static in the output.
Let the recorder worker close ffmpeg's stdin.
Stop closed it from the caller while the worker was still writing,
turning a normal stop into a broken pipe and losing the tail of the
recording.
Reserve the output file exclusively and hand ffmpeg the descriptor.
The path was generated, then reopened by name, so another process
could take the name in between. The file is opened once with O_EXCL
and passed to ffmpeg as an inherited descriptor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Return early instead of indexing empty slices.
Buffer, source, listener, and capture calls took the address of
element zero to hand C a pointer, which panics when the caller passes
nothing to delete, queue, or read.
Give devices and contexts their own handle types.
They were passed around as untyped pointers, so a device could be
supplied where a context was expected and the mistake only showed up
as a crash inside the C library.
Delete buffers through the buffer API.
Buffer names were being freed with the source deletion call, which
leaks the buffer and can free an unrelated object.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reject unsupported audio intervals and non-positive buffer sizes.
An interval like 15ms was truncated to 10ms frames while the send
ticker kept the original duration, so packets were produced at a rate
the frame size did not match. Checking at dial time gives a clear
error instead of malformed audio.
Require all three coordinates for positional audio.
Only X was checked, so supplying X without Y or Z wrote a header that
claimed positional data and then read past the values that were
actually provided.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Derive the TLS server name from the configured address.
tls.DialWithDialer was handed the raw address, so a server reached by
a name that differs from its certificate could not be verified and
SNI was never sent. The dial is now split into a plain TCP connect and
an explicit tls.Client with the hostname filled in.
Clone the caller's TLS configuration before modifying it.
Reconnects and concurrent clients share one configuration value, and
setting ServerName on it would leak across connections.
Apply the dialer timeout to the handshake as well.
net.Dialer.Timeout covers only the TCP connect, so a peer that accepts
and then goes silent could block startup forever.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add the OCB2-AES128 crypt state used by the Mumble UDP channel.
CryptSetup carries the key and both nonces; the handler installs them
instead of being an unimplemented stub, so UDP audio can be encrypted
and decrypted at all.
Add the Mumble 1.5 native UDP transport.
The socket opens during the handshake so it is ready when CryptSetup
arrives, and audio is sent as a protobuf envelope with a leading type
byte. Advertise ourselves as a 1.5 client so servers select it.
Advance the decrypt IV from the server nonce.
Using the client nonce for inbound packets desynchronized the two
sides after the first packet.
Keep the legacy UDPVoiceOpus payload working inside 1.5 crypto.
A 1.5 server still tunnels the older 0x80 format to older peers, so
both payload shapes have to be decoded from the same envelope.
Fall back to the TCP tunnel until a UDP packet is authenticated.
Nothing confirms the return path works until the server answers, so
audio stays tunneled until then and switches to UDP afterwards.
Tunneled packets are ignored while UDP is active so a frame is never
processed twice.
Add -tcp to force the tunnel and skip UDP entirely.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fill sequence gaps with Opus packet loss concealment.
A dropped packet previously left a hole in the stream. The decoder is
now asked for a concealed frame for each missing sequence number, and
is only reset when a decode actually fails or a talk burst ends.
Mark the end of a talk burst with a terminator packet.
Listeners need to drop their per-speaker ordering state before the
sender starts numbering a new burst.
Set the encoder bitrate from the packet budget and frame size.
SetBitrateToMax ignored the server's bandwidth limit, so frames were
encoded larger than the allowed data bytes and truncated. The result
is clamped to Opus's 8 kbps floor, and the auto-bitrate listener now
keeps at least ten bytes per frame so a low-bandwidth server cannot
compute a budget too small to encode anything.
Allocate the decode buffer for the sample count it is given.
The decoder doubled the requested frame size for stereo and then
returned a slice scaled by the same factor again.
Hold the client read lock across encode and reset.
File playback can replace or reset the stereo encoder while a voice
frame is being encoded with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Move volume, boost, mute, and OpenAL source behind accessors guarded
by a per-user mutex.
The audio goroutine reads these fields on every decoded packet while
the UI writes them from key handlers, which is a data race on the
gain a stream is currently rendering with.
Also add the per-user sequence fields the decoder needs to notice
gaps in a user's audio stream.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reject ACL groups and user-list entries that omit required fields.
Both handlers dereferenced optional protobuf pointers directly, so a
malformed or hostile packet crashed the client.
Read user statistics from the server counters.
The three FromServer fields were guarded by the matching FromClient
pointers, so server-side late, lost, and resync counts were reported
as zero whenever the client-side ones were absent.
Remove stale reverse links when a channel's link set is replaced.
Rebuilding the map left the other channel still pointing back at us,
and the new links were never made reciprocal.
Clamp negative ban durations to zero.
Duration is sent as an unsigned protocol field, so a negative value
became an effectively permanent ban.
Validate manual ban minutes before they reach that field.
Reject non-numeric, negative, and overflowing input in the UI rather
than converting it silently.
Confirm admin targets still exist before running an action.
A user or channel can be removed by the server while an admin prompt
is open, leaving the action pointed at an object no longer in the
connection.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Guard the listener lists with a mutex and iterate a snapshot.
Attaching or detaching a listener from another goroutine mutated the
linked list while a dispatch was walking it, and a second Detach on
the same item corrupted the head and tail links.
Deliver audio through dispatchAudio instead of an inline loop that
juggled the volatile lock.
The old loop unlocked and relocked around every listener, so a user
map change mid-dispatch could be missed. Streams are now buffered and
a slow listener has its packet dropped rather than blocking protocol
processing.
Close a user's audio streams when the user is removed.
Detaching a listener closes its streams too, so a stream can no longer
be written after the reader is gone.
Unlock volatile when a user moves to an unknown channel.
The error path locked it a second time instead of unlocking, which
deadlocked all later protocol handling.
Give context actions their client when they are created.
Every context action method dereferences it, so triggering a
server-supplied action panicked on a nil client.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Encode values the way protobuf does rather than with Mumble's
tunnel-specific varint format.
The 1.5 native UDP protocol carries protobuf messages, so the two
encodings were being mixed and the server rejected our packets.
Handle the minimum signed 64-bit value without overflowing.
Negating math.MinInt64 wraps back to itself, which produced a
corrupt encoding instead of an error.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add a log package with debug, info, warn, and error levels.
It wraps a single process-wide logger that the gumble and audio
packages can call into without importing a logging library.
Logging is off unless -logfile names a file.
Anything written to stderr while the terminal UI is running corrupts
the display, so a logger is only installed once an explicit
destination exists. -log sets the level and defaults to warn.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
The recording pipeline had two bugs that combined to produce
buzzing/static in recorded audio:
1. NormalizeStereoFrame truncated incoming audio frames to
frameSize*AudioChannels samples. When the Opus decoder produced
20ms frames (1920 stereo samples) but the recorder used a 10ms
frameSize (960 samples), half the audio from every packet was
silently dropped.
2. The recorder's run() loop dequeued one fixed-length frame per
source per tick. After truncation, the remaining 10ms of each
20ms packet was gone, so every other tick produced silence.
This 50 Hz on/off pattern sounded like static.
Fixes:
- NormalizeStereoFrame no longer truncates; it only converts mono
to stereo and preserves all audio data
- RecordAudioFrame now accepts an explicit stereo flag from callers
instead of guessing from sample count (which failed for even-length
mono data like 480-sample mic frames)
- run() accumulates variable-length frames per source and consumes
them in fixed-size chunks, preserving any leftover for the next tick
- popNext() now computes the actual frame step from PCM sample count
instead of always advancing by 1. Mumble 1.5 frame numbers are 10ms
timestamps, so 20ms stereo frames advance by 2, eliminating the
'seq gap' skip that fired on every single packet.
- Drain loop now discards late/duplicate packets (sequence < expected)
instead of letting them permanently block the buffer. Previously a
single late packet at jitterBuf[0] would cause popNext to always
return nil without the gap check catching it (only handled >), so
all subsequent drains would break immediately.
- Reduced reclaim log verbosity: log every 50th or on state change,
not every call with processed==0.
- Log OpenAL vendor/version/renderer at startup
- Log buffer creation count and user volume on new audio stream
- Enhanced reclaim(): log source state, processed/queued/empty counts,
and check for OpenAL errors; log every 50th cycle or on unusual state
- Log jitter buffer drain events (first 3 + periodic) with buffer counts
- Log sequence gap skips in jitter buffer
- WARN when processAudioPacket has no empty buffers (audio dropped)
- Check OpenAL errors after Buffer.SetData, QueueBuffer, and Play()
- Log source state transitions when calling Play() on non-playing source