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