From cd003d7ac48864f4e010b9c27ef795d727c8351a Mon Sep 17 00:00:00 2001 From: "Brandon McGinty (deepseek)" Date: Mon, 10 Aug 2026 18:11:26 -0400 Subject: [PATCH] Fix jitter buffer: late-packet blockage and frame-step tracking - 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. --- gumble/gumbleopenal/stream.go | 51 ++++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/gumble/gumbleopenal/stream.go b/gumble/gumbleopenal/stream.go index 4c2918e..30f216b 100644 --- a/gumble/gumbleopenal/stream.go +++ b/gumble/gumbleopenal/stream.go @@ -470,8 +470,8 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) { emptyBufs = append(emptyBufs, reclaimedBufs...) } reclaimLogCounter++ - // Log every 50th reclaim to avoid spam, but always log if state is unusual - if reclaimLogCounter%50 == 1 || processed == 0 || srcState != openal.Playing { + // Log every 50th reclaim, or if state is not Playing + if reclaimLogCounter%50 == 1 || srcState != openal.Playing { log.Debug("reclaim #%d: state=%s processed=%d queued=%d empty=%d", reclaimLogCounter, srcState, processed, queued, len(emptyBufs)) } @@ -516,7 +516,26 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) { } p := jitterBuf[0] jitterBuf = jitterBuf[1:] - jitterNextSeq = p.Sequence + 1 + // Frame numbers are Mumble timestamps in 10 ms units. + // Compute the actual step from the PCM sample count so we + // never skip a legitimate gap. + samples := len(p.AudioBuffer) + if samples > gumble.AudioDefaultFrameSize && samples%2 == 0 { + // Stereo: step = stereo frames / base frame size + step := int64((samples / 2) / gumble.AudioDefaultFrameSize) + if step >= 1 { + jitterNextSeq = p.Sequence + step + } else { + jitterNextSeq = p.Sequence + 1 + } + } else { + step := int64(samples / gumble.AudioDefaultFrameSize) + if step >= 1 { + jitterNextSeq = p.Sequence + step + } else { + jitterNextSeq = p.Sequence + 1 + } + } return p } @@ -548,13 +567,25 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) { for { pkt := popNext() if pkt == nil { - // A locally dropped packet must not permanently stall the - // jitter buffer waiting for a sequence number that cannot arrive. - if len(jitterBuf) > 0 && jitterBuf[0].Sequence > jitterNextSeq { - 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 - continue + if len(jitterBuf) > 0 { + if jitterBuf[0].Sequence < jitterNextSeq { + // Late or duplicate: discard so it doesn't + // permanently block the drain loop. + log.Debug("jitter: discarding late seq=%d for %s (next=%d buf=%d)", + jitterBuf[0].Sequence, e.User.Name, jitterNextSeq, len(jitterBuf)) + jitterBuf = jitterBuf[1:] + continue + } + if jitterBuf[0].Sequence > jitterNextSeq { + // Gap in sequence: skip ahead so we don't + // wait forever for a lost packet. + 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 + continue + } + // Sequence == jitterNextSeq but popNext returned nil? + // Shouldn't happen; break to avoid infinite loop. } break }