Resync jitter buffer when a sender restarts frame numbering

Mumble destroys and recreates AudioInput when the sender switches audio
devices. The destructor sends no terminator, and the replacement resets
iFrameCounter to zero, so a sender who never unkeys silently restarts its
frame numbering mid-burst. The jitter buffer kept expecting the old
sequence and discarded every packet as late until the next unkey.

Resync on a sustained run of late packets combined with a backwards jump
too large to be network reordering. Both conditions are needed: the run
length alone would let a clump of reordered packets drag the expected
sequence backwards, and small jumps need no intervention because the
restarted stream climbs past the stale expectation on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tyler Spivey
2026-08-14 13:00:06 -07:00
co-authored by Claude Opus 5
parent 2cdbce8edb
commit 132df6863a
2 changed files with 57 additions and 0 deletions
+38
View File
@@ -53,8 +53,32 @@ const recorderOutgoingSource uint32 = ^uint32(0)
const ( const (
maxBufferSize = 11520 // Max frame size (2880) * bytes per stereo sample (4) maxBufferSize = 11520 // Max frame size (2880) * bytes per stereo sample (4)
jitterMaxPackets = 50 jitterMaxPackets = 50
// Mumble destroys and recreates AudioInput when the sender switches audio
// devices, which restarts its frame numbering at zero. The destructor
// sends no terminator, so a sender that never unkeys leaves us expecting a
// frame number the new stream will not reach for hours: every packet looks
// permanently late and gets discarded. Detect that and resync.
//
// Two conditions must hold together. A sustained run of late packets
// distinguishes a restarted stream from a clump of reordered packets,
// which is bounded and then recovers on its own. The backwards jump must
// also be too large to be network reordering; a smaller jump needs no
// intervention because the restarted stream climbs back past the stale
// expectation within jitterResyncJump frames anyway.
jitterLateResync = 5
// Frame numbers are Mumble timestamps in 10 ms units, so this is 1 second
// — far beyond any real reordering window.
jitterResyncJump = 100
) )
// jitterShouldResync reports whether the sender restarted its frame numbering
// rather than merely delivering a few packets out of order. lateRun is the
// number of consecutive late packets and backJump is how far the current
// packet sits below the expected sequence.
func jitterShouldResync(lateRun int, backJump int64) bool {
return lateRun >= jitterLateResync && backJump >= jitterResyncJump
}
// jitterPlaybackReady holds the requested initial playout delay only once. // jitterPlaybackReady holds the requested initial playout delay only once.
// Requiring the delay on every packet drains and refills the renderer in bursts. // Requiring the delay on every packet drains and refills the renderer in bursts.
func jitterPlaybackReady(started bool, buffered, target time.Duration) bool { func jitterPlaybackReady(started bool, buffered, target time.Duration) bool {
@@ -495,6 +519,7 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
var jitterDuration time.Duration var jitterDuration time.Duration
var jitterNextSeq int64 var jitterNextSeq int64
var jitterInit, jitterStarted bool var jitterInit, jitterStarted bool
var jitterLateRun int
var jitterDrainLogCounter, jitterAnomalyLogCounter int var jitterDrainLogCounter, jitterAnomalyLogCounter int
resetJitter := func() { resetJitter := func() {
jitterBuf = nil jitterBuf = nil
@@ -502,6 +527,7 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
jitterNextSeq = 0 jitterNextSeq = 0
jitterInit = false jitterInit = false
jitterStarted = false jitterStarted = false
jitterLateRun = 0
} }
// insertSorted inserts a packet into the jitter buffer sorted // insertSorted inserts a packet into the jitter buffer sorted
@@ -595,6 +621,17 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
if pkt == nil { if pkt == nil {
if len(jitterBuf) > 0 { if len(jitterBuf) > 0 {
if jitterBuf[0].Sequence < jitterNextSeq { if jitterBuf[0].Sequence < jitterNextSeq {
jitterLateRun++
if jitterShouldResync(jitterLateRun, jitterNextSeq-jitterBuf[0].Sequence) {
// The sender restarted its frame numbering
// mid-burst. Follow it instead of discarding
// every remaining packet until it unkeys.
log.Debug("jitter: sequence restart for %s, resyncing from %d to %d",
e.User.Name, jitterNextSeq, jitterBuf[0].Sequence)
jitterNextSeq = jitterBuf[0].Sequence
jitterLateRun = 0
continue
}
// Late or duplicate: discard so it doesn't // Late or duplicate: discard so it doesn't
// permanently block the drain loop. // permanently block the drain loop.
jitterAnomalyLogCounter++ jitterAnomalyLogCounter++
@@ -622,6 +659,7 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
} }
break break
} }
jitterLateRun = 0
jitterDrainLogCounter++ jitterDrainLogCounter++
if jitterDrainLogCounter <= 3 || jitterDrainLogCounter%1000 == 0 { if jitterDrainLogCounter <= 3 || jitterDrainLogCounter%1000 == 0 {
log.Debug("jitter: draining seq=%d for %s (buf=%d emptyBufs=%d)", log.Debug("jitter: draining seq=%d for %s (buf=%d emptyBufs=%d)",
@@ -62,6 +62,25 @@ func TestJitterPlaybackDelayAppliesOnlyAtStartup(t *testing.T) {
} }
} }
func TestJitterResyncsAfterSenderRestartsSequence(t *testing.T) {
// Mumble restarts frame numbering at zero when the sender switches audio
// devices mid-burst, and sends no terminator to announce it.
if !jitterShouldResync(jitterLateResync, 52724) {
t.Fatal("jitter did not resync after the sender restarted its frame numbering")
}
if jitterShouldResync(jitterLateResync-1, 52724) {
t.Fatal("jitter resynced before the late run was conclusive")
}
// A clump of reordered packets is bounded and recovers on its own; it must
// not drag the expected sequence backwards.
if jitterShouldResync(jitterLateResync, jitterResyncJump-1) {
t.Fatal("jitter resynced on a backwards jump small enough to be reordering")
}
if jitterShouldResync(1, 52724) {
t.Fatal("jitter resynced on a single late packet")
}
}
func TestAudioPacketDurationUsesStereoFrameCount(t *testing.T) { func TestAudioPacketDurationUsesStereoFrameCount(t *testing.T) {
packet := &gumble.AudioPacket{AudioBuffer: make(gumble.AudioBuffer, 2*gumble.AudioDefaultFrameSize)} packet := &gumble.AudioPacket{AudioBuffer: make(gumble.AudioBuffer, 2*gumble.AudioDefaultFrameSize)}
if got := audioPacketDuration(packet); got != 10*time.Millisecond { if got := audioPacketDuration(packet); got != 10*time.Millisecond {