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
This commit is contained in:
2026-03-05 15:28:28 -05:00
parent 85b23acb24
commit 8a0b16a4ec
+24 -2
View File
@@ -48,6 +48,7 @@ type Player struct {
baseStreamer beep.Streamer baseStreamer beep.Streamer
seeker beep.StreamSeeker seeker beep.StreamSeeker
resampled beep.Streamer resampled beep.Streamer
buffered *BufferedStreamer
control *beep.Ctrl control *beep.Ctrl
volume *effects.Volume volume *effects.Volume
speakerStreamer beep.Streamer speakerStreamer beep.Streamer
@@ -125,9 +126,12 @@ func (p *Player) InitSpeaker() error {
defer profiling.TimeOp(p.logger, "player.InitSpeaker")() defer profiling.TimeOp(p.logger, "player.InitSpeaker")()
// TODO: allow user to change buffer size and speaker sample rate. // 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( err := speaker.Init(
p.format.SampleRate, p.format.SampleRate,
p.format.SampleRate.N(time.Second/10), p.format.SampleRate.N(time.Second/5),
) )
if err != nil { if err != nil {
return fmt.Errorf( return fmt.Errorf(
@@ -307,8 +311,15 @@ func (p *Player) updateStreamers(
4, sr, speakerSampleRate, p.baseStreamer, 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 // 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. // Preserve existing volume settings across track changes.
prevVolume := 0.0 prevVolume := 0.0
@@ -432,6 +443,11 @@ func (p *Player) loadFileLocked(filePath string) error {
p.state = Stopped p.state = Stopped
speaker.Unlock() speaker.Unlock()
// Stop the read-ahead goroutine for the previous track.
if p.buffered != nil {
p.buffered.Close()
}
if p.currentFile != nil { if p.currentFile != nil {
if closeErr := p.currentFile.Close(); closeErr != nil { if closeErr := p.currentFile.Close(); closeErr != nil {
p.logger.Warn( p.logger.Warn(
@@ -604,11 +620,17 @@ func (p *Player) UnloadTrack() {
p.currentFile = nil 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 // Release streamer chain. Volume is intentionally kept so the
// user's volume setting persists across tracks. // user's volume setting persists across tracks.
p.baseStreamer = nil p.baseStreamer = nil
p.seeker = nil p.seeker = nil
p.resampled = nil p.resampled = nil
p.buffered = nil
p.control = nil p.control = nil
p.speakerStreamer = nil p.speakerStreamer = nil
p.trackLengthMs = 0 p.trackLengthMs = 0