From f7ca1382964676b167cf46ab16f0ab184915414f Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Sun, 22 Mar 2026 11:10:53 -0400 Subject: [PATCH] fix: flush BufferedStreamer ring buffer on seek to prevent stale audio When seeking, the underlying decoder position was updated but the BufferedStreamer's ring buffer still contained up to 2 seconds of pre-seek audio data. The speaker would drain this stale buffer before playing audio from the new position, causing an audible delay where the old position's audio continued playing. Add a Flush() method to BufferedStreamer that resets the ring buffer pointers, and call it in seekLocked() immediately after a successful seek. This ensures the speaker starts playing from the seeked position without any stale audio artifact. --- backend/player/buffered_streamer.go | 13 ++++ backend/player/buffered_streamer_test.go | 94 ++++++++++++++++++++++++ backend/player/player.go | 7 ++ 3 files changed, 114 insertions(+) diff --git a/backend/player/buffered_streamer.go b/backend/player/buffered_streamer.go index 6d711fd..1d51f20 100644 --- a/backend/player/buffered_streamer.go +++ b/backend/player/buffered_streamer.go @@ -177,6 +177,19 @@ func (bs *BufferedStreamer) Err() error { return bs.err } +// Flush discards all buffered samples so the next Stream call +// returns freshly-read data from the source. This must be called +// after seeking the underlying source to prevent stale pre-seek +// audio from being played back. +func (bs *BufferedStreamer) Flush() { + bs.mu.Lock() + defer bs.mu.Unlock() + + bs.readPos = 0 + bs.writPos = 0 + bs.count = 0 +} + // Close signals the read-ahead goroutine to stop. It is safe to // call multiple times. func (bs *BufferedStreamer) Close() { diff --git a/backend/player/buffered_streamer_test.go b/backend/player/buffered_streamer_test.go index 3d90528..96a1684 100644 --- a/backend/player/buffered_streamer_test.go +++ b/backend/player/buffered_streamer_test.go @@ -260,6 +260,100 @@ func TestBufferedStreamer_EmptyBufferReturnsSilence(t *testing.T) { } } +func TestBufferedStreamer_Flush(t *testing.T) { + // Use a slow source so the read-ahead goroutine cannot fully + // drain it before we flush. Each chunk sleeps 5ms, giving us + // time to flush while data is still being produced. + src := &slowStreamer{ + inner: finiteStreamer(5000), + delay: 5 * time.Millisecond, + } + bs := NewBufferedStreamer(src, 2048) + + defer bs.Close() + + // Let read-ahead fill some data. + time.Sleep(50 * time.Millisecond) + + // Read a few samples to confirm data is buffered. + buf := make([][2]float64, 32) + n, ok := bs.Stream(buf) + + if !ok || n == 0 { + t.Fatal("expected buffered data before flush") + } + + // Record the last sample value we saw. + lastBefore := buf[n-1][0] + + // Verify the buffer had more data than we consumed (i.e. + // there's stale data in the ring that Flush should discard). + bs.mu.Lock() + countBeforeFlush := bs.count + bs.mu.Unlock() + + if countBeforeFlush == 0 { + t.Fatal("expected non-empty ring buffer before flush") + } + + // Flush discards all buffered data. + bs.Flush() + + // Verify the ring buffer is empty after flush. + bs.mu.Lock() + countAfterFlush := bs.count + bs.mu.Unlock() + + if countAfterFlush != 0 { + t.Fatalf( + "expected 0 samples after flush, got %d", + countAfterFlush, + ) + } + + // Wait for read-ahead to refill with fresh data. + time.Sleep(100 * time.Millisecond) + + // The next non-zero sample must come from AFTER the + // pre-flush position in the source (i.e. its value must be + // greater than lastBefore + countBeforeFlush, since those + // samples were discarded). + foundNonZero := false + + for range 200 { + n, ok = bs.Stream(buf) + + for i := range n { + if buf[i][0] != 0 { + // The sample must be strictly greater than what + // was buffered before flush. + if buf[i][0] <= lastBefore { + t.Fatalf( + "after flush, got sample value %f "+ + "which is <= pre-flush value %f "+ + "(stale data not discarded)", + buf[i][0], lastBefore, + ) + } + + foundNonZero = true + + break + } + } + + if foundNonZero || !ok { + break + } + + time.Sleep(1 * time.Millisecond) + } + + if !foundNonZero { + t.Fatal("expected non-zero samples after flush") + } +} + func TestBufferedStreamer_Close(t *testing.T) { // Use a source that never drains. infinite := beep.StreamerFunc(func(samples [][2]float64) (int, bool) { diff --git a/backend/player/player.go b/backend/player/player.go index 31426c9..c4eb1c9 100644 --- a/backend/player/player.go +++ b/backend/player/player.go @@ -818,6 +818,13 @@ func (p *Player) seekLocked(targetSeconds int) error { speaker.Unlock() + // Flush the read-ahead buffer so the speaker immediately + // plays audio from the new position instead of draining + // up to 2 seconds of stale pre-seek samples. + if p.buffered != nil { + p.buffered.Flush() + } + if p.mediaControls != nil { p.mediaControls.NotifySeek(targetSeconds) }