package player import ( "sync" "time" "github.com/gopxl/beep/v2" ) // BufferedStreamer wraps a beep.Streamer with a goroutine-driven // read-ahead ring buffer. It decouples the source streamer's I/O // timing from the speaker callback's real-time deadline, preventing // audible glitches caused by disk stalls, GC pauses, or CPU // scheduling delays. // // The read-ahead goroutine continuously fills the ring buffer from // the source. The speaker callback drains the ring buffer without // ever touching the source directly. If the ring buffer is // temporarily empty (read-ahead hasn't caught up), Stream returns // silence rather than blocking or signaling end-of-stream. type BufferedStreamer struct { // srcMu serializes all access to the underlying source. The // read-ahead goroutine holds it while calling source.Stream; // callers that need to Seek the source must hold it too (via // LockSource/UnlockSource) so the non-thread-safe decoder is // never read and seeked concurrently. srcMu sync.Mutex mu sync.Mutex source beep.Streamer ring [][2]float64 readPos int writPos int count int done bool err error closed chan struct{} } // NewBufferedStreamer creates a BufferedStreamer that pre-fills // bufferSize samples from source via a background goroutine. // A typical bufferSize is 2× the sample rate (~2 seconds of audio). func NewBufferedStreamer( source beep.Streamer, bufferSize int, ) *BufferedStreamer { bs := &BufferedStreamer{ source: source, ring: make([][2]float64, bufferSize), closed: make(chan struct{}), } go bs.readAhead() return bs } // readAhead continuously reads from the source into the ring buffer // until the source is drained, an error occurs, or Close is called. func (bs *BufferedStreamer) readAhead() { // Temporary buffer for reading from source outside the lock. // 512 samples per chunk keeps the critical section short. const chunkSize = 512 tmp := make([][2]float64, chunkSize) for { // Check if closed. select { case <-bs.closed: return default: } bs.mu.Lock() space := len(bs.ring) - bs.count if space == 0 { // Buffer full — release lock and wait briefly. bs.mu.Unlock() select { case <-bs.closed: return case <-time.After(1 * time.Millisecond): } continue } // Determine how many samples to request. toRead := space if toRead > chunkSize { toRead = chunkSize } bs.mu.Unlock() // Read from source WITHOUT holding bs.mu so disk I/O does // not block the speaker goroutine. srcMu is held to keep // this read from racing a concurrent source Seek. bs.srcMu.Lock() n, ok := bs.source.Stream(tmp[:toRead]) bs.srcMu.Unlock() if n > 0 { bs.mu.Lock() for i := range n { bs.ring[bs.writPos] = tmp[i] bs.writPos = (bs.writPos + 1) % len(bs.ring) } bs.count += n bs.mu.Unlock() } if !ok { bs.mu.Lock() bs.done = true if srcErr := bs.source.Err(); srcErr != nil { bs.err = srcErr } bs.mu.Unlock() return } // If source returned 0 samples but is still ok, yield // briefly to avoid busy-spinning. if n == 0 { select { case <-bs.closed: return case <-time.After(1 * time.Millisecond): } } } } // Stream copies samples from the ring buffer into the provided // slice. If the buffer is temporarily empty but the source is not // yet drained, it fills the output with silence and returns // (len(samples), true) to avoid speaker underrun. func (bs *BufferedStreamer) Stream( samples [][2]float64, ) (int, bool) { bs.mu.Lock() defer bs.mu.Unlock() if bs.count == 0 && bs.done { return 0, false } if bs.count == 0 { // Buffer temporarily empty — fill with silence. for i := range samples { samples[i] = [2]float64{} } return len(samples), true } // Copy available samples from ring buffer. n := len(samples) if n > bs.count { n = bs.count } for i := range n { samples[i] = bs.ring[bs.readPos] bs.readPos = (bs.readPos + 1) % len(bs.ring) } bs.count -= n return n, true } // Err returns any error encountered by the source streamer. func (bs *BufferedStreamer) Err() error { bs.mu.Lock() defer bs.mu.Unlock() 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 } // LockSource blocks the read-ahead goroutine from touching the // underlying source, giving the caller exclusive access so it can // safely Seek the non-thread-safe decoder. Every LockSource must // be paired with an UnlockSource. func (bs *BufferedStreamer) LockSource() { bs.srcMu.Lock() } // UnlockSource releases the exclusive source access acquired by // LockSource, allowing the read-ahead goroutine to resume. func (bs *BufferedStreamer) UnlockSource() { bs.srcMu.Unlock() } // Close signals the read-ahead goroutine to stop. It is safe to // call multiple times. func (bs *BufferedStreamer) Close() { select { case <-bs.closed: // Already closed. default: close(bs.closed) } }