From 8a0b16a4ec08a95bfd3834c8216e21dce854432d Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Thu, 5 Mar 2026 15:28:28 -0500 Subject: [PATCH] feat(quick-15): insert BufferedStreamer into player pipeline and increase speaker buffer - BufferedStreamer wraps resampled streamer with 2s read-ahead buffer - Speaker buffer increased from 100ms to 200ms for secondary protection - Close old BufferedStreamer on track change and unload to prevent goroutine leaks - Streamer chain: decode -> resample -> BufferedStreamer -> ctrl -> volume -> speaker --- backend/player/player.go | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/backend/player/player.go b/backend/player/player.go index c2e173d..8ee39aa 100644 --- a/backend/player/player.go +++ b/backend/player/player.go @@ -48,6 +48,7 @@ type Player struct { baseStreamer beep.Streamer seeker beep.StreamSeeker resampled beep.Streamer + buffered *BufferedStreamer control *beep.Ctrl volume *effects.Volume speakerStreamer beep.Streamer @@ -125,9 +126,12 @@ func (p *Player) InitSpeaker() error { defer profiling.TimeOp(p.logger, "player.InitSpeaker")() // TODO: allow user to change buffer size and speaker sample rate. + // Speaker buffer is 200ms (~8820 samples at 44100 Hz), providing + // secondary protection against underruns behind the read-ahead + // BufferedStreamer. err := speaker.Init( p.format.SampleRate, - p.format.SampleRate.N(time.Second/10), + p.format.SampleRate.N(time.Second/5), ) if err != nil { return fmt.Errorf( @@ -307,8 +311,15 @@ func (p *Player) updateStreamers( 4, sr, speakerSampleRate, p.baseStreamer, ) + // Buffer resampled audio to decouple disk I/O from speaker + // timing. 2 seconds of read-ahead at speaker sample rate + // absorbs I/O stalls and GC pauses without audible glitches. + p.buffered = NewBufferedStreamer( + p.resampled, int(speakerSampleRate)*2, + ) + // wrap in ctrl streamer to allow play/pause - p.control = &beep.Ctrl{Streamer: p.resampled} + p.control = &beep.Ctrl{Streamer: p.buffered} // Preserve existing volume settings across track changes. prevVolume := 0.0 @@ -432,6 +443,11 @@ func (p *Player) loadFileLocked(filePath string) error { p.state = Stopped speaker.Unlock() + // Stop the read-ahead goroutine for the previous track. + if p.buffered != nil { + p.buffered.Close() + } + if p.currentFile != nil { if closeErr := p.currentFile.Close(); closeErr != nil { p.logger.Warn( @@ -604,11 +620,17 @@ func (p *Player) UnloadTrack() { p.currentFile = nil } + // Stop the read-ahead goroutine before releasing the chain. + if p.buffered != nil { + p.buffered.Close() + } + // Release streamer chain. Volume is intentionally kept so the // user's volume setting persists across tracks. p.baseStreamer = nil p.seeker = nil p.resampled = nil + p.buffered = nil p.control = nil p.speakerStreamer = nil p.trackLengthMs = 0