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