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 (
maxBufferSize = 11520 // Max frame size (2880) * bytes per stereo sample (4)
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.
// Requiring the delay on every packet drains and refills the renderer in bursts.
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 jitterNextSeq int64
var jitterInit, jitterStarted bool
var jitterLateRun int
var jitterDrainLogCounter, jitterAnomalyLogCounter int
resetJitter := func() {
jitterBuf = nil
@@ -502,6 +527,7 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
jitterNextSeq = 0
jitterInit = false
jitterStarted = false
jitterLateRun = 0
}
// insertSorted inserts a packet into the jitter buffer sorted
@@ -595,6 +621,17 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
if pkt == nil {
if len(jitterBuf) > 0 {
if jitterBuf[0].Sequence < jitterNextSeq {
jitterLateRun++
if jitterShouldResync(jitterLateRun, jitterNextSeq-jitterBuf[0].Sequence) {
// The sender restarted its frame numbering
// mid-burst. Follow it instead of discarding
// every remaining packet until it unkeys.
log.Debug("jitter: sequence restart for %s, resyncing from %d to %d",
e.User.Name, jitterNextSeq, jitterBuf[0].Sequence)
jitterNextSeq = jitterBuf[0].Sequence
jitterLateRun = 0
continue
}
// Late or duplicate: discard so it doesn't
// permanently block the drain loop.
jitterAnomalyLogCounter++
@@ -622,6 +659,7 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
}
break
}
jitterLateRun = 0
jitterDrainLogCounter++
if jitterDrainLogCounter <= 3 || jitterDrainLogCounter%1000 == 0 {
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) {
packet := &gumble.AudioPacket{AudioBuffer: make(gumble.AudioBuffer, 2*gumble.AudioDefaultFrameSize)}
if got := audioPacketDuration(packet); got != 10*time.Millisecond {