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