Empty buffer returns silence rather than blocking or signaling end-of-stream
Speaker buffer doubled from 100ms to 200ms as secondary underrun protection
duration
completed
tasks
files_changed
10m 38s
2026-03-05
2
3
Quick Task 15: Fix Audio Glitches and Skips in Decoding Summary
Ring-buffer BufferedStreamer with goroutine read-ahead between decoder/resampler and speaker output, plus 200ms speaker buffer — eliminates glitches from disk I/O stalls, GC pauses, and CPU scheduling delays.
Chain: decode→resample→BufferedStreamer→ctrl→volume→speaker; speaker buffer 100ms→200ms; Close on unload/track-change
Implementation Details
BufferedStreamer Design
Ring buffer: Pre-allocated [][2]float64 of configurable size (default 88200 samples ≈ 2 seconds at 44100 Hz)
Read-ahead goroutine: Reads from source in 512-sample chunks outside the mutex, copies into ring under lock
Thread safety: Mutex protects ring metadata only; source I/O never holds the lock, so the speaker goroutine is never blocked by disk
Empty buffer handling: Returns silence (len(samples), true) when buffer temporarily empty — brief silence is far better than a glitch or premature track end
Shutdown: Close() signals goroutine via channel; safe to call multiple times; called on track change and unload
Player Pipeline Changes
buffered *BufferedStreamer field added to Player struct
Inserted between beep.Resample and beep.Ctrl in updateStreamers()
loadFileLocked() closes old BufferedStreamer before loading new track
UnloadTrack() closes and nils BufferedStreamer to prevent goroutine leaks
Speaker buffer changed from time.Second/10 (100ms) to time.Second/5 (200ms)
Lock Safety
No changes to lock ordering or mutex-sensitive code paths. The BufferedStreamer is self-contained and does not interact with speaker.Lock() or p.mu.
Verification Results
go build ./backend/... — PASS
go vet ./backend/player/... — PASS
go test ./backend/player/ -v — all 12 tests pass (5 BufferedStreamer + 7 existing)