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