Skip to main content

koan_core/audio/
buffer.rs

1use std::fs::File;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
5use std::thread;
6
7use symphonia::core::codecs::audio::well_known::{
8    CODEC_ID_AAC, CODEC_ID_ALAC, CODEC_ID_FLAC, CODEC_ID_MP3, CODEC_ID_OPUS, CODEC_ID_PCM_F32LE,
9    CODEC_ID_PCM_S16LE, CODEC_ID_PCM_S24LE, CODEC_ID_PCM_S32LE, CODEC_ID_VORBIS,
10};
11use symphonia::core::codecs::audio::{AudioCodecId, AudioCodecParameters, AudioDecoderOptions};
12use symphonia::core::formats::probe::Hint;
13use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo, Track, TrackType};
14use symphonia::core::io::MediaSourceStream;
15use symphonia::core::meta::MetadataOptions;
16use symphonia::core::units::{Duration, Time, TimeBase, Timestamp};
17use symphonia_common::apple::audio::alac::MagicCookie;
18use thiserror::Error;
19
20use crate::audio::dsp::{Chain, Setup};
21use crate::audio::opus::OpusBridge;
22use crate::audio::viz::VizBuffer;
23use crate::config::ReplayGainMode;
24use crate::player::state::{QueueItemId, RendererClock};
25
26#[derive(Debug, Error)]
27pub enum DecodeError {
28    #[error("failed to open file: {0}")]
29    Io(#[from] std::io::Error),
30    #[error("no supported audio track found")]
31    NoTrack,
32    #[error("unsupported codec")]
33    UnsupportedCodec,
34    #[error("decode error: {0}")]
35    Decode(String),
36}
37
38/// Info about the decoded audio stream, extracted before decoding starts.
39#[derive(Debug, Clone)]
40pub struct StreamInfo {
41    pub codec: String,
42    pub sample_rate: u32,
43    pub channels: u16,
44    pub bit_depth: Option<u16>,
45    /// Bitrate in kbps. Meaningful for lossy codecs, None for lossless.
46    pub bitrate_kbps: Option<u32>,
47    pub duration_ms: u64,
48}
49
50/// Handle to a running decode thread. Drop to stop it.
51pub struct DecodeHandle {
52    stop: Arc<AtomicBool>,
53    thread: Option<thread::JoinHandle<()>>,
54}
55
56impl DecodeHandle {
57    /// Signal the decode thread to stop without waiting for it to exit.
58    /// Unparks it too: a full ring has it parked for as long as the output
59    /// takes to drain half of it.
60    pub fn signal_stop(&self) {
61        self.stop.store(true, Ordering::Relaxed);
62        if let Some(handle) = &self.thread {
63            handle.thread().unpark();
64        }
65    }
66
67    /// The decode thread, to wake when its ring has room. An output that
68    /// drains the ring faster than it plays, as an encoder for a renderer
69    /// does, would otherwise wait out the decoder's park on a full ring.
70    pub fn thread(&self) -> Option<thread::Thread> {
71        self.thread.as_ref().map(|t| t.thread().clone())
72    }
73
74    /// Create a DecodeHandle with no real thread (for tests only).
75    #[cfg(test)]
76    pub fn new_for_test(stop: Arc<AtomicBool>) -> Self {
77        Self { stop, thread: None }
78    }
79
80    /// Signal the decode thread to stop and wait for it.
81    pub fn stop(&mut self) {
82        self.signal_stop();
83        if let Some(handle) = self.thread.take()
84            && let Err(payload) = handle.join()
85        {
86            let msg = payload
87                .downcast_ref::<String>()
88                .map(|s| s.as_str())
89                .or_else(|| payload.downcast_ref::<&str>().copied())
90                .unwrap_or("unknown");
91            log::error!("decode thread panicked: {}", msg);
92        }
93    }
94}
95
96impl Drop for DecodeHandle {
97    fn drop(&mut self) {
98        self.stop();
99    }
100}
101
102// --- Playback timeline: the source of truth for "what's playing" ---
103
104/// A track boundary in the playback stream. At `sample_offset` cumulative
105/// samples written to the ring buffer, this track starts.
106#[derive(Debug, Clone)]
107pub struct TrackBoundary {
108    pub id: QueueItemId,
109    pub path: PathBuf,
110    pub info: StreamInfo,
111    /// Cumulative interleaved samples written to the ring buffer when this
112    /// track's first sample was pushed. For the first track this is 0
113    /// (or seek_samples if seeking).
114    pub sample_offset: u64,
115    /// Samples of this track's audio written to ring buffer so far.
116    /// Updated as decode progresses. At EOF, equals total decoded samples.
117    pub samples_written: u64,
118    /// The seek offset in samples for this track (non-zero only if user seeked).
119    pub seek_samples: u64,
120    /// The rate the ring buffer's samples play at — the source's, unless DSP
121    /// resampled it to reach an impulse response.
122    pub output_rate: u32,
123}
124
125impl TrackBoundary {
126    /// Where in this track the playhead is when `played` samples have played.
127    fn position_ms(&self, played: u64) -> Option<u64> {
128        let ch = self.info.channels as u64;
129        let rate = self.info.sample_rate as u64;
130        let out = self.output_rate as u64;
131        if ch == 0 || rate == 0 || out == 0 {
132            return None;
133        }
134        // Add seek offset since that's where playback started within the track.
135        let track_samples = played.saturating_sub(self.sample_offset);
136        Some((track_samples / ch) * 1000 / out + (self.seek_samples / ch) * 1000 / rate)
137    }
138}
139
140/// Where the playhead is. A play is its boundary, not its item: an item
141/// repeated gaplessly has a boundary per pass, and each is a play of its own.
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub struct Playhead {
144    /// The index of the boundary the playhead is past, in this session.
145    pub boundary: usize,
146    pub id: QueueItemId,
147    pub position_ms: u64,
148}
149
150/// Shared timeline that the decode thread writes and the UI reads.
151/// The decode thread appends boundaries; the UI reads them + samples_played
152/// to derive current track and position.
153pub struct PlaybackTimeline {
154    boundaries: parking_lot::RwLock<Vec<TrackBoundary>>,
155    /// Total interleaved samples written to the ring buffer across all tracks.
156    samples_written: AtomicU64,
157    /// Total interleaved samples consumed (played) by the audio engine.
158    /// Written by the audio render callback, read by UI.
159    pub samples_played: Arc<AtomicU64>,
160    /// Told when the decoder queues another track, which is when the moment
161    /// the playhead reaches it becomes known.
162    queued: parking_lot::Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
163    /// Where a renderer playing this session's stream is, in milliseconds
164    /// of the stream. Set, it is the playhead in place of `samples_played`:
165    /// what has been encoded runs seconds ahead of what is heard.
166    clock: parking_lot::Mutex<Option<RendererClock>>,
167}
168
169impl std::fmt::Debug for PlaybackTimeline {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        f.debug_struct("PlaybackTimeline")
172            .field("samples_played", &self.samples_played)
173            .finish_non_exhaustive()
174    }
175}
176
177impl PlaybackTimeline {
178    pub fn new() -> Arc<Self> {
179        Arc::new(Self {
180            boundaries: parking_lot::RwLock::new(Vec::new()),
181            samples_written: AtomicU64::new(0),
182            samples_played: Arc::new(AtomicU64::new(0)),
183            queued: parking_lot::Mutex::new(None),
184            clock: parking_lot::Mutex::new(None),
185        })
186    }
187
188    /// Samples played: the engine's count, or the renderer's clock.
189    fn played(&self, bounds: &[TrackBoundary]) -> u64 {
190        let Some(clock) = *self.clock.lock() else {
191            return self.samples_played.load(Ordering::Acquire);
192        };
193        let Some(first) = bounds.first() else {
194            return 0;
195        };
196        let per_second = first.output_rate as u64 * first.info.channels as u64;
197        let at = clock.now_ms() * per_second / 1000;
198        // Rounded down to a whole frame, and never past what was written.
199        (at - at % (first.info.channels as u64).max(1))
200            .min(self.samples_written.load(Ordering::Acquire))
201    }
202
203    /// The renderer's clock, in milliseconds of the stream.
204    pub fn clock(&self) -> Option<RendererClock> {
205        *self.clock.lock()
206    }
207
208    pub fn set_clock(&self, clock: Option<RendererClock>) {
209        *self.clock.lock() = clock;
210    }
211
212    /// The track at `samples` into the session.
213    pub fn track_at(&self, samples: u64) -> Option<QueueItemId> {
214        let bounds = self.boundaries.read();
215        let idx = bounds.partition_point(|b| b.sample_offset <= samples);
216        Some(bounds.get(idx.checked_sub(1)?)?.id)
217    }
218
219    /// How long until the playhead reaches the end of what has been written.
220    pub fn until_end(&self) -> Option<std::time::Duration> {
221        let bounds = self.boundaries.read();
222        let played = self.played(&bounds);
223        let last = bounds.last()?;
224        let per_second = last.output_rate as u64 * last.info.channels as u64;
225        if per_second == 0 {
226            return None;
227        }
228        let left = self
229            .samples_written
230            .load(Ordering::Acquire)
231            .saturating_sub(played);
232        Some(std::time::Duration::from_micros(
233            left.saturating_mul(1_000_000) / per_second,
234        ))
235    }
236
237    /// Call `f` whenever the decoder queues another track.
238    pub fn on_queued(&self, f: impl Fn() + Send + Sync + 'static) {
239        *self.queued.lock() = Some(Box::new(f));
240    }
241
242    /// The play under the playhead and how far into it, without the clones
243    /// `current_playback` makes.
244    pub fn playhead(&self) -> Option<Playhead> {
245        let bounds = self.boundaries.read();
246        let played = self.played(&bounds);
247        let boundary = bounds
248            .partition_point(|b| b.sample_offset <= played)
249            .checked_sub(1)?;
250        let current = bounds.get(boundary)?;
251        Some(Playhead {
252            boundary,
253            id: current.id,
254            position_ms: current.position_ms(played)?,
255        })
256    }
257
258    /// How many boundaries the session has: the index the next one takes.
259    pub fn boundary_count(&self) -> usize {
260        self.boundaries.read().len()
261    }
262
263    /// How far into the play at `boundary` playback has got, stopping at its
264    /// end once the playhead has moved on to the next.
265    pub fn position_in(&self, boundary: usize) -> Option<u64> {
266        let bounds = self.boundaries.read();
267        let played = self.played(&bounds);
268        let at = bounds
269            .get(boundary + 1)
270            .map_or(played, |next| played.min(next.sample_offset));
271        bounds.get(boundary)?.position_ms(at)
272    }
273
274    /// How long until the playhead reaches the track queued after the one
275    /// playing, at the rate this one plays. None with nothing queued.
276    pub fn until_next_track(&self) -> Option<std::time::Duration> {
277        let bounds = self.boundaries.read();
278        let played = self.played(&bounds);
279        let idx = bounds.partition_point(|b| b.sample_offset <= played);
280        let next = bounds.get(idx)?;
281        let current = bounds.get(idx.checked_sub(1)?)?;
282        let per_second = current.output_rate as u64 * current.info.channels as u64;
283        if per_second == 0 {
284            return None;
285        }
286        let left = next.sample_offset - played;
287        Some(std::time::Duration::from_micros(
288            left.saturating_mul(1_000_000) / per_second,
289        ))
290    }
291
292    /// The tracks the decoder has queued after the one under the playhead, in
293    /// order. Committed: the ring cannot be truncated, so only a new session
294    /// takes them back.
295    pub fn queued_after_playhead(&self) -> Vec<QueueItemId> {
296        let bounds = self.boundaries.read();
297        let played = self.played(&bounds);
298        let idx = bounds.partition_point(|b| b.sample_offset <= played);
299        bounds[idx..].iter().map(|b| b.id).collect()
300    }
301
302    /// The decode thread's write access.
303    pub fn writer(&self) -> TimelineWriter<'_> {
304        TimelineWriter { timeline: self }
305    }
306
307    /// Reset for a new playback session.
308    pub fn reset(&self) {
309        self.boundaries.write().clear();
310        self.samples_written.store(0, Ordering::Relaxed);
311        self.samples_played.store(0, Ordering::Relaxed);
312        *self.clock.lock() = None;
313    }
314
315    /// Get a clone of the samples_played Arc for the audio engine.
316    pub fn samples_played_counter(&self) -> Arc<AtomicU64> {
317        self.samples_played.clone()
318    }
319
320    /// Derive current track info and position from the playback head.
321    /// Returns (id, path, stream_info, position_ms).
322    ///
323    /// Acquires the boundaries read lock BEFORE reading `samples_played` so
324    /// channels/sample_rate/boundaries are all from a consistent snapshot.
325    /// Without this ordering, a track transition could update the atomics
326    /// after we read `samples_played` but before we read the boundary list.
327    pub fn current_playback(&self) -> Option<(QueueItemId, PathBuf, StreamInfo, u64)> {
328        // Lock first — ensures we see boundaries consistent with the atomic read.
329        let bounds = self.boundaries.read();
330
331        if bounds.is_empty() {
332            return None;
333        }
334
335        // Read samples_played while holding the lock. This guarantees we
336        // never observe a stale boundary list with a newer samples_played
337        // (or vice versa).
338        let played = self.played(&bounds);
339
340        // Find which track the playback head is in via binary search.
341        // partition_point returns first index where offset > played;
342        // the track we want is one before that.
343        let idx = bounds.partition_point(|b| b.sample_offset <= played);
344        let current = if idx > 0 {
345            &bounds[idx - 1]
346        } else {
347            return None;
348        };
349
350        let position_ms = current.position_ms(played)?;
351
352        Some((
353            current.id,
354            current.path.clone(),
355            current.info.clone(),
356            position_ms,
357        ))
358    }
359}
360
361/// One decode session's write access to the timeline.
362///
363/// Sessions never overlap: the player joins the outgoing decode thread before
364/// it resets the timeline for the next, so nothing here has to tell an old
365/// session's writes from a new one's.
366pub struct TimelineWriter<'a> {
367    timeline: &'a PlaybackTimeline,
368}
369
370impl TimelineWriter<'_> {
371    /// Cumulative samples written to the ring buffer this session.
372    fn samples_written(&self) -> u64 {
373        self.timeline.samples_written.load(Ordering::Relaxed)
374    }
375
376    /// Called by decode thread when starting a new track.
377    fn push_boundary(&self, boundary: TrackBoundary) {
378        self.timeline.boundaries.write().push(boundary);
379        if let Some(queued) = self.timeline.queued.lock().as_ref() {
380            queued();
381        }
382    }
383
384    /// Called by decode thread after pushing samples.
385    fn add_written(&self, count: u64) {
386        let mut bounds = self.timeline.boundaries.write();
387        self.timeline
388            .samples_written
389            .fetch_add(count, Ordering::Relaxed);
390        // Also update the last boundary's samples_written.
391        if let Some(last) = bounds.last_mut() {
392            last.samples_written += count;
393        }
394    }
395}
396
397// ---------------------------------------------------------------------------
398// Source abstraction
399// ---------------------------------------------------------------------------
400
401/// A source entry for the generic decode queue.
402///
403/// Each entry provides an ID, a display path (for logging/timeline),
404/// a format hint, and a factory that constructs a fresh `MediaSourceStream`.
405pub struct SourceEntry {
406    pub id: QueueItemId,
407    /// Path used for logging and `TrackBoundary`. Need not be a real FS path.
408    pub path: PathBuf,
409    /// Format hint for Symphonia (e.g. file extension).
410    pub hint: Hint,
411    /// Factory that creates the `MediaSourceStream`. Called exactly once per track.
412    pub make_mss: Box<dyn FnOnce() -> std::io::Result<MediaSourceStream<'static>> + Send>,
413}
414
415impl SourceEntry {
416    /// Convenience: build a `SourceEntry` from a local file path.
417    pub fn from_file(id: QueueItemId, path: PathBuf) -> Self {
418        let ext = path
419            .extension()
420            .and_then(|e| e.to_str())
421            .unwrap_or("")
422            .to_string();
423        let path_clone = path.clone();
424        let mut hint = Hint::new();
425        if !ext.is_empty() {
426            hint.with_extension(&ext);
427        }
428        Self {
429            id,
430            path,
431            hint,
432            make_mss: Box::new(move || {
433                let file = File::open(&path_clone)?;
434                Ok(MediaSourceStream::new(Box::new(file), Default::default()))
435            }),
436        }
437    }
438}
439
440// ---------------------------------------------------------------------------
441// Probe API
442// ---------------------------------------------------------------------------
443
444/// Probe a `MediaSourceStream` (with hint) and return stream info without decoding.
445pub fn probe_source(mss: MediaSourceStream<'_>, hint: &Hint) -> Result<StreamInfo, DecodeError> {
446    probe_mss(mss, hint)
447}
448
449/// Probe a file and return stream info without decoding.
450pub fn probe_file(path: &Path) -> Result<StreamInfo, DecodeError> {
451    let file_size = std::fs::metadata(path).ok().map(|m| m.len());
452    let file = File::open(path)?;
453    let mss = MediaSourceStream::new(Box::new(file), Default::default());
454    let mut hint = Hint::new();
455    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
456        hint.with_extension(ext);
457    }
458    let mut info = probe_mss(mss, &hint)?;
459    // For Opus (and other lossy codecs where symphonia couldn't give us a
460    // bitrate), estimate from file size / duration when both are available.
461    if info.bitrate_kbps.is_none()
462        && info.bit_depth.is_none()
463        && let Some(size) = file_size
464        && info.duration_ms > 0
465    {
466        info.bitrate_kbps = Some((size * 8 / info.duration_ms) as u32);
467    }
468    Ok(info)
469}
470
471/// Probe a `MediaSourceStream` with a hint.
472fn probe_mss(mss: MediaSourceStream<'_>, hint: &Hint) -> Result<StreamInfo, DecodeError> {
473    let reader = symphonia::default::get_probe()
474        .probe(
475            hint,
476            mss,
477            FormatOptions::default(),
478            MetadataOptions::default(),
479        )
480        .map_err(|e| match e {
481            // Kept whole rather than flattened to a string: a probe against a
482            // partial file fails by running out of bytes, and the caller has to
483            // tell that apart from a file it cannot make sense of.
484            symphonia::core::errors::Error::IoError(io) => DecodeError::Io(io),
485            other => DecodeError::Decode(other.to_string()),
486        })?;
487
488    let track = reader
489        .default_track(TrackType::Audio)
490        .ok_or(DecodeError::NoTrack)?;
491    let codec_params = track
492        .codec_params
493        .as_ref()
494        .and_then(|p| p.audio())
495        .ok_or(DecodeError::NoTrack)?;
496    let is_opus = codec_params.codec == CODEC_ID_OPUS;
497    // Opus always decodes to 48 kHz regardless of the input sample rate.
498    let sample_rate = if is_opus {
499        48000
500    } else {
501        codec_params.sample_rate.unwrap_or(44100)
502    };
503    let channels = codec_params
504        .channels
505        .as_ref()
506        .map(|c| c.count() as u16)
507        .unwrap_or(2);
508    let bit_depth = source_bit_depth(codec_params);
509    let duration_ms = track_duration_ms(&*reader, track, sample_rate);
510    let codec = codec_name(codec_params.codec);
511
512    // Symphonia doesn't expose bitrate directly. For lossy codecs we can
513    // estimate from bits_per_coded_sample when the demuxer provides it.
514    // Opus estimation from file size is handled in probe_file() where we
515    // have the path; here we only have a MediaSourceStream.
516    let bitrate_kbps = estimate_bitrate_from_codec_params(codec_params);
517
518    Ok(StreamInfo {
519        codec,
520        sample_rate,
521        channels,
522        bit_depth,
523        bitrate_kbps,
524        duration_ms,
525    })
526}
527
528// ---------------------------------------------------------------------------
529// Generic decode API (SourceEntry-based)
530// ---------------------------------------------------------------------------
531
532/// What is done to the samples between the decoder and the ring buffer.
533#[derive(Clone)]
534pub struct Processing {
535    pub rg_mode: ReplayGainMode,
536    pub pre_amp_db: f64,
537    /// The output device's DSP profile. `None` leaves the samples untouched.
538    pub dsp: Option<Arc<Setup>>,
539}
540
541impl Default for Processing {
542    fn default() -> Self {
543        Self {
544            rg_mode: ReplayGainMode::Off,
545            pre_amp_db: 0.0,
546            dsp: None,
547        }
548    }
549}
550
551impl Processing {
552    /// The rate a source at `source` plays at.
553    pub fn output_rate(&self, source: u32) -> u32 {
554        self.dsp.as_ref().map_or(source, |d| d.output_rate(source))
555    }
556}
557
558/// Start decoding from a `SourceEntry` into the ring buffer.
559///
560/// `first`      — the first track's source entry.
561/// `seek_ms`    — if > 0, seek to this position before decoding the first track.
562/// `next_track` — closure returning the next `SourceEntry` for gapless playback.
563///                Called on EOF. Returns None when the playlist is exhausted.
564/// `on_finished` — called on the decode thread at a natural end, with the stop
565///                flag. The player joins this thread once it sets that flag, so
566///                the closure must return promptly after it is set.
567#[allow(clippy::too_many_arguments)]
568pub fn start_decode<N, F>(
569    first: SourceEntry,
570    producer: rtrb::Producer<f32>,
571    seek_ms: u64,
572    next_track: N,
573    timeline: Arc<PlaybackTimeline>,
574    viz_buffer: Option<Arc<VizBuffer>>,
575    processing: Processing,
576    on_finished: F,
577) -> Result<DecodeHandle, DecodeError>
578where
579    N: Fn() -> Option<SourceEntry> + Send + 'static,
580    F: FnOnce(&AtomicBool) + Send + 'static,
581{
582    let stop = Arc::new(AtomicBool::new(false));
583    let stop_clone = stop.clone();
584    let thread = thread::Builder::new()
585        .name("koan-decode".into())
586        .spawn(move || {
587            decode_queue_loop(
588                first,
589                producer,
590                &stop_clone,
591                seek_ms,
592                &next_track,
593                &timeline.writer(),
594                viz_buffer.as_deref(),
595                &processing,
596            );
597            // Notify the player that the decode loop finished (playlist
598            // exhausted or error). Only fire if we weren't explicitly stopped
599            // (i.e. this is a natural end, not a seek/skip teardown).
600            if !stop_clone.load(Ordering::Relaxed) {
601                on_finished(&stop_clone);
602            }
603        })
604        .map_err(DecodeError::Io)?;
605
606    Ok(DecodeHandle {
607        stop,
608        thread: Some(thread),
609    })
610}
611
612// ---------------------------------------------------------------------------
613// Internal decode loop
614// ---------------------------------------------------------------------------
615
616/// Sources that may fail to open or decode in a row before the session is
617/// abandoned. One bad file must not end the queue, but a queue of nothing but
618/// bad files still has to terminate.
619const MAX_CONSECUTIVE_FAILURES: u32 = 32;
620
621/// Gapless decode loop: decode first entry, then call next_track on EOF.
622///
623/// Every track in a session shares one ring buffer, and therefore the audio
624/// engine configured for it. A track whose PCM format differs from the first
625/// ends the session rather than being written at the wrong format; the player
626/// restarts it on a correctly configured engine.
627///
628/// An unreadable source is skipped, not fatal — the decode head runs up to a
629/// full ring buffer ahead of the DAC, so tearing down here would truncate the
630/// track still being heard as well as dropping the rest of the queue.
631#[allow(clippy::too_many_arguments)]
632fn decode_queue_loop<N>(
633    first: SourceEntry,
634    mut producer: rtrb::Producer<f32>,
635    stop: &AtomicBool,
636    initial_seek_ms: u64,
637    next_track: &N,
638    timeline: &TimelineWriter<'_>,
639    viz_buffer: Option<&VizBuffer>,
640    processing: &Processing,
641) where
642    N: Fn() -> Option<SourceEntry>,
643{
644    // The delay line is indexed against the engine's played counter, which the
645    // player has just reset for this session.
646    if let Some(viz) = viz_buffer {
647        viz.reset();
648    }
649
650    let mut pending = Some(first);
651    let mut seek_ms = initial_seek_ms;
652    let mut format: Option<PcmFormat> = None;
653    let mut failures: u32 = 0;
654    let mut chain: Option<Chain> = None;
655
656    while let Some(entry) = pending.take() {
657        if stop.load(Ordering::Relaxed) {
658            break;
659        }
660
661        let SourceEntry {
662            id,
663            path,
664            hint,
665            make_mss,
666        } = entry;
667
668        let outcome = make_mss().map_err(DecodeError::Io).and_then(|mss| {
669            decode_single(
670                id,
671                &path,
672                &hint,
673                mss,
674                &mut producer,
675                stop,
676                seek_ms,
677                timeline,
678                viz_buffer,
679                processing,
680                &mut chain,
681                format,
682            )
683        });
684
685        match outcome {
686            Ok(Decoded::Complete(decoded_format)) => {
687                format = Some(decoded_format);
688                failures = 0;
689            }
690            Ok(Decoded::FormatMismatch) => break,
691            Err(e) => {
692                if stop.load(Ordering::Relaxed) {
693                    break;
694                }
695                failures += 1;
696                log::error!("skipping {}: {}", path.display(), e);
697                if failures >= MAX_CONSECUTIVE_FAILURES {
698                    log::error!(
699                        "{} sources failed in a row, decode thread giving up",
700                        failures
701                    );
702                    break;
703                }
704            }
705        }
706        // A stop ends the session wherever it lands; looking ahead would peek
707        // the playlist and log a transition that never happens.
708        if stop.load(Ordering::Relaxed) {
709            break;
710        }
711
712        seek_ms = 0;
713        pending = (next_track)();
714        match pending {
715            Some(ref next) => log::info!("gapless transition → {}", next.path.display()),
716            None => log::info!("playlist exhausted, decode thread finishing"),
717        }
718    }
719
720    // What the chain still holds is the end of the last track.
721    if let (Some(chain), Some(format)) = (chain.as_mut(), format)
722        && !stop.load(Ordering::Relaxed)
723    {
724        write_ring(&mut producer, chain.flush(), stop, viz_buffer, format);
725    }
726
727    wait_for_drain(&producer, stop, format);
728}
729
730/// Write `samples` into the ring, blocking while it is full. False once the
731/// session has been stopped.
732///
733/// The viz buffer is fed inside the loop so it receives samples at the rate
734/// the output consumes them, not in packet-sized bursts. Without this, FLAC
735/// packets (~93ms each at 44.1kHz) would update it only ~11 times a second,
736/// making waveform modes visibly choppy.
737fn write_ring(
738    producer: &mut rtrb::Producer<f32>,
739    samples: &[f32],
740    stop: &AtomicBool,
741    viz_buffer: Option<&VizBuffer>,
742    (sample_rate, channels): PcmFormat,
743) -> bool {
744    let mut offset = 0;
745    while offset < samples.len() {
746        if stop.load(Ordering::Relaxed) {
747            return false;
748        }
749
750        let slots = producer.slots();
751        if slots == 0 {
752            // A full ring is the steady state, so this is the wait playback
753            // spends nearly all its time in: until the output has played
754            // half of it, which leaves the other half — a second or more at
755            // any rate koan plays — to refill against. The viz delay line
756            // is read at the playhead, so a refill arriving in one burst
757            // reads the same as one trickling in. A stop unparks it.
758            let half = producer.buffer().capacity() / 2;
759            thread::park_timeout(time_to_play(half, Some((sample_rate, channels))));
760            continue;
761        }
762
763        let chunk_size = slots.min(samples.len() - offset);
764        if let Ok(mut chunk) = producer.write_chunk_uninit(chunk_size) {
765            let to_write = &samples[offset..offset + chunk_size];
766            let (first, second) = chunk.as_mut_slices();
767            let first_len = first.len().min(to_write.len());
768            for (slot, &val) in first.iter_mut().zip(&to_write[..first_len]) {
769                slot.write(val);
770            }
771            if first_len < to_write.len() {
772                for (slot, &val) in second.iter_mut().zip(&to_write[first_len..]) {
773                    slot.write(val);
774                }
775            }
776            // SAFETY: All slots in the chunk have been initialized by the
777            // two loops above — first.len() + second.len() == chunk_size,
778            // and every slot is written via MaybeUninit::write().
779            unsafe { chunk.commit_all() };
780
781            if let Some(viz) = viz_buffer {
782                viz.push_samples(to_write, channels, sample_rate);
783            }
784
785            offset += chunk_size;
786        }
787    }
788    true
789}
790
791/// How long the output takes to play `samples` interleaved samples. Without a
792/// format to go by, a step short enough to poll with.
793fn time_to_play(samples: usize, format: Option<PcmFormat>) -> std::time::Duration {
794    match format {
795        Some((rate, channels)) if rate > 0 && channels > 0 => std::time::Duration::from_micros(
796            samples as u64 * 1_000_000 / (rate as u64 * channels as u64),
797        ),
798        _ => std::time::Duration::from_millis(2),
799    }
800}
801
802/// Block until the audio engine has consumed everything in the ring buffer.
803///
804/// A session ends only once its audio has been heard, so the player can tear
805/// the engine down without clipping the tail of the last track decoded.
806/// Returns early if playback is torn down underneath us.
807fn wait_for_drain(producer: &rtrb::Producer<f32>, stop: &AtomicBool, format: Option<PcmFormat>) {
808    let capacity = producer.buffer().capacity();
809    while !stop.load(Ordering::Relaxed) && !producer.is_abandoned() {
810        let left = capacity.saturating_sub(producer.slots());
811        if left == 0 {
812            return;
813        }
814        thread::park_timeout(time_to_play(left, format));
815    }
816}
817
818// ---------------------------------------------------------------------------
819// Core decode single track
820// ---------------------------------------------------------------------------
821
822/// The PCM format of a decoded stream: sample rate in Hz and channel count.
823/// The audio engine is configured from this, so the ring buffer may only ever
824/// hold samples of one such format at a time.
825type PcmFormat = (u32, u16);
826
827/// Outcome of decoding one source.
828enum Decoded {
829    /// Decoded to EOF, in the given format.
830    Complete(PcmFormat),
831    /// The source's format differs from the stream already in the ring buffer.
832    /// Nothing further was written — the engine must be reconfigured first.
833    FormatMismatch,
834}
835
836/// Decode a single source into the producer. Returns on clean EOF.
837///
838/// `expected` — the format already in the ring buffer, if any. A source that
839/// does not match it is rejected without writing samples or pushing a boundary.
840#[allow(clippy::too_many_arguments)]
841fn decode_single(
842    queue_item_id: QueueItemId,
843    path: &Path,
844    hint: &Hint,
845    mss: MediaSourceStream<'_>,
846    producer: &mut rtrb::Producer<f32>,
847    stop: &AtomicBool,
848    seek_ms: u64,
849    timeline: &TimelineWriter<'_>,
850    viz_buffer: Option<&VizBuffer>,
851    processing: &Processing,
852    chain: &mut Option<Chain>,
853    expected: Option<PcmFormat>,
854) -> Result<Decoded, DecodeError> {
855    let mut reader = symphonia::default::get_probe()
856        .probe(
857            hint,
858            mss,
859            FormatOptions::default(),
860            MetadataOptions::default(),
861        )
862        .map_err(|e| DecodeError::Decode(e.to_string()))?;
863
864    let track = reader
865        .default_track(TrackType::Audio)
866        .ok_or(DecodeError::NoTrack)?;
867    let track_id = track.id;
868    let time_base = track.time_base;
869    let codec_params = track
870        .codec_params
871        .as_ref()
872        .and_then(|p| p.audio())
873        .ok_or(DecodeError::NoTrack)?;
874    let is_opus_codec = codec_params.codec == CODEC_ID_OPUS;
875
876    // Opus always decodes to 48 kHz regardless of the internal rate.
877    let sample_rate = if is_opus_codec {
878        48000
879    } else {
880        codec_params.sample_rate.unwrap_or(44100)
881    };
882    let channels = codec_params
883        .channels
884        .as_ref()
885        .map(|c| c.count() as u16)
886        .unwrap_or(2);
887
888    let duration_ms = track_duration_ms(&*reader, track, sample_rate);
889
890    // Try codec_params first; fall back to file-size estimation for Opus/lossy.
891    let mut bitrate_kbps = estimate_bitrate_from_codec_params(codec_params);
892    if bitrate_kbps.is_none()
893        && is_opus_codec
894        && let Ok(meta) = std::fs::metadata(path)
895        && duration_ms > 0
896    {
897        bitrate_kbps = Some((meta.len() * 8 / duration_ms) as u32);
898    }
899
900    let info = StreamInfo {
901        codec: codec_name(codec_params.codec),
902        sample_rate,
903        channels,
904        bit_depth: source_bit_depth(codec_params),
905        bitrate_kbps,
906        duration_ms,
907    };
908
909    // The ring holds the output format, which DSP may have moved off the
910    // source's: two sources resampled to one impulse response's rate play
911    // gaplessly on one engine.
912    let out_rate = processing.output_rate(sample_rate);
913    let format = (out_rate, channels);
914    if let Some(expected) = expected
915        && expected != format
916    {
917        log::info!(
918            "format change at {}: {}Hz/{}ch → {}Hz/{}ch, restarting audio engine",
919            path.display(),
920            expected.0,
921            expected.1,
922            out_rate,
923            channels
924        );
925        return Ok(Decoded::FormatMismatch);
926    }
927
928    // Build either a Symphonia decoder or our Opus bridge.
929    let mut symphonia_decoder = if is_opus_codec {
930        None
931    } else {
932        Some(
933            symphonia::default::get_codecs()
934                .make_audio_decoder(codec_params, &AudioDecoderOptions::default())
935                .map_err(|_| DecodeError::UnsupportedCodec)?,
936        )
937    };
938    let mut opus_bridge = if is_opus_codec {
939        Some(OpusBridge::new(codec_params).map_err(|e| DecodeError::Decode(e.to_string()))?)
940    } else {
941        None
942    };
943
944    // Seek if requested (only for the first track usually).
945    //
946    // Accurate rather than coarse: a coarse seek picks a byte offset by
947    // interpolating linearly over the file and then derives its reported
948    // timestamp from that same guess, so on VBR MP3 it lands seconds from the
949    // request *and* reports a position it never reached. Accurate mode walks
950    // frame headers and is truthful about both, for 1.5-3ms on files up to
951    // 79MB. The timeline records where playback actually resumed, so the
952    // transport shows the position being heard.
953    //
954    // Opus lands `OPUS_PREROLL_MS` early and decodes its way to the request,
955    // discarding what it decoded: its decoder carries state from packet to
956    // packet, and a fresh one needs that long to converge (RFC 7845 §4.6).
957    // Starting cold at the request would play its first frames wrong.
958    let mut seek_samples = 0;
959    let mut discard = 0usize;
960    if seek_ms > 0 {
961        let preroll = if is_opus_codec { OPUS_PREROLL_MS } else { 0 };
962        let seeked = reader
963            .seek(
964                SeekMode::Accurate,
965                SeekTo::Time {
966                    time: Time::from_millis_u64(seek_ms.saturating_sub(preroll)),
967                    track_id: Some(track_id),
968                },
969            )
970            .map_err(|e| DecodeError::Decode(format!("seek failed: {}", e)))?;
971        seek_samples = landing_samples(time_base, seeked.actual_ts, sample_rate, channels)
972            .unwrap_or(
973                seek_ms.saturating_sub(preroll) * sample_rate as u64 * channels as u64 / 1000,
974            );
975        let landed_at_start = seek_samples == 0;
976        if preroll > 0 {
977            let wanted = seek_ms * sample_rate as u64 / 1000 * channels as u64;
978            discard = wanted.saturating_sub(seek_samples) as usize;
979            seek_samples += discard as u64;
980        }
981        if let Some(ref mut dec) = symphonia_decoder {
982            dec.reset();
983        }
984        if let Some(ref mut opus) = opus_bridge {
985            opus.reset(landed_at_start);
986        }
987    }
988
989    // The chain carries across a gapless boundary: resetting its filters would
990    // put a transient at every track change. A resampler for the outgoing rate
991    // gives up its tail first, which belongs to the track before.
992    if let Some(setup) = &processing.dsp {
993        match chain {
994            Some(c) => {
995                let tail = c.set_source_rate(sample_rate);
996                if !write_ring(producer, tail, stop, viz_buffer, format) {
997                    return Ok(Decoded::Complete(format));
998                }
999            }
1000            None => *chain = Some(Chain::new(setup, sample_rate, channels)),
1001        }
1002    }
1003
1004    // Record this track's boundary in the timeline.
1005    let write_offset = timeline.samples_written();
1006    timeline.push_boundary(TrackBoundary {
1007        id: queue_item_id,
1008        path: path.to_path_buf(),
1009        info,
1010        sample_offset: write_offset,
1011        samples_written: 0,
1012        seek_samples,
1013        output_rate: out_rate,
1014    });
1015
1016    // Read ReplayGain tags and select the active gain for this track.
1017    let rg_mode = processing.rg_mode;
1018    let pre_amp_db = processing.pre_amp_db;
1019    let rg_gain = if rg_mode != ReplayGainMode::Off {
1020        match crate::audio::replaygain::read_tags(path) {
1021            Ok(rg_info) => {
1022                let selected = crate::audio::replaygain::select_gain(&rg_info, rg_mode);
1023                if let Some((gain_db, _)) = selected {
1024                    log::info!(
1025                        "replaygain: applying {:.2} dB ({:?}) to {}",
1026                        gain_db,
1027                        rg_mode,
1028                        path.display()
1029                    );
1030                }
1031                selected
1032            }
1033            Err(e) => {
1034                log::debug!("replaygain: no tags for {}: {}", path.display(), e);
1035                None
1036            }
1037        }
1038    } else {
1039        None
1040    };
1041    let mut rg_scratch: Vec<f32> = Vec::new();
1042
1043    let mut sample_buf: Vec<f32> = Vec::new();
1044
1045    loop {
1046        if stop.load(Ordering::Relaxed) {
1047            return Ok(Decoded::Complete(format));
1048        }
1049
1050        let packet = match reader.next_packet() {
1051            Ok(Some(p)) => p,
1052            Ok(None) => return Ok(Decoded::Complete(format)),
1053            Err(e) => return Err(DecodeError::Decode(e.to_string())),
1054        };
1055
1056        if packet.track_id != track_id {
1057            continue;
1058        }
1059
1060        // Decode the packet — either via Opus bridge or Symphonia codec.
1061        let samples: &[f32] = if let Some(ref mut opus) = opus_bridge {
1062            match opus.decode_packet(&packet.data) {
1063                Ok(s) => s,
1064                Err(e) => {
1065                    log::warn!("opus decode error (skipping packet): {}", e);
1066                    continue;
1067                }
1068            }
1069        } else {
1070            let decoder = symphonia_decoder.as_mut().unwrap();
1071            let decoded = match decoder.decode(&packet) {
1072                Ok(d) => d,
1073                Err(symphonia::core::errors::Error::DecodeError(e)) => {
1074                    log::warn!("decode error (skipping packet): {}", e);
1075                    continue;
1076                }
1077                Err(e) => return Err(DecodeError::Decode(e.to_string())),
1078            };
1079
1080            let spec = decoded.spec();
1081            let (decoded_rate, decoded_channels) = (spec.rate(), spec.channels().count() as u16);
1082            // The engine is configured from the probed format. PCM that
1083            // disagrees with it would play at the wrong speed, so end the
1084            // session instead and let the player reconfigure.
1085            if (decoded_rate, decoded_channels) != (sample_rate, channels) {
1086                log::warn!(
1087                    "{}: decoded {}Hz/{}ch but stream declares {}Hz/{}ch, restarting audio engine",
1088                    path.display(),
1089                    decoded_rate,
1090                    decoded_channels,
1091                    sample_rate,
1092                    channels
1093                );
1094                return Ok(Decoded::FormatMismatch);
1095            }
1096            decoded.copy_to_vec_interleaved(&mut sample_buf);
1097            &sample_buf[..]
1098        };
1099
1100        let samples = if discard > 0 {
1101            let dropped = discard.min(samples.len());
1102            discard -= dropped;
1103            &samples[dropped..]
1104        } else {
1105            samples
1106        };
1107        if samples.is_empty() {
1108            continue;
1109        }
1110
1111        // Apply ReplayGain if active. Uses a reusable scratch buffer to avoid
1112        // allocating per packet. Zero overhead when RG is off.
1113        let samples = if let Some((gain_db, peak)) = rg_gain {
1114            rg_scratch.clear();
1115            rg_scratch.extend_from_slice(samples);
1116            crate::audio::replaygain::apply_gain(&mut rg_scratch, gain_db, peak, pre_amp_db);
1117            &rg_scratch[..]
1118        } else {
1119            samples
1120        };
1121
1122        let (samples, length) = match chain.as_mut() {
1123            Some(c) => c.process(samples),
1124            None => (samples, samples.len() as u64),
1125        };
1126        if !write_ring(producer, samples, stop, viz_buffer, format) {
1127            return Ok(Decoded::Complete(format));
1128        }
1129        timeline.add_written(length);
1130    }
1131}
1132
1133/// How far before a seek's target Opus starts decoding, for its decoder to
1134/// converge before anything is heard (RFC 7845 recommends at least 80 ms).
1135const OPUS_PREROLL_MS: u64 = 80;
1136
1137/// Interleaved sample offset of a seek's landing point.
1138///
1139/// `actual_ts` is in the track's timebase, which is not always the reciprocal
1140/// of the sample rate (Matroska ticks in milliseconds), so it is converted
1141/// through `Time` rather than assumed to be a frame count.
1142fn landing_samples(
1143    time_base: Option<TimeBase>,
1144    actual_ts: Timestamp,
1145    sample_rate: u32,
1146    channels: u16,
1147) -> Option<u64> {
1148    let (seconds, nanos) = time_base?.calc_time(actual_ts)?.parts();
1149    let rate = sample_rate as u64;
1150    let frames = seconds.max(0) as u64 * rate + (nanos as u64 * rate) / 1_000_000_000;
1151    Some(frames * channels as u64)
1152}
1153
1154/// Duration of a track in milliseconds.
1155///
1156/// The container's stated duration is authoritative because a track's timebase
1157/// is not always the reciprocal of the sample rate — Matroska ticks in
1158/// milliseconds, and states its duration at media level rather than per track.
1159/// Falls back to the playable frame count when no duration is stated at all.
1160pub(crate) fn track_duration_ms(
1161    reader: &(impl FormatReader + ?Sized),
1162    track: &Track,
1163    sample_rate: u32,
1164) -> u64 {
1165    fn to_ms(time_base: Option<TimeBase>, duration: Option<Duration>) -> Option<u64> {
1166        let time = time_base?.calc_duration(duration?)?;
1167        Some(time.as_millis().max(0) as u64)
1168    }
1169
1170    let media = reader.media_info();
1171    to_ms(track.time_base, track.duration)
1172        .or_else(|| to_ms(media.time_base, media.duration))
1173        .or_else(|| {
1174            track
1175                .num_frames
1176                .map(|frames| frames * 1000 / sample_rate as u64)
1177        })
1178        .unwrap_or(0)
1179}
1180
1181/// Estimate bitrate (kbps) from Symphonia codec parameters.
1182///
1183/// Symphonia doesn't expose a `bit_rate` field. For lossy codecs like MP3/AAC
1184/// we can derive it from `bits_per_coded_sample` when the demuxer populates it.
1185/// Returns `None` for lossless codecs or when the info isn't available.
1186fn estimate_bitrate_from_codec_params(params: &AudioCodecParameters) -> Option<u32> {
1187    let is_lossy = matches!(
1188        params.codec,
1189        CODEC_ID_MP3 | CODEC_ID_AAC | CODEC_ID_VORBIS | CODEC_ID_OPUS
1190    );
1191    if !is_lossy {
1192        return None;
1193    }
1194
1195    // bits_per_coded_sample * sample_rate / 1000 gives kbps for CBR streams.
1196    // Few demuxers fill this in, but it's our best shot without file size.
1197    let bpcs = params.bits_per_coded_sample?;
1198    let sr = params.sample_rate?;
1199    let channels = params
1200        .channels
1201        .as_ref()
1202        .map(|c| c.count() as u32)
1203        .unwrap_or(2);
1204    Some(bpcs * sr * channels / 1000)
1205}
1206
1207/// The source's bit depth, or `None` where the stream does not state one
1208/// (lossy codecs). Symphonia's MP4 demuxer leaves `bits_per_sample` unset for
1209/// ALAC, whose depth lives in the magic cookie.
1210pub fn source_bit_depth(params: &AudioCodecParameters) -> Option<u16> {
1211    if params.codec == CODEC_ID_OPUS {
1212        return None;
1213    }
1214    if let Some(bits) = params.bits_per_sample {
1215        return Some(bits as u16);
1216    }
1217    if params.codec == CODEC_ID_ALAC {
1218        let cookie = params.extra_data.as_deref()?;
1219        return MagicCookie::read(cookie)
1220            .ok()
1221            .map(|c| u16::from(c.bit_depth));
1222    }
1223    None
1224}
1225
1226pub fn codec_name(codec: AudioCodecId) -> String {
1227    match codec {
1228        CODEC_ID_FLAC => "FLAC",
1229        CODEC_ID_MP3 => "MP3",
1230        CODEC_ID_AAC => "AAC",
1231        CODEC_ID_VORBIS => "Vorbis",
1232        CODEC_ID_OPUS => "Opus",
1233        CODEC_ID_ALAC => "ALAC",
1234        CODEC_ID_PCM_S16LE => "PCM/16",
1235        CODEC_ID_PCM_S24LE => "PCM/24",
1236        CODEC_ID_PCM_S32LE => "PCM/32",
1237        CODEC_ID_PCM_F32LE => "PCM/f32",
1238        other => return format!("Unknown({:?})", other),
1239    }
1240    .to_string()
1241}
1242
1243#[cfg(test)]
1244mod tests {
1245    use std::path::PathBuf;
1246    use std::sync::atomic::Ordering;
1247
1248    use super::*;
1249    use crate::player::state::QueueItemId;
1250
1251    fn make_info(sample_rate: u32, channels: u16) -> StreamInfo {
1252        StreamInfo {
1253            codec: "FLAC".to_string(),
1254            sample_rate,
1255            channels,
1256            bit_depth: Some(16),
1257            bitrate_kbps: None,
1258            duration_ms: 10_000,
1259        }
1260    }
1261
1262    fn make_boundary(
1263        id: QueueItemId,
1264        sample_offset: u64,
1265        seek_samples: u64,
1266        channels: u16,
1267        sample_rate: u32,
1268    ) -> TrackBoundary {
1269        TrackBoundary {
1270            id,
1271            path: PathBuf::from("/music/track.flac"),
1272            info: make_info(sample_rate, channels),
1273            sample_offset,
1274            samples_written: 0,
1275            seek_samples,
1276            output_rate: sample_rate,
1277        }
1278    }
1279
1280    fn writer(timeline: &PlaybackTimeline) -> TimelineWriter<'_> {
1281        timeline.writer()
1282    }
1283
1284    // --- PlaybackTimeline tests ---
1285
1286    #[test]
1287    fn test_timeline_single_track() {
1288        // Push one boundary at offset 0 with stereo 44100 Hz audio.
1289        // After simulating 44100 frames (88200 interleaved samples) played,
1290        // current_playback() should report track index 0 at position 1000 ms.
1291        let timeline = PlaybackTimeline::new();
1292        let tl = writer(&timeline);
1293        let id = QueueItemId::new();
1294        // sample_offset=0, seek_samples=0, channels=2, sample_rate=44100
1295        tl.push_boundary(make_boundary(id, 0, 0, 2, 44100));
1296        tl.add_written(88200); // 1 second of audio
1297
1298        // Simulate 1 second played: 44100 frames * 2 channels = 88200 interleaved samples
1299        timeline.samples_played.store(88200, Ordering::Relaxed);
1300
1301        let result = timeline.current_playback();
1302        assert!(
1303            result.is_some(),
1304            "expected Some for single track with samples played"
1305        );
1306        let (result_id, _path, _info, position_ms) = result.unwrap();
1307        assert_eq!(result_id, id);
1308        assert_eq!(
1309            position_ms, 1000,
1310            "1 second of 44100 Hz stereo should be 1000 ms"
1311        );
1312    }
1313
1314    #[test]
1315    fn test_timeline_gapless_transition() {
1316        // Two tracks in gapless sequence. Track 1 ends at sample 88200 (1 sec stereo 44100 Hz).
1317        // Track 2 begins at sample_offset 88200. When playback head is at 100000 (past the boundary),
1318        // current_playback() should report track 2.
1319        let timeline = PlaybackTimeline::new();
1320        let tl = writer(&timeline);
1321        let id1 = QueueItemId::new();
1322        let id2 = QueueItemId::new();
1323
1324        // Track 1: starts at offset 0
1325        tl.push_boundary(make_boundary(id1, 0, 0, 2, 44100));
1326        tl.add_written(88200);
1327
1328        // Track 2: starts at offset 88200 (immediately after track 1's samples)
1329        tl.push_boundary(make_boundary(id2, 88200, 0, 2, 44100));
1330        tl.add_written(44100); // half a second of track 2
1331
1332        // Set playback head past the track 1/2 boundary
1333        timeline.samples_played.store(90000, Ordering::Relaxed);
1334
1335        let result = timeline.current_playback();
1336        assert!(result.is_some());
1337        let (result_id, _path, _info, position_ms) = result.unwrap();
1338        assert_eq!(
1339            result_id, id2,
1340            "playback head past boundary should report second track"
1341        );
1342        // (90000 - 88200) / 2 channels * 1000 / 44100 = 900 / 44100 ≈ 20 ms
1343        assert_eq!(position_ms, 20, "position within track 2 should be ~20 ms");
1344    }
1345
1346    #[test]
1347    fn test_timeline_zero_samples() {
1348        // With 0 samples played and a boundary at offset 0, current_playback() should
1349        // still return the first track at position 0 ms.
1350        let timeline = PlaybackTimeline::new();
1351        let tl = writer(&timeline);
1352        let id = QueueItemId::new();
1353        tl.push_boundary(make_boundary(id, 0, 0, 2, 44100));
1354        tl.add_written(1000);
1355        timeline.samples_played.store(0, Ordering::Relaxed);
1356
1357        let result = timeline.current_playback();
1358        assert!(
1359            result.is_some(),
1360            "expected Some at 0 samples played with a boundary at offset 0"
1361        );
1362        let (result_id, _path, _info, position_ms) = result.unwrap();
1363        assert_eq!(result_id, id);
1364        assert_eq!(position_ms, 0);
1365    }
1366
1367    #[test]
1368    fn test_timeline_past_all_boundaries() {
1369        // When samples_played exceeds all boundaries, the last track should be reported.
1370        // The binary search finds the last boundary whose sample_offset <= played.
1371        let timeline = PlaybackTimeline::new();
1372        let tl = writer(&timeline);
1373        let id1 = QueueItemId::new();
1374        let id2 = QueueItemId::new();
1375
1376        tl.push_boundary(make_boundary(id1, 0, 0, 2, 44100));
1377        tl.add_written(88200);
1378        tl.push_boundary(make_boundary(id2, 88200, 0, 2, 44100));
1379        tl.add_written(88200);
1380
1381        // Simulate playback far past both tracks
1382        timeline
1383            .samples_played
1384            .store(999_999_999, Ordering::Relaxed);
1385
1386        let result = timeline.current_playback();
1387        assert!(result.is_some());
1388        let (result_id, _path, _info, _position_ms) = result.unwrap();
1389        assert_eq!(
1390            result_id, id2,
1391            "samples past all boundaries should report the last track"
1392        );
1393    }
1394
1395    #[test]
1396    fn test_timeline_seek_offset() {
1397        // When a seek offset is set, position_ms should include the seek position.
1398        // seek_samples = 88200 means playback started 1 second into the track.
1399        // With 0 additional samples played past the boundary, position should be 1000 ms.
1400        let timeline = PlaybackTimeline::new();
1401        let tl = writer(&timeline);
1402        let id = QueueItemId::new();
1403        let seek_samples = 88200u64; // 1 second at 44100 Hz stereo
1404        tl.push_boundary(make_boundary(id, 0, seek_samples, 2, 44100));
1405        tl.add_written(44100); // half a second written so far
1406        // samples_played at the track boundary (0 frames past the track start)
1407        timeline.samples_played.store(0, Ordering::Relaxed);
1408
1409        let result = timeline.current_playback();
1410        assert!(result.is_some());
1411        let (_result_id, _path, _info, position_ms) = result.unwrap();
1412        // track_samples = 0 - 0 = 0; seek contribution = (88200/2)*1000/44100 = 1000 ms
1413        assert_eq!(
1414            position_ms, 1000,
1415            "position should include seek offset of 1000 ms"
1416        );
1417    }
1418
1419    #[test]
1420    fn the_next_track_is_due_when_the_current_one_has_played_out() {
1421        let timeline = PlaybackTimeline::new();
1422        let tl = writer(&timeline);
1423        let (a, b) = (QueueItemId::new(), QueueItemId::new());
1424        // One second of 44.1kHz stereo before b starts.
1425        tl.push_boundary(make_boundary(a, 0, 0, 2, 44100));
1426        assert_eq!(timeline.until_next_track(), None, "nothing queued after it");
1427        tl.push_boundary(make_boundary(b, 88200, 0, 2, 44100));
1428
1429        timeline.samples_played.store(44100, Ordering::Relaxed);
1430        assert_eq!(
1431            timeline.until_next_track(),
1432            Some(std::time::Duration::from_millis(500))
1433        );
1434        timeline.samples_played.store(88200, Ordering::Relaxed);
1435        assert_eq!(timeline.until_next_track(), None, "b is playing now");
1436    }
1437
1438    #[test]
1439    fn the_queued_callback_hears_each_track() {
1440        let timeline = PlaybackTimeline::new();
1441        let heard = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1442        let counter = heard.clone();
1443        timeline.on_queued(move || {
1444            counter.fetch_add(1, Ordering::Relaxed);
1445        });
1446        let tl = writer(&timeline);
1447        tl.push_boundary(make_boundary(QueueItemId::new(), 0, 0, 2, 44100));
1448        tl.push_boundary(make_boundary(QueueItemId::new(), 88200, 0, 2, 44100));
1449        assert_eq!(heard.load(Ordering::Relaxed), 2);
1450    }
1451
1452    #[test]
1453    fn a_track_played_past_reads_as_played_to_its_end() {
1454        let timeline = PlaybackTimeline::new();
1455        let tl = writer(&timeline);
1456        let (a, b) = (QueueItemId::new(), QueueItemId::new());
1457        tl.push_boundary(make_boundary(a, 0, 0, 2, 44100));
1458        tl.push_boundary(make_boundary(b, 88200, 0, 2, 44100));
1459        timeline
1460            .samples_played
1461            .store(88200 + 44100, Ordering::Relaxed);
1462
1463        assert_eq!(timeline.position_in(0), Some(1000));
1464        assert_eq!(timeline.position_in(1), Some(500));
1465        assert_eq!(
1466            timeline.playhead(),
1467            Some(Playhead {
1468                boundary: 1,
1469                id: b,
1470                position_ms: 500
1471            })
1472        );
1473    }
1474
1475    #[test]
1476    fn an_item_queued_twice_is_two_plays() {
1477        let timeline = PlaybackTimeline::new();
1478        let tl = writer(&timeline);
1479        let a = QueueItemId::new();
1480        tl.push_boundary(make_boundary(a, 0, 0, 2, 44100));
1481        tl.push_boundary(make_boundary(a, 88200, 0, 2, 44100));
1482        timeline
1483            .samples_played
1484            .store(88200 + 44100, Ordering::Relaxed);
1485
1486        assert_eq!(timeline.position_in(0), Some(1000), "the first pass ended");
1487        assert_eq!(timeline.position_in(1), Some(500));
1488        assert_eq!(timeline.playhead().map(|p| p.boundary), Some(1));
1489    }
1490
1491    #[test]
1492    fn test_timeline_reset() {
1493        // After reset(), current_playback() returns None and all counters are cleared.
1494        let timeline = PlaybackTimeline::new();
1495        let tl = writer(&timeline);
1496        let id = QueueItemId::new();
1497        tl.push_boundary(make_boundary(id, 0, 0, 2, 44100));
1498        tl.add_written(88200);
1499        timeline.samples_played.store(44100, Ordering::Relaxed);
1500
1501        // Sanity check: playback is live before reset
1502        assert!(timeline.current_playback().is_some());
1503
1504        timeline.reset();
1505
1506        assert!(
1507            timeline.current_playback().is_none(),
1508            "after reset, current_playback should return None"
1509        );
1510        assert_eq!(
1511            timeline.samples_played.load(Ordering::Relaxed),
1512            0,
1513            "samples_played should be 0 after reset"
1514        );
1515        assert_eq!(
1516            timeline.samples_written.load(Ordering::Relaxed),
1517            0,
1518            "samples_written should be 0 after reset"
1519        );
1520    }
1521
1522    // --- Probe and decode integration tests ---
1523
1524    #[test]
1525    fn probe_file_extracts_stream_info() {
1526        let dir = tempfile::tempdir().unwrap();
1527        let wav_path = dir.path().join("probe_test.wav");
1528        crate::test_utils::generate_wav(&wav_path, 44100, 2, 1.0, 16);
1529
1530        let info = probe_file(&wav_path).expect("probe_file should succeed on a valid WAV");
1531        assert_eq!(info.sample_rate, 44100, "sample rate mismatch");
1532        assert_eq!(info.channels, 2, "channel count mismatch");
1533        assert_eq!(info.bit_depth, Some(16), "bit depth mismatch");
1534        assert!(
1535            info.duration_ms > 900 && info.duration_ms < 1100,
1536            "duration should be ~1000ms, got {}",
1537            info.duration_ms
1538        );
1539        assert!(
1540            info.codec.contains("PCM"),
1541            "codec should be PCM variant, got {}",
1542            info.codec
1543        );
1544    }
1545
1546    #[test]
1547    fn probe_file_reads_alac_bit_depth_from_the_magic_cookie() {
1548        let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/audio/testdata/24-bit-96k.m4a");
1549        let info = probe_file(&path).expect("probe_file should succeed on ALAC in MP4");
1550        assert_eq!(info.codec, "ALAC");
1551        assert_eq!(info.sample_rate, 96000);
1552        assert_eq!(info.bit_depth, Some(24));
1553    }
1554
1555    #[test]
1556    fn decode_single_produces_samples() {
1557        let dir = tempfile::tempdir().unwrap();
1558        let wav_path = dir.path().join("tone.wav");
1559        // 440 Hz sine, mono, 0.1s — enough to verify non-zero decode output.
1560        crate::test_utils::generate_wav_tone(&wav_path, 44100, 440.0, 0.1);
1561
1562        // Set up rtrb ring buffer.
1563        let (mut producer, mut consumer) = rtrb::RingBuffer::new(44100 * 2);
1564
1565        let timeline = PlaybackTimeline::new();
1566        let tl = writer(&timeline);
1567        let stop = Arc::new(AtomicBool::new(false));
1568
1569        let id = QueueItemId::new();
1570        let entry = SourceEntry::from_file(id, wav_path.clone());
1571        let hint = entry.hint.clone();
1572        let mss = (entry.make_mss)().expect("should open WAV file");
1573
1574        let result = decode_single(
1575            id,
1576            &wav_path,
1577            &hint,
1578            mss,
1579            &mut producer,
1580            &stop,
1581            0,
1582            &tl,
1583            None,
1584            &Processing::default(),
1585            &mut None,
1586            None,
1587        );
1588        assert!(
1589            matches!(result, Ok(Decoded::Complete((44100, 1)))),
1590            "decode_single should complete at the source format"
1591        );
1592
1593        // Read samples from the consumer side.
1594        let available = consumer.slots();
1595        assert!(available > 0, "expected samples in ring buffer, got 0");
1596
1597        // Verify at least some samples are non-zero (it's a sine wave, not silence).
1598        let mut found_nonzero = false;
1599        while consumer.slots() > 0 {
1600            if let Ok(chunk) = consumer.read_chunk(consumer.slots().min(1024)) {
1601                let (first, second) = chunk.as_slices();
1602                for &s in first.iter().chain(second.iter()) {
1603                    if s.abs() > 0.001 {
1604                        found_nonzero = true;
1605                        break;
1606                    }
1607                }
1608                chunk.commit_all();
1609            }
1610            if found_nonzero {
1611                break;
1612            }
1613        }
1614        assert!(
1615            found_nonzero,
1616            "expected non-zero samples from 440Hz sine decode"
1617        );
1618    }
1619
1620    // --- Ring buffer format contract ---
1621
1622    /// Decode a queue of files through `decode_queue_loop` with a consumer
1623    /// draining in the background. Returns the boundaries the decode thread
1624    /// pushed onto the timeline.
1625    fn run_queue(paths: &[PathBuf]) -> Vec<TrackBoundary> {
1626        run_queue_with(paths, &Processing::default()).0
1627    }
1628
1629    /// `run_queue`, through `processing`. Also returns how many samples the
1630    /// consumer read and how many the timeline counted.
1631    fn run_queue_with(
1632        paths: &[PathBuf],
1633        processing: &Processing,
1634    ) -> (Vec<TrackBoundary>, u64, u64) {
1635        let (producer, mut consumer) = rtrb::RingBuffer::new(1 << 16);
1636        let timeline = PlaybackTimeline::new();
1637        let tl = writer(&timeline);
1638        let stop = Arc::new(AtomicBool::new(false));
1639
1640        let drain_stop = Arc::new(AtomicBool::new(false));
1641        let drain_flag = drain_stop.clone();
1642        let drainer = std::thread::spawn(move || {
1643            let mut read = 0u64;
1644            while !drain_flag.load(Ordering::Relaxed) {
1645                let n = consumer.slots();
1646                if n > 0
1647                    && let Ok(chunk) = consumer.read_chunk(n)
1648                {
1649                    read += n as u64;
1650                    chunk.commit_all();
1651                }
1652                std::thread::sleep(std::time::Duration::from_micros(200));
1653            }
1654            read
1655        });
1656
1657        let rest: std::sync::Mutex<Vec<PathBuf>> = std::sync::Mutex::new(paths[1..].to_vec());
1658        let next_track = move || {
1659            let mut rest = rest.lock().ok()?;
1660            if rest.is_empty() {
1661                return None;
1662            }
1663            Some(SourceEntry::from_file(QueueItemId::new(), rest.remove(0)))
1664        };
1665
1666        decode_queue_loop(
1667            SourceEntry::from_file(QueueItemId::new(), paths[0].clone()),
1668            producer,
1669            &stop,
1670            0,
1671            &next_track,
1672            &tl,
1673            None,
1674            processing,
1675        );
1676
1677        drain_stop.store(true, Ordering::Relaxed);
1678        let read = drainer.join().unwrap();
1679
1680        let written = timeline.samples_written.load(Ordering::Relaxed);
1681        (timeline.boundaries.read().clone(), read, written)
1682    }
1683
1684    #[test]
1685    fn convolution_at_one_rate_plays_two_source_rates_gaplessly() {
1686        let dir = tempfile::tempdir().unwrap();
1687        let a = dir.path().join("a.wav");
1688        let b = dir.path().join("b.wav");
1689        crate::test_utils::generate_wav(&a, 44100, 2, 0.5, 16);
1690        crate::test_utils::generate_wav(&b, 96000, 2, 0.5, 16);
1691        let mut ir = vec![0.0; 129];
1692        ir[64] = 1.0;
1693        let processing = Processing {
1694            dsp: Some(Arc::new(Setup::new(
1695                vec![],
1696                vec![crate::audio::dsp::Impulse::from_channels(48000, vec![ir])],
1697            ))),
1698            ..Processing::default()
1699        };
1700
1701        let (bounds, read, written) = run_queue_with(&[a, b], &processing);
1702        assert_eq!(bounds.len(), 2, "both resample to 48 kHz, so one session");
1703        assert!(bounds.iter().all(|b| b.output_rate == 48000));
1704        assert_eq!(bounds[0].info.sample_rate, 44100);
1705        assert_eq!(bounds[1].info.sample_rate, 96000);
1706        // Half a second of each at 48 kHz, stereo — and every sample the
1707        // timeline counted reached the output, delays trimmed and tails flushed.
1708        assert_eq!(written, 2 * 24000 * 2);
1709        assert_eq!(read, written);
1710        assert_eq!(bounds[1].sample_offset, 24000 * 2);
1711    }
1712
1713    #[test]
1714    fn gapless_continues_when_format_matches() {
1715        let dir = tempfile::tempdir().unwrap();
1716        let a = dir.path().join("a.wav");
1717        let b = dir.path().join("b.wav");
1718        crate::test_utils::generate_wav(&a, 44100, 2, 0.1, 16);
1719        crate::test_utils::generate_wav(&b, 44100, 2, 0.1, 16);
1720
1721        let bounds = run_queue(&[a, b]);
1722        assert_eq!(
1723            bounds.len(),
1724            2,
1725            "same-format tracks should decode gaplessly"
1726        );
1727    }
1728
1729    #[test]
1730    fn gapless_stops_at_sample_rate_change() {
1731        let dir = tempfile::tempdir().unwrap();
1732        let a = dir.path().join("a.wav");
1733        let b = dir.path().join("b.wav");
1734        crate::test_utils::generate_wav(&a, 44100, 2, 0.1, 16);
1735        crate::test_utils::generate_wav(&b, 48000, 2, 0.1, 16);
1736
1737        let bounds = run_queue(&[a, b]);
1738        assert_eq!(
1739            bounds.len(),
1740            1,
1741            "a 48kHz track must not join a 44.1kHz ring buffer"
1742        );
1743        assert_eq!(bounds[0].info.sample_rate, 44100);
1744    }
1745
1746    #[test]
1747    fn gapless_stops_at_channel_change() {
1748        let dir = tempfile::tempdir().unwrap();
1749        let a = dir.path().join("a.wav");
1750        let b = dir.path().join("b.wav");
1751        crate::test_utils::generate_wav(&a, 44100, 2, 0.1, 16);
1752        crate::test_utils::generate_wav(&b, 44100, 1, 0.1, 16);
1753
1754        let bounds = run_queue(&[a, b]);
1755        assert_eq!(
1756            bounds.len(),
1757            1,
1758            "a mono track must not join a stereo ring buffer"
1759        );
1760        assert_eq!(bounds[0].info.channels, 2);
1761    }
1762
1763    #[test]
1764    fn drain_waits_for_the_consumer() {
1765        let (mut producer, mut consumer) = rtrb::RingBuffer::new(64);
1766        for _ in 0..64 {
1767            producer.push(0.0).unwrap();
1768        }
1769        let stop = Arc::new(AtomicBool::new(false));
1770
1771        let reader = std::thread::spawn(move || {
1772            std::thread::sleep(std::time::Duration::from_millis(20));
1773            let chunk = consumer.read_chunk(64).unwrap();
1774            chunk.commit_all();
1775            consumer
1776        });
1777
1778        wait_for_drain(&producer, &stop, None);
1779        assert_eq!(producer.slots(), 64, "drain must wait for an empty buffer");
1780        drop(reader.join().unwrap());
1781    }
1782
1783    #[test]
1784    fn drain_returns_when_playback_is_torn_down() {
1785        let (producer, consumer) = rtrb::RingBuffer::<f32>::new(64);
1786        let stop = Arc::new(AtomicBool::new(true));
1787        wait_for_drain(&producer, &stop, None);
1788        drop(consumer);
1789    }
1790
1791    // --- Failure handling in the gapless queue ---
1792
1793    /// A file that exists and has an audio extension but no audio in it.
1794    fn write_garbage(path: &Path) {
1795        std::fs::write(path, b"this is not a wav file").unwrap();
1796    }
1797
1798    #[test]
1799    fn an_unreadable_track_is_skipped_and_the_queue_continues() {
1800        let dir = tempfile::tempdir().unwrap();
1801        let a = dir.path().join("a.wav");
1802        let bad = dir.path().join("bad.wav");
1803        let c = dir.path().join("c.wav");
1804        crate::test_utils::generate_wav(&a, 44100, 2, 0.1, 16);
1805        write_garbage(&bad);
1806        crate::test_utils::generate_wav(&c, 44100, 2, 0.1, 16);
1807
1808        let bounds = run_queue(&[a.clone(), bad, c.clone()]);
1809        let decoded: Vec<_> = bounds.iter().map(|b| b.path.clone()).collect();
1810        assert_eq!(
1811            decoded,
1812            vec![a, c],
1813            "one bad file must not take the rest of the queue with it"
1814        );
1815    }
1816
1817    #[test]
1818    fn a_missing_track_is_skipped_and_the_queue_continues() {
1819        let dir = tempfile::tempdir().unwrap();
1820        let missing = dir.path().join("gone.wav");
1821        let b = dir.path().join("b.wav");
1822        crate::test_utils::generate_wav(&b, 44100, 2, 0.1, 16);
1823
1824        let bounds = run_queue(&[missing, b.clone()]);
1825        assert_eq!(bounds.len(), 1);
1826        assert_eq!(
1827            bounds[0].path, b,
1828            "a bad first track must not end the session"
1829        );
1830    }
1831
1832    #[test]
1833    fn an_entirely_unreadable_queue_terminates() {
1834        let dir = tempfile::tempdir().unwrap();
1835        let bad = dir.path().join("bad.wav");
1836        write_garbage(&bad);
1837
1838        // Nothing decodes, so the ring stays empty; the consumer only has to
1839        // outlive the producer.
1840        let (producer, _consumer) = rtrb::RingBuffer::<f32>::new(1 << 12);
1841        let timeline = PlaybackTimeline::new();
1842        let tl = writer(&timeline);
1843        let stop = Arc::new(AtomicBool::new(false));
1844
1845        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1846        let counter = calls.clone();
1847        let bad_path = bad.clone();
1848        let next_track = move || {
1849            counter.fetch_add(1, Ordering::Relaxed);
1850            Some(SourceEntry::from_file(QueueItemId::new(), bad_path.clone()))
1851        };
1852
1853        decode_queue_loop(
1854            SourceEntry::from_file(QueueItemId::new(), bad),
1855            producer,
1856            &stop,
1857            0,
1858            &next_track,
1859            &tl,
1860            None,
1861            &Processing::default(),
1862        );
1863
1864        // The first source plus one per next_track call, capped.
1865        assert_eq!(
1866            calls.load(Ordering::Relaxed) + 1,
1867            MAX_CONSECUTIVE_FAILURES as usize
1868        );
1869        assert!(timeline.boundaries.read().is_empty());
1870    }
1871
1872    // --- Seek landing position ---
1873
1874    #[test]
1875    fn landing_samples_converts_frame_timebases() {
1876        // The usual audio case: one tick per frame.
1877        let tb = TimeBase::try_from_recip(44100).unwrap();
1878        assert_eq!(
1879            landing_samples(Some(tb), Timestamp::from(44100u32), 44100, 2),
1880            Some(88_200)
1881        );
1882    }
1883
1884    #[test]
1885    fn landing_samples_converts_millisecond_timebases() {
1886        // Matroska ticks in milliseconds, not frames.
1887        let tb = TimeBase::try_new(1, 1000).unwrap();
1888        assert_eq!(
1889            landing_samples(Some(tb), Timestamp::from(1500u32), 48000, 2),
1890            Some(48000 * 3 / 2 * 2)
1891        );
1892    }
1893
1894    #[test]
1895    fn landing_samples_needs_a_timebase() {
1896        assert_eq!(
1897            landing_samples(None, Timestamp::from(1000u32), 44100, 2),
1898            None
1899        );
1900    }
1901
1902    /// Build a 300s VBR MP3: 30s of near-silence then a loud tone, which gives
1903    /// lame a wide enough bitrate spread to make coarse seeking miss badly.
1904    #[cfg(test)]
1905    fn make_vbr_mp3(dir: &Path) -> PathBuf {
1906        let wav = dir.join("source.wav");
1907        let mp3 = dir.join("source.mp3");
1908        let ok = std::process::Command::new("sox")
1909            .args(["-n", "-r", "44100", "-c", "2"])
1910            .arg(&wav)
1911            .args([
1912                "synth", "30", "sine", "200", "vol", "0.02", ":", "synth", "270", "sine", "880",
1913                "vol", "0.9",
1914            ])
1915            .status()
1916            .expect("sox not installed")
1917            .success();
1918        assert!(ok, "sox failed");
1919        let ok = std::process::Command::new("lame")
1920            .args(["-V", "2", "--quiet"])
1921            .arg(&wav)
1922            .arg(&mp3)
1923            .status()
1924            .expect("lame not installed")
1925            .success();
1926        assert!(ok, "lame failed");
1927        mp3
1928    }
1929
1930    /// A VBR seek must report where playback actually resumed: the reported
1931    /// start plus the audio that actually followed has to add back up to the
1932    /// file's duration. Under a coarse seek this file lands 3.7s late while
1933    /// reporting 83ms early — a 3.8s lie for the rest of the track.
1934    #[test]
1935    #[ignore = "generates a fixture with sox + lame; run with cargo test -- --ignored"]
1936    fn seek_on_vbr_reports_where_it_landed() {
1937        let dir = tempfile::tempdir().unwrap();
1938        let path = make_vbr_mp3(dir.path());
1939        let info = probe_file(&path).unwrap();
1940        let channels = info.channels as u64;
1941        let rate = info.sample_rate as u64;
1942
1943        let seek_ms = 150_000u64;
1944        let (mut producer, mut consumer) = rtrb::RingBuffer::<f32>::new(1 << 16);
1945        let stop = Arc::new(AtomicBool::new(false));
1946        let timeline = PlaybackTimeline::new();
1947        let tl = writer(&timeline);
1948
1949        let drain_stop = stop.clone();
1950        let drained = std::thread::spawn(move || {
1951            let mut total = 0u64;
1952            while !drain_stop.load(Ordering::Relaxed) {
1953                let slots = consumer.slots();
1954                if slots == 0 {
1955                    std::thread::sleep(std::time::Duration::from_micros(200));
1956                    continue;
1957                }
1958                let chunk = consumer.read_chunk(slots).unwrap();
1959                total += slots as u64;
1960                chunk.commit_all();
1961            }
1962            total
1963        });
1964
1965        let file = File::open(&path).unwrap();
1966        let mss = MediaSourceStream::new(Box::new(file), Default::default());
1967        let mut hint = Hint::new();
1968        hint.with_extension("mp3");
1969        decode_single(
1970            QueueItemId::new(),
1971            &path,
1972            &hint,
1973            mss,
1974            &mut producer,
1975            &stop,
1976            seek_ms,
1977            &tl,
1978            None,
1979            &Processing::default(),
1980            &mut None,
1981            None,
1982        )
1983        .unwrap();
1984
1985        let written = timeline.samples_written.load(Ordering::Relaxed);
1986        stop.store(true, Ordering::Relaxed);
1987        drained.join().unwrap();
1988
1989        let reported_start_ms = {
1990            let bounds = timeline.boundaries.read();
1991            (bounds[0].seek_samples / channels) * 1000 / rate
1992        };
1993        let decoded_ms = (written / channels) * 1000 / rate;
1994
1995        // Where playback started plus how much audio followed is the whole file.
1996        let total_ms = reported_start_ms + decoded_ms;
1997        assert!(
1998            total_ms.abs_diff(info.duration_ms) < 500,
1999            "reported start {}ms + {}ms decoded = {}ms, but the file is {}ms",
2000            reported_start_ms,
2001            decoded_ms,
2002            total_ms,
2003            info.duration_ms
2004        );
2005        // Landing is frame-granular, never sample-exact.
2006        assert!(
2007            reported_start_ms.abs_diff(seek_ms) < 100,
2008            "seek to {}ms reported {}ms",
2009            seek_ms,
2010            reported_start_ms
2011        );
2012    }
2013}