Skip to content

Fix audio protocol bugs: robot voice, clipped syllables, spurious PLC - #2

Open
bnfone wants to merge 7 commits into
jonasgunklach:mainfrom
bnfone:fix/robot-voice-protocol
Open

Fix audio protocol bugs: robot voice, clipped syllables, spurious PLC#2
bnfone wants to merge 7 commits into
jonasgunklach:mainfrom
bnfone:fix/robot-voice-protocol

Conversation

@bnfone

@bnfone bnfone commented Jun 14, 2026

Copy link
Copy Markdown

Problem

CleanMumble produced robot voice on all non-CleanMumble clients (Linux,
standard iOS Mumble) and showed ~25 % apparent inbound packet loss when
receiving from any standard Mumble client.

Root cause

The Mumble protocol defines frame_number as a count of 10 ms audio
units
, not packets. This is documented in
docs/dev/network-protocol/voice_data.md and the authoritative receiver
formula in AudioOutputSpeech.cpp:216:

jbp.timestamp = iFrameSize * audioData.frameNumber

where iFrameSize = 480 samples = 10 ms @ 48 kHz. A 20 ms Opus frame
must therefore increment frame_number by 2 per packet.

Outbound (CleanMumble → remote): CleanMumble incremented by 1.
Remote jitter buffers computed timestamps at 2× speed → robot voice on
every receiving client.

Inbound (remote → CleanMumble): Standard clients send seq 0, 2, 4, 6…
The JitterBuffer expected step 1 and saw a gap at every odd sequence
number → 50 % of frames decoded as FEC → audio stretched 2× in duration
→ robot voice + ~25 % apparent packet loss in the stats UI.

Changes

Core protocol fix

  • RealMumbleClient: increment audioSequence by opusFrameMs / 10
    per packet (= 2 for 20 ms frames). Applied to both sendAudioFrame
    and sendTerminator.
  • JitterBuffer: auto-detect the sender's sequence step from the
    first two distinct sequence numbers received. The detected step is used
    for nextSeq advancement, FEC candidate lookup, and the ancient-packet
    drop threshold. Corrects nextSeq immediately if the drain timer
    already advanced it before the step was known.

Audio quality improvements

  • pendingTerminator: when the sender's terminator arrives in the
    same TCP batch as the last audio frames, defer the reset until the
    drain loop has played all buffered frames. Previously reset() was
    called immediately, silently dropping the last syllable of every
    utterance.
  • nextFrameDueTime: anchor stale detection to the playback timeline
    rather than packet arrival time. TCP-batched bursts drain faster than
    real-time, but the deadline advances one frame per play — preventing
    false PLC fires between batches.
  • Adaptive stale threshold: max(2 × frameMs, 0.75 × targetDepthMs)
    instead of the fixed 1.5 × frameMs (30 ms). Scales with observed
    jitter so high-latency paths get more headroom before PLC fires.
  • maxConsecutivePLC 250 ms → 80 ms: shorter PLC tail when a sender
    stops without a clean terminator.
  • TCP_NODELAY: disable Nagle's algorithm so each audio frame is sent
    immediately rather than being coalesced, removing up to 40 ms of
    artificial head-of-line delay.
  • Per-sender ring 200 ms → 1 s: the original 200 ms cap was chosen
    to bound latency when PLC ran at 2× real-time. With nextFrameDueTime
    fixing the spurious-PLC root cause, that hazard is gone. The larger
    ring absorbs TCP retransmit bursts (which can deliver 300–500 ms of
    audio at once) without dropping the oldest frames.

Testing

Tested on macOS 15 (Sequoia) against:

  • Linux Mumble client
  • Standard iOS Mumble client

bnfone and others added 7 commits June 14, 2026 11:47
…clients

The Mumble protocol defines frame_number (sequence number) as a count of
10 ms audio units, not a count of packets. This is documented in
docs/dev/network-protocol/voice_data.md:

  "The sequence number might increase by more than one between subsequent
   audio packets in case the audio packets contain multiple audio segments."

The authoritative formula is in AudioOutputSpeech.cpp:216:

  jbp.timestamp = iFrameSize * audioData.frameNumber

where iFrameSize = 480 samples = 10 ms @ 48 kHz. A 20 ms Opus frame
therefore requires frame_number to increment by 2 per packet.

Effect on outbound (CleanMumble → remote):
  CleanMumble sent seq 0, 1, 2, 3 … The remote jitter buffer computed
  timestamps as 480 × frameNumber, so each packet appeared to represent
  only 10 ms instead of 20 ms → playback at 2× speed → robot voice on
  iOS, Linux, and every other Mumble client receiving from CleanMumble.

Effect on inbound (remote → CleanMumble):
  Linux/iOS Mumble sends seq 0, 2, 4, 6 … (step 2 for 20 ms frames).
  JitterBuffer expected step 1, so it saw a gap at every odd sequence
  number and attempted Opus FEC for each apparent "missing" packet.
  50 % of all frames were decoded as FEC → audio stretched 2× in
  duration → robot voice + ~25 % apparent packet loss in the stats UI.

Why iOS → iOS worked: both ends used the same (wrong) convention, so
the timestamps were at least internally consistent.

Fix:
- RealMumbleClient: increment audioSequence by opusFrameMs / 10 per
  packet (= 2 for the default 20 ms frame size). Applied to both
  sendAudioFrame and sendTerminator.

- JitterBuffer: auto-detect the sender's sequence step from the first
  two distinct sequence numbers received in an utterance. The detected
  step is used for nextSeq advancement (play / FEC / PLC paths), the
  FEC candidate lookup (packets[want + seqStep]), and the ancient-packet
  drop threshold (nxt - seq > 8 * seqStep). If the drain timer has
  already advanced nextSeq by 1 before the step is known, nextSeq is
  corrected immediately in push() so the next tick finds the real packet
  instead of triggering spurious FEC. State resets on each reset() call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the Mumble server TCP-batches audio frames and a terminator in the
same flush, the terminator can arrive before the buffered audio frames
have been drained. Calling reset() immediately discards those frames and
the last syllable of the utterance is silently dropped.

onTerminator() checks whether frames are still queued:
- If yes: sets pendingTerminator=true and returns. The drain loop detects
  this flag once the queue empties and resets the decoder cleanly at that
  point, without emitting spurious PLC for the "missing" next packet.
- If no: resets immediately (same behaviour as before for the common case
  where the terminator arrives after all frames have already been played).

reset() is kept for hard teardown (user leaves channel / disconnect).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mumble audio travels over TCP, so the server batches several frames into
one flush. CleanMumble drains the batch faster than real-time (the drain
timer runs every 10 ms, frames are 20 ms each), after which the buffer
looks empty and the previous stale check — based on lastArrivalHostTime —
immediately fires PLC because "no packet arrived in the last frame period".

nextFrameDueTime anchors the stale deadline to the playback timeline:
after each emitted frame (play, FEC, or PLC) the deadline is set to
now + one frame interval. A missing packet is only considered stale when
that deadline has passed, which gives the next TCP batch time to arrive
before PLC kicks in. Before the first frame of an utterance is played
the check falls back to lastArrivalHostTime (same as before).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a sender stops speaking without a clean terminator (network drop,
app crash), the jitter buffer emits PLC silence until maxConsecutivePLC
is reached, then parks the playhead. At 25 frames × 10 ms = 250 ms this
tail was audibly long. 8 frames = 80 ms is enough to bridge real packet
loss without producing a noticeable silence artifact at utterance end.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The fixed threshold of 1.5 × frameMs (= 30 ms for 20 ms frames) fired
PLC whenever a packet was more than 30 ms late. Over a TCP connection
this is too aggressive: server-side batching, OS scheduling jitter, and
the occasional retransmit can all delay a packet by 30–60 ms without it
being truly lost.

The new threshold is max(2 × frameMs, 0.75 × targetDepthMs). targetDepthMs
is the jitter buffer's exponentially-smoothed depth estimate: it grows
automatically when the network is jittery and shrinks on clean paths. At
the default 60 ms jitter target this gives 45 ms of headroom; at 200 ms
(very jittery path) it gives 150 ms. The 2 × frameMs floor ensures we
always wait at least one extra frame period before declaring a packet lost.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Nagle's algorithm coalesces small TCP writes into larger segments to
improve throughput. For bulk data this is desirable, but for real-time
audio it adds latency: the OS holds a 20 ms Opus frame until either the
previous segment is acknowledged or enough data accumulates to fill an
MSS (~1460 bytes). On a LAN the ACK usually arrives within a few ms, but
on paths with higher RTT this can delay frames by up to 40 ms — an entire
frame period — before they are even sent.

With TCP_NODELAY each sendFrame() call results in an immediate TCP segment,
keeping end-to-end audio latency at the network RTT rather than RTT + Nagle
coalescing delay. The slight increase in TCP overhead (one segment per frame
instead of potentially batched) is negligible for Opus frame sizes (20–150
bytes) compared to IP/TCP header overhead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The original 200 ms (9 600 samples @ 48 kHz) cap was chosen to bound
latency in a specific failure mode: the JitterBuffer's 10 ms drain timer
emits one 20 ms frame per tick when PLC is running, filling the ring at
2× real-time. A 200 ms ring would overflow after ~100 ms of continuous
PLC and drop the oldest audio — so keeping it small made the worst-case
latency predictable.

That reasoning no longer applies after two fixes introduced earlier in
this branch:

1. nextFrameDueTime ("Fix spurious PLC between TCP bursts", f744c9b):
   stale detection is anchored to the playback timeline rather than
   packet arrival time. When the server TCP-batches several frames they
   drain faster than real-time, but the due-time deadline advances one
   frame per play — so PLC does not fire between batches. Continuous PLC
   now only occurs during genuine packet loss, which on a Mumble TCP
   connection is rare.

2. Adaptive stale threshold ("Use adaptive stale threshold…", 76ca76d):
   delayed packets on high-jitter paths that previously triggered a PLC
   cycle now wait longer for the real packet to arrive. This further
   reduces spurious PLC.

With PLC bursts effectively eliminated the 2× fill-rate hazard is gone.
The remaining risk is a TCP retransmit: a single retransmit on a path
with RTT > 100 ms can deliver 300–500 ms of queued audio all at once.
The JitterBuffer drains that burst faster than real-time and all decoded
PCM lands in the ring before CoreAudio can consume it. A 200 ms ring
overflows in that scenario; the oldest frames are dropped and the
listener hears an audible glitch.

1 s (48 000 samples) absorbs a worst-case retransmit burst while keeping
the maximum latency at 1 s + JB target depth — well within the 2–3 s
that is tolerable for voice conversation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant