package player import ( "context" "fmt" "math" "sync" "sync/atomic" "time" "github.com/gopxl/beep/v2" "github.com/gopxl/beep/v2/speaker" ) // Quality holds configurable audio output parameters. type Quality struct { SampleRate int // output sample rate in Hz (e.g. 44100, 48000) BufferMs int // speaker buffer in milliseconds ResampleQuality int // beep resample quality factor (1–4) BitDepth int // PCM bit depth for FFmpeg output: 16 or 32 (32 = lossless) } // StreamerFactory creates a beep.StreamSeekCloser for a custom URI scheme // (e.g., spotify:track:xxx). Returns the streamer, its format, the track // duration, and any error. type StreamerFactory func(uri string) (beep.StreamSeekCloser, beep.Format, time.Duration, error) // Player is the audio engine managing the playback pipeline: // // [Gapless] -> [10x Biquad EQ] -> [Volume] -> [Tap] -> [Ctrl] -> speaker // ↑ // ├─ current: [Decode A] → [Resample A] // └─ next: [Decode B] → [Resample B] (preloaded) type Player struct { mu sync.Mutex sr beep.SampleRate gapless *gaplessStreamer current *trackPipeline // active track's resources nextPipeline *trackPipeline // preloaded track's resources started bool // true after first speaker.Play() suspendMu sync.Mutex // guards suspended and speaker suspend/resume calls suspended bool // true when speaker.Suspend() has been called ctrl *beep.Ctrl volMin atomic.Uint64 // dB floor stored as Float64bits, range [-90, 0] volume atomic.Uint64 // dB stored as Float64bits, range [volMin, +6] speed atomic.Uint64 // playback speed ratio as Float64bits; 1.0 = normal eqBands [10]atomic.Uint64 // dB stored as math.Float64bits tap *tap playing atomic.Bool paused atomic.Bool mono atomic.Bool resampleQuality int bitDepth int // 16 or 32 gaplessAdvance atomic.Bool // set when gapless transition fires seekGen atomic.Int64 // generation counter for yt-dlp seeks; incremented to cancel stale seeks streamTitle atomic.Value // stores string, set by ICY reader callback customFactories map[string]StreamerFactory // URI scheme prefix -> factory (e.g. "spotify:" -> fn) bufferedURLMatch func(string) bool // optional: returns true for URLs needing navBuffer pipeline streamMetaResolver StreamMetadataResolver // optional: API-based now-playing for streams without ICY metaCancel context.CancelFunc // cancels the active metadata poller; guarded by mu } // StreamMetadataResolver matches a stream URL to a now-playing fetcher for // broadcasters that carry no inline ICY metadata (e.g. NTS, FIP) and instead // publish the current track via a separate JSON API. It returns a fetch // function, the poll interval, and ok=false when the URL is not recognized. type StreamMetadataResolver func(streamURL string) (fetch func(ctx context.Context) (string, error), interval time.Duration, ok bool) // New creates a Player and initializes the speaker with the given quality settings. func New(q Quality) (*Player, error) { if q.SampleRate <= 0 || q.BufferMs <= 0 || q.ResampleQuality <= 0 { return nil, fmt.Errorf("invalid quality settings: SampleRate=%d, BufferMs=%d, ResampleQuality=%d", q.SampleRate, q.BufferMs, q.ResampleQuality) } sr := beep.SampleRate(q.SampleRate) if err := speaker.Init(sr, sr.N(time.Duration(q.BufferMs)*time.Millisecond)); err != nil { return nil, fmt.Errorf("speaker init: %w", err) } bitDepth := q.BitDepth if bitDepth != 32 { bitDepth = 16 } p := &Player{sr: sr, resampleQuality: q.ResampleQuality, bitDepth: bitDepth} p.volMin.Store(math.Float64bits(-50)) p.speed.Store(math.Float64bits(1.0)) p.gapless = &gaplessStreamer{} // Suspend the speaker immediately; the ALSA audio callback goroutine // burns ~2% CPU even on silence. Resume is called on every Play(). _ = speaker.Suspend() p.suspended = true p.gapless.onSwap = func() { // Called from audio thread (goroutine) when gapless transition occurs. // Swap current ← nextPipeline and close the old one. The API metadata // poller is intentionally not restarted here: gapless advance is for // finite tracks, while resolver-backed streams (NTS, FIP) are infinite // live radio that is never preloaded as a gapless next track. p.mu.Lock() old := p.current p.current = p.nextPipeline p.nextPipeline = nil p.mu.Unlock() if old != nil { old.close() } p.gaplessAdvance.Store(true) } return p, nil } // Play opens and starts playing an audio file. On the first call it builds // the long-lived EQ → volume → tap → ctrl chain and starts the speaker. // Subsequent calls swap only the track source via the gapless streamer. // knownDuration is the metadata duration (use 0 if unknown); it is used as a // fallback when the decoder cannot determine the length (e.g. HTTP streams). func (p *Player) Play(path string, knownDuration time.Duration) error { tp, err := p.buildPipeline(path) if err != nil { return err } tp.setKnownDuration(knownDuration) return p.playPipeline(tp) } // PlayYTDL starts playing a yt-dlp page URL via a piped yt-dlp | ffmpeg chain. // Playback starts as soon as the first PCM samples arrive (~1-3s). Not seekable. func (p *Player) PlayYTDL(pageURL string, knownDuration time.Duration) error { // Probe duration concurrently with pipeline setup so it doesn't delay playback. probeCh := make(chan time.Duration, 1) if knownDuration == 0 { go func() { probeCh <- probeYTDLDuration(pageURL) }() } tp, err := p.buildYTDLPipeline(pageURL, 0) if err != nil { return err } if knownDuration == 0 { // The probe ran concurrently with buildYTDLPipeline. Try to // collect the result, but don't block playback for more than 2s. // A hung probeYTDLDuration (e.g. yt-dlp zombie keeping pipes // open) previously blocked here forever, leaving the UI stuck // at "Buffering...". select { case d := <-probeCh: if d > 0 { knownDuration = d } case <-time.After(2 * time.Second): // Probe still running — start playback without duration. // The seek bar won't show progress but audio plays immediately. } } tp.knownDuration = knownDuration return p.playPipeline(tp) } // playPipeline wires a ready-to-play trackPipeline into the speaker chain. // On the first call it builds the long-lived EQ → volume → tap → ctrl chain. // Subsequent calls swap only the track source via the gapless streamer. func (p *Player) playPipeline(tp *trackPipeline) error { p.resumeSpeaker() // Collect old pipelines to close after releasing locks. var oldCurrent, oldNext *trackPipeline if p.started { // Lock the speaker so the goroutine finishes any in-progress Stream() // call before we swap the source and unpause. The ctrl.Paused write // must happen under the speaker lock because the audio thread reads it // on every Stream() call. speaker.Lock() p.gapless.Replace(tp.stream) p.ctrl.Paused = false speaker.Unlock() } p.mu.Lock() oldCurrent = p.current oldNext = p.nextPipeline p.current = tp p.nextPipeline = nil firstPlay := !p.started if firstPlay { p.gapless.Replace(tp.stream) // Build the long-lived pipeline once var s beep.Streamer = p.gapless s = newSpeedStreamer(s, &p.speed) for i := range 10 { s = newBiquad(s, eqFreqs[i], 1.4, &p.eqBands[i], float64(p.sr)) } p.tap = newTap(s, 4096) s = &volumeStreamer{s: p.tap, vol: &p.volume, mono: &p.mono, cachedDB: math.NaN()} p.ctrl = &beep.Ctrl{Streamer: s} p.started = true } p.playing.Store(true) p.paused.Store(false) p.mu.Unlock() if firstPlay { speaker.Play(p.ctrl) } // Start API-based now-playing polling for streams without ICY metadata // (no-op otherwise). Done here, not in buildPipelineAt, so preloaded // pipelines that may never play don't spawn pollers. p.startStreamMetadata(tp.path) // Close old resources asynchronously to avoid blocking the caller // (UI thread) on slow Close() operations (ffmpeg wait, HTTP teardown). go closePipelines(oldCurrent, oldNext) return nil } // Preload builds a pipeline for the next track and queues it for gapless transition. // knownDuration is the metadata duration (use 0 if unknown). func (p *Player) Preload(path string, knownDuration time.Duration) error { tp, err := p.buildPipeline(path) if err != nil { return err } tp.setKnownDuration(knownDuration) return p.preloadPipeline(tp) } // PreloadYTDL builds a yt-dlp pipe pipeline and queues it for gapless transition. func (p *Player) PreloadYTDL(pageURL string, knownDuration time.Duration) error { tp, err := p.buildYTDLPipeline(pageURL, 0) if err != nil { return err } tp.knownDuration = knownDuration return p.preloadPipeline(tp) } // preloadPipeline queues a ready trackPipeline for gapless transition. func (p *Player) preloadPipeline(tp *trackPipeline) error { // Lock speaker to atomically swap the gapless next stream, ensuring no // in-flight transition reads from the old pipeline we're about to close. speaker.Lock() p.gapless.SetNext(tp.stream) speaker.Unlock() p.mu.Lock() old := p.nextPipeline p.nextPipeline = tp p.mu.Unlock() if old != nil { old.close() } return nil } // ClearPreload discards the preloaded next track (e.g., when shuffle/repeat changes). // Speaker is locked to ensure no in-flight gapless transition can reference the // pipeline we're about to close. func (p *Player) ClearPreload() { speaker.Lock() p.gapless.SetNext(nil) speaker.Unlock() p.mu.Lock() old := p.nextPipeline p.nextPipeline = nil p.mu.Unlock() if old != nil { old.close() } } // GaplessAdvanced returns true (once) when a gapless transition happened. func (p *Player) GaplessAdvanced() bool { return p.gaplessAdvance.CompareAndSwap(true, false) } // TogglePause toggles between paused and playing states. // When pausing, the speaker is suspended to save CPU; when unpausing // it is resumed so the audio callback drains the queued samples. func (p *Player) TogglePause() { speaker.Lock() if p.ctrl != nil { p.ctrl.Paused = !p.ctrl.Paused paused := p.ctrl.Paused speaker.Unlock() p.paused.Store(paused) if paused { p.suspendSpeaker() } else { p.resumeSpeaker() } } else { speaker.Unlock() } } // Stop halts playback and releases resources. The speaker is suspended so // the ALSA audio callback goroutine blocks (zero CPU) instead of streaming // silence. Resume is called automatically on the next Play(). func (p *Player) Stop() { // Lock speaker to ensure the goroutine finishes any in-progress Stream() // call, then clear the source and pause. After unlock, the speaker will // only see silence from the gapless streamer (paused ctrl). speaker.Lock() p.gapless.Clear() if p.ctrl != nil { p.ctrl.Paused = true } speaker.Unlock() // Now safe to close decoder resources — speaker can't be reading them. p.mu.Lock() oldCurrent := p.current oldNext := p.nextPipeline p.current = nil p.nextPipeline = nil p.playing.Store(false) p.paused.Store(false) p.mu.Unlock() p.stopStreamMetadata() closePipelines(oldCurrent, oldNext) p.suspendSpeaker() } // Seek moves the playback position by the given duration (positive or negative). // For seekable local files, the decoder's Seek method is used directly. // For HTTP streams with a known Content-Length and duration (seekableStream), // seek is implemented by reconnecting with a Range: bytes=N- header and // rebuilding the decoder at the computed byte offset. This is known as // seek-by-reconnect. // Returns nil immediately for non-seekable streams (e.g., Icecast radio). // The speaker lock is acquired first (outer), then p.mu briefly to snapshot // the current pipeline, ensuring consistent lock ordering with the audio thread. // Clears the preloaded next pipeline to prevent a stale gapless transition. func (p *Player) Seek(d time.Duration) error { speaker.Lock() defer speaker.Unlock() p.mu.Lock() cur := p.current p.mu.Unlock() if cur == nil { return nil } // seekableStream: HTTP stream with Content-Length — reconnect at byte offset. // Release the speaker lock before the slow HTTP reconnect so the audio // thread and UI tick handler aren't blocked during the request. if cur.seekableStream && cur.knownDuration > 0 && cur.contentLength > 0 { // Compute new absolute position. curPos := cur.format.SampleRate.D(cur.decoder.Position()) + cur.streamOffset newPos := max(curPos+d, 0) if newPos >= cur.knownDuration { newPos = cur.knownDuration - time.Second } // Map position to byte offset: offset = newPos/duration * contentLength. // Use floating-point to avoid int64 overflow on large files. ratio := float64(newPos) / float64(cur.knownDuration) byteOffset := int64(ratio * float64(cur.contentLength)) // Snapshot values and mute audio while reconnecting — prevents the // old stream from playing at the pre-seek position during the rebuild. path := cur.path knownDuration := cur.knownDuration contentLength := cur.contentLength p.gapless.Replace(nil) speaker.Unlock() // Build a new pipeline starting at the computed byte offset. // Speaker lock is NOT held — HTTP I/O can take seconds on slow networks. tp, err := p.buildPipelineAt(path, byteOffset, newPos) speaker.Lock() // re-acquire for defer if err != nil { // Restore the old stream on failure if the pipeline hasn't changed. p.mu.Lock() if p.current == cur { p.gapless.Replace(cur.stream) } p.mu.Unlock() return fmt.Errorf("seek reconnect: %w", err) } tp.knownDuration = knownDuration // seekableStream / contentLength / path are set by buildPipelineAt when // contentLength > 0, but byteOffset shifts the origin, so we keep the // original full-file contentLength and mark seekableStream explicitly. tp.seekableStream = true tp.contentLength = contentLength // Verify the current pipeline hasn't changed while we were unlocked // (e.g. track skip or another seek). If it changed, discard our work. p.mu.Lock() if p.current != cur { p.mu.Unlock() go closePipelines(tp) return nil } p.mu.Unlock() p.gapless.Replace(tp.stream) // Clear any preloaded next pipeline — its transition point is now stale. p.gapless.SetNext(nil) p.mu.Lock() old := p.current oldNext := p.nextPipeline p.current = tp p.nextPipeline = nil p.mu.Unlock() go closePipelines(old, oldNext) return nil } // yt-dlp seek-by-restart: handled outside the speaker lock via SeekYTDL. if cur.ytdlSeek { // Release speaker lock, then do the slow seek. speaker.Unlock() err := p.SeekYTDL(d) speaker.Lock() // re-acquire so defer Unlock works return err } // Local file (or ffmpeg-buffered PCM): use the decoder's native Seek. if !cur.seekable { return nil } curSample := cur.decoder.Position() curDur := cur.format.SampleRate.D(curSample) newSample := max(cur.format.SampleRate.N(curDur+d), 0) if newSample >= cur.decoder.Len() { newSample = cur.decoder.Len() - 1 } if err := cur.decoder.Seek(newSample); err != nil { return err } // Invalidate the preloaded next pipeline — the gapless transition point // has moved and the old preload may be stale. The speaker lock is already // held, so we can safely clear the gapless next stream. p.gapless.SetNext(nil) p.mu.Lock() old := p.nextPipeline p.nextPipeline = nil p.mu.Unlock() if old != nil { old.close() } return nil } // CancelSeekYTDL increments the seek generation, causing any in-flight // SeekYTDL to discard its result instead of swapping streams. func (p *Player) CancelSeekYTDL() { p.seekGen.Add(1) } // SeekYTDL seeks a yt-dlp stream by restarting the pipeline at the target // position. Must NOT be called with the speaker lock held. // If a newer seek is requested (via CancelSeekYTDL) while this one is // building, the result is discarded. func (p *Player) SeekYTDL(d time.Duration) error { gen := p.seekGen.Load() // Snapshot current state without speaker lock. p.mu.Lock() cur := p.current p.mu.Unlock() if cur == nil || !cur.ytdlSeek { return nil } // Read position, then mute the current stream so the speaker outputs // silence while the new pipeline is being built (which blocks on Peek // waiting for yt-dlp data). Without this, the old audio keeps playing // at the pre-seek position during the rebuild. speaker.Lock() curPos := cur.format.SampleRate.D(cur.decoder.Position()) + cur.streamOffset p.gapless.Replace(nil) speaker.Unlock() newPos := max(curPos+d, 0) if cur.knownDuration > 0 && newPos >= cur.knownDuration { newPos = cur.knownDuration - time.Second } startSec := int(newPos.Seconds()) // Build pipeline WITHOUT speaker lock (this is the slow part — spawns yt-dlp). tp, err := p.buildYTDLPipeline(cur.path, startSec) if err != nil { return fmt.Errorf("yt-dlp seek: %w", err) } tp.knownDuration = cur.knownDuration tp.ytdlSeek = true // Check if this seek was cancelled while we were building. if p.seekGen.Load() != gen { // A newer seek was requested — discard this result. go closePipelines(tp) return nil } // Now acquire speaker lock to swap streams. speaker.Lock() p.gapless.Replace(tp.stream) p.gapless.SetNext(nil) speaker.Unlock() p.mu.Lock() old := p.current oldNext := p.nextPipeline p.current = tp p.nextPipeline = nil p.mu.Unlock() // Clean up old pipelines async to avoid blocking on process wait. go closePipelines(old, oldNext) return nil } // IsYTDLSeek reports whether the current track uses yt-dlp seek-by-restart. func (p *Player) IsYTDLSeek() bool { p.mu.Lock() cur := p.current p.mu.Unlock() return cur != nil && cur.ytdlSeek } // IsStreamSeek reports whether the current track uses HTTP seek-by-reconnect. // When true, Seek() releases the speaker lock during the HTTP request, so // callers should dispatch seeks asynchronously to avoid blocking the UI. func (p *Player) IsStreamSeek() bool { p.mu.Lock() cur := p.current p.mu.Unlock() return cur != nil && cur.seekableStream && cur.knownDuration > 0 && cur.contentLength > 0 } // Position returns the current playback position. // For ranged HTTP streams (seek-by-reconnect), streamOffset is added to the // decoder's sample-based position so the reported time is absolute within // the track, not relative to the reconnect point. func (p *Player) Position() time.Duration { speaker.Lock() defer speaker.Unlock() p.mu.Lock() cur := p.current p.mu.Unlock() if cur == nil { return 0 } return cur.format.SampleRate.D(cur.decoder.Position()) + cur.streamOffset } // Duration returns the total duration of the current track. // For seekable local files it is derived from the decoder's sample count. // For HTTP streams where the decoder reports Len()==0, the metadata hint // stored at pipeline build time (knownDuration) is returned instead. func (p *Player) Duration() time.Duration { speaker.Lock() defer speaker.Unlock() p.mu.Lock() cur := p.current p.mu.Unlock() if cur == nil { return 0 } if n := cur.decoder.Len(); n > 0 { return cur.format.SampleRate.D(n) } return cur.knownDuration } // PositionAndDuration returns both position and duration under a single // speaker lock, avoiding two separate lock acquisitions per tick. func (p *Player) PositionAndDuration() (time.Duration, time.Duration) { speaker.Lock() defer speaker.Unlock() p.mu.Lock() cur := p.current p.mu.Unlock() if cur == nil { return 0, 0 } pos := cur.format.SampleRate.D(cur.decoder.Position()) + cur.streamOffset var dur time.Duration if n := cur.decoder.Len(); n > 0 { dur = cur.format.SampleRate.D(n) } else { dur = cur.knownDuration } return pos, dur } // SetVolumeMin sets the minimum volume floor in dB, clamped to [-90, 0]. // If the current volume is below the new floor it is immediately raised to match. func (p *Player) SetVolumeMin(db float64) { newMin := max(min(db, 0), -90) p.volMin.Store(math.Float64bits(newMin)) for { cur := p.volume.Load() curDB := math.Float64frombits(cur) if curDB >= newMin { break } if p.volume.CompareAndSwap(cur, math.Float64bits(newMin)) { break } } } // VolumeMin returns the current minimum volume floor in dB. func (p *Player) VolumeMin() float64 { return math.Float64frombits(p.volMin.Load()) } // SetVolume sets the volume in dB, clamped to [VolumeMin, +6]. func (p *Player) SetVolume(db float64) { p.volume.Store(math.Float64bits(max(min(db, 6), p.VolumeMin()))) } // Volume returns the current volume in dB. func (p *Player) Volume() float64 { return math.Float64frombits(p.volume.Load()) } // SetSpeed sets the playback speed ratio, clamped to [0.25, 2.0]. // 1.0 is normal speed, 2.0 is double speed, etc. func (p *Player) SetSpeed(ratio float64) { p.speed.Store(math.Float64bits(max(min(ratio, 2.0), 0.25))) } // Speed returns the current playback speed ratio. func (p *Player) Speed() float64 { return math.Float64frombits(p.speed.Load()) } // ToggleMono switches between stereo and mono (L+R downmix) output. func (p *Player) ToggleMono() { p.mono.Store(!p.mono.Load()) } // Mono returns true if mono output is enabled. func (p *Player) Mono() bool { return p.mono.Load() } // SetEQBand sets a single EQ band's gain in dB, clamped to [-12, +12]. func (p *Player) SetEQBand(band int, dB float64) { if band < 0 || band >= 10 { return } p.eqBands[band].Store(math.Float64bits(max(min(dB, 12), -12))) } // EQBands returns a copy of all 10 EQ band gains. func (p *Player) EQBands() [10]float64 { var bands [10]float64 for i := range 10 { bands[i] = math.Float64frombits(p.eqBands[i].Load()) } return bands } // IsPlaying returns true if a track is loaded and playing (possibly paused). func (p *Player) IsPlaying() bool { return p.playing.Load() } // IsPaused returns true if playback is paused. func (p *Player) IsPaused() bool { return p.paused.Load() } // Drained returns true if the current track ended with no preloaded next track. func (p *Player) Drained() bool { return p.gapless.Drained() } // HasPreload returns true if a next track is already queued for gapless transition. func (p *Player) HasPreload() bool { p.mu.Lock() defer p.mu.Unlock() return p.nextPipeline != nil } // StreamTitle returns the current ICY stream title (e.g., "Artist - Song"). // Returns "" when no ICY metadata has been received. func (p *Player) StreamTitle() string { v, _ := p.streamTitle.Load().(string) return v } // setStreamTitle is the ICY onMeta callback, called from the reader goroutine. func (p *Player) setStreamTitle(title string) { p.streamTitle.Store(title) } // RegisterStreamMetadataResolver installs a resolver used to pull now-playing // metadata for streams that lack inline ICY metadata. Pass nil to disable. func (p *Player) RegisterStreamMetadataResolver(r StreamMetadataResolver) { p.mu.Lock() p.streamMetaResolver = r p.mu.Unlock() } // startStreamMetadata (re)starts background now-playing polling for streamURL, // cancelling any previous poller. It is a no-op unless a registered resolver // recognizes the URL, so streams that carry ICY metadata (or local files) are // unaffected. The poller feeds titles through the same path as ICY metadata. func (p *Player) startStreamMetadata(streamURL string) { p.stopStreamMetadata() p.mu.Lock() resolver := p.streamMetaResolver p.mu.Unlock() if resolver == nil || streamURL == "" || !isURL(streamURL) { return } fetch, interval, ok := resolver(streamURL) if !ok || fetch == nil { return } if interval <= 0 { interval = 15 * time.Second } ctx, cancel := context.WithCancel(context.Background()) p.mu.Lock() p.metaCancel = cancel p.mu.Unlock() go p.pollStreamMetadata(ctx, fetch, interval) } // stopStreamMetadata cancels the active metadata poller, if any. func (p *Player) stopStreamMetadata() { p.mu.Lock() cancel := p.metaCancel p.metaCancel = nil p.mu.Unlock() if cancel != nil { cancel() } } // pollStreamMetadata fetches the current title immediately and then on each // interval tick until ctx is cancelled, publishing non-empty titles via // setStreamTitle. A title fetched after cancellation is discarded so a stale // poller cannot clobber the next stream's metadata. func (p *Player) pollStreamMetadata(ctx context.Context, fetch func(context.Context) (string, error), interval time.Duration) { ticker := time.NewTicker(interval) defer ticker.Stop() for { title, err := fetch(ctx) if ctx.Err() != nil { return } if err == nil && title != "" { p.setStreamTitle(title) } select { case <-ctx.Done(): return case <-ticker.C: } } } // Seekable reports whether the current track supports seeking. // Returns true for local files (decoder-native seek) and for HTTP streams // with a known Content-Length and duration (seek-by-reconnect). func (p *Player) Seekable() bool { p.mu.Lock() cur := p.current p.mu.Unlock() if cur == nil { return false } return cur.seekable || (cur.seekableStream && cur.knownDuration > 0) || (cur.ytdlSeek && cur.knownDuration > 0) } // StreamErr returns the current streamer error, if any (e.g., connection drops). func (p *Player) StreamErr() error { p.mu.Lock() cur := p.current p.mu.Unlock() if cur == nil { return nil } return cur.decoder.Err() } // SamplesInto copies the latest audio samples into dst, avoiding allocation. // Returns the number of samples written. func (p *Player) SamplesInto(dst []float64) int { p.mu.Lock() tap := p.tap p.mu.Unlock() if tap == nil { return 0 } return tap.SamplesInto(dst) } // SampleRate returns the output sample rate in Hz. func (p *Player) SampleRate() int { return int(p.sr) } // StreamBytes returns the bytes downloaded and total content length for the // current HTTP stream. Returns (0, 0) for local files or when no counter exists. func (p *Player) StreamBytes() (downloaded, total int64) { p.mu.Lock() cur := p.current p.mu.Unlock() if cur == nil { return 0, 0 } if cur.bytesRead != nil { downloaded = cur.bytesRead.Load() } total = cur.contentLength return downloaded, total } // RegisterStreamerFactory registers a factory for a custom URI scheme prefix // (e.g., "spotify:"). When buildPipeline encounters a path starting with this // prefix, it calls the factory to create the decoder instead of the normal // file/HTTP pipeline. func (p *Player) RegisterStreamerFactory(scheme string, f StreamerFactory) { p.mu.Lock() defer p.mu.Unlock() if p.customFactories == nil { p.customFactories = make(map[string]StreamerFactory) } p.customFactories[scheme] = f } // RegisterBufferedURLMatcher registers a function that identifies HTTP URLs // requiring the buffered download + ffmpeg pipeline (e.g. Subsonic stream // endpoints). This replaces hardcoded URL pattern checks. func (p *Player) RegisterBufferedURLMatcher(match func(string) bool) { p.mu.Lock() defer p.mu.Unlock() p.bufferedURLMatch = match } // suspendSpeaker suspends the ALSA audio callback goroutine so it blocks // on a condition variable instead of busy-looping. Safe to call multiple // times; subsequent calls are no-ops. func (p *Player) suspendSpeaker() { p.suspendMu.Lock() defer p.suspendMu.Unlock() if p.suspended { return } if err := speaker.Suspend(); err != nil { // Non-fatal: the ALSA driver may return an error if the context // has already hit a terminal error. Continue without tracking // the suspended state so we don't try to resume a dead context. return } p.suspended = true } // resumeSpeaker resumes the ALSA audio callback goroutine. Safe to call // multiple times; subsequent calls are no-ops. func (p *Player) resumeSpeaker() { p.suspendMu.Lock() defer p.suspendMu.Unlock() if !p.suspended { return } if err := speaker.Resume(); err != nil { return } p.suspended = false } // Close fully stops the speaker and cleans up all resources. func (p *Player) Close() { p.Stop() speaker.Clear() }