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.
This commit is contained in:
2026-03-22 11:10:53 -04:00
parent 3173b78a87
commit f7ca138296
3 changed files with 114 additions and 0 deletions
+13
View File
@@ -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() {
+94
View File
@@ -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) {
+7
View File
@@ -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)
}