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::audio::SampleBuffer;
8use symphonia::core::codecs::{
9    CODEC_TYPE_AAC, CODEC_TYPE_ALAC, CODEC_TYPE_FLAC, CODEC_TYPE_MP3, CODEC_TYPE_OPUS,
10    CODEC_TYPE_PCM_F32LE, CODEC_TYPE_PCM_S16LE, CODEC_TYPE_PCM_S24LE, CODEC_TYPE_PCM_S32LE,
11    CODEC_TYPE_VORBIS, CODEC_TYPE_WAVPACK, CodecType, DecoderOptions,
12};
13use symphonia::core::formats::{FormatOptions, SeekMode, SeekTo};
14use symphonia::core::io::MediaSourceStream;
15use symphonia::core::meta::MetadataOptions;
16use symphonia::core::probe::Hint;
17use symphonia::core::units::Time;
18use thiserror::Error;
19
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: u16,
43    pub duration_ms: u64,
44}
45
46/// Handle to a running decode thread. Drop to stop it.
47pub struct DecodeHandle {
48    stop: Arc<AtomicBool>,
49    thread: Option<thread::JoinHandle<()>>,
50}
51
52impl DecodeHandle {
53    /// Signal the decode thread to stop without waiting for it to exit.
54    pub fn signal_stop(&self) {
55        self.stop.store(true, Ordering::Relaxed);
56    }
57
58    /// Create a DecodeHandle with no real thread (for tests only).
59    #[cfg(test)]
60    pub fn new_for_test(stop: Arc<AtomicBool>) -> Self {
61        Self { stop, thread: None }
62    }
63
64    /// Signal the decode thread to stop and wait for it.
65    pub fn stop(&mut self) {
66        self.signal_stop();
67        if let Some(handle) = self.thread.take()
68            && let Err(payload) = handle.join()
69        {
70            let msg = payload
71                .downcast_ref::<String>()
72                .map(|s| s.as_str())
73                .or_else(|| payload.downcast_ref::<&str>().copied())
74                .unwrap_or("unknown");
75            log::error!("decode thread panicked: {}", msg);
76        }
77    }
78}
79
80impl Drop for DecodeHandle {
81    fn drop(&mut self) {
82        self.stop();
83    }
84}
85
86// --- Playback timeline: the source of truth for "what's playing" ---
87
88/// A track boundary in the playback stream. At `sample_offset` cumulative
89/// samples written to the ring buffer, this track starts.
90#[derive(Debug, Clone)]
91pub struct TrackBoundary {
92    pub id: QueueItemId,
93    pub path: PathBuf,
94    pub info: StreamInfo,
95    /// Cumulative interleaved samples written to the ring buffer when this
96    /// track's first sample was pushed. For the first track this is 0
97    /// (or seek_samples if seeking).
98    pub sample_offset: u64,
99    /// Samples of this track's audio written to ring buffer so far.
100    /// Updated as decode progresses. At EOF, equals total decoded samples.
101    pub samples_written: u64,
102    /// The seek offset in samples for this track (non-zero only if user seeked).
103    pub seek_samples: u64,
104}
105
106/// Shared timeline that the decode thread writes and the UI reads.
107/// The decode thread appends boundaries; the UI reads them + samples_played
108/// to derive current track and position.
109pub struct PlaybackTimeline {
110    boundaries: parking_lot::RwLock<Vec<TrackBoundary>>,
111    /// Total interleaved samples written to the ring buffer across all tracks.
112    samples_written: AtomicU64,
113    /// Total interleaved samples consumed (played) by the audio engine.
114    /// Written by CoreAudio render callback, read by UI.
115    pub samples_played: Arc<AtomicU64>,
116}
117
118impl PlaybackTimeline {
119    pub fn new() -> Arc<Self> {
120        Arc::new(Self {
121            boundaries: parking_lot::RwLock::new(Vec::new()),
122            samples_written: AtomicU64::new(0),
123            samples_played: Arc::new(AtomicU64::new(0)),
124        })
125    }
126
127    /// Called by decode thread when starting a new track.
128    fn push_boundary(&self, boundary: TrackBoundary) {
129        self.boundaries.write().push(boundary);
130    }
131
132    /// Called by decode thread after pushing samples.
133    fn add_written(&self, count: u64) {
134        self.samples_written.fetch_add(count, Ordering::Relaxed);
135        // Also update the last boundary's samples_written.
136        let mut bounds = self.boundaries.write();
137        if let Some(last) = bounds.last_mut() {
138            last.samples_written += count;
139        }
140    }
141
142    /// Reset for a new playback session.
143    pub fn reset(&self) {
144        self.boundaries.write().clear();
145        self.samples_written.store(0, Ordering::Relaxed);
146        self.samples_played.store(0, Ordering::Relaxed);
147    }
148
149    /// Get a clone of the samples_played Arc for the audio engine.
150    pub fn samples_played_counter(&self) -> Arc<AtomicU64> {
151        self.samples_played.clone()
152    }
153
154    /// Derive current track info and position from the playback head.
155    /// Called by the UI on every tick.
156    /// Returns (id, path, stream_info, position_ms).
157    ///
158    /// Acquires the boundaries read lock BEFORE reading `samples_played` so
159    /// channels/sample_rate/boundaries are all from a consistent snapshot.
160    /// Without this ordering, a track transition could update the atomics
161    /// after we read `samples_played` but before we read the boundary list.
162    pub fn current_playback(&self) -> Option<(QueueItemId, PathBuf, StreamInfo, u64)> {
163        // Lock first — ensures we see boundaries consistent with the atomic read.
164        let bounds = self.boundaries.read();
165
166        if bounds.is_empty() {
167            return None;
168        }
169
170        // Read samples_played while holding the lock. This guarantees we
171        // never observe a stale boundary list with a newer samples_played
172        // (or vice versa).
173        let played = self.samples_played.load(Ordering::Acquire);
174
175        // Find which track the playback head is in via binary search.
176        // partition_point returns first index where offset > played;
177        // the track we want is one before that.
178        let idx = bounds.partition_point(|b| b.sample_offset <= played);
179        let current = if idx > 0 {
180            &bounds[idx - 1]
181        } else {
182            return None;
183        };
184
185        let ch = current.info.channels as u64;
186        let rate = current.info.sample_rate as u64;
187        if ch == 0 || rate == 0 {
188            return None;
189        }
190
191        // Position within this track: (played - track_start) converted to ms.
192        // Add seek offset since that's where playback started within the track.
193        let track_samples = played.saturating_sub(current.sample_offset);
194        let position_ms =
195            (track_samples / ch) * 1000 / rate + (current.seek_samples / ch) * 1000 / rate;
196
197        Some((
198            current.id,
199            current.path.clone(),
200            current.info.clone(),
201            position_ms,
202        ))
203    }
204}
205
206// ---------------------------------------------------------------------------
207// Source abstraction
208// ---------------------------------------------------------------------------
209
210/// A source entry for the generic decode queue.
211///
212/// Each entry provides an ID, a display path (for logging/timeline),
213/// a format hint, and a factory that constructs a fresh `MediaSourceStream`.
214pub struct SourceEntry {
215    pub id: QueueItemId,
216    /// Path used for logging and `TrackBoundary`. Need not be a real FS path.
217    pub path: PathBuf,
218    /// Format hint for Symphonia (e.g. file extension).
219    pub hint: Hint,
220    /// Factory that creates the `MediaSourceStream`. Called exactly once per track.
221    pub make_mss: Box<dyn FnOnce() -> std::io::Result<MediaSourceStream> + Send>,
222}
223
224impl SourceEntry {
225    /// Convenience: build a `SourceEntry` from a local file path.
226    pub fn from_file(id: QueueItemId, path: PathBuf) -> Self {
227        let ext = path
228            .extension()
229            .and_then(|e| e.to_str())
230            .unwrap_or("")
231            .to_string();
232        let path_clone = path.clone();
233        let mut hint = Hint::new();
234        if !ext.is_empty() {
235            hint.with_extension(&ext);
236        }
237        Self {
238            id,
239            path,
240            hint,
241            make_mss: Box::new(move || {
242                let file = File::open(&path_clone)?;
243                Ok(MediaSourceStream::new(Box::new(file), Default::default()))
244            }),
245        }
246    }
247}
248
249// ---------------------------------------------------------------------------
250// Probe API
251// ---------------------------------------------------------------------------
252
253/// Probe a `MediaSourceStream` (with hint) and return stream info without decoding.
254pub fn probe_source(mss: MediaSourceStream, hint: &Hint) -> Result<StreamInfo, DecodeError> {
255    probe_mss(mss, hint)
256}
257
258/// Probe a file and return stream info without decoding.
259pub fn probe_file(path: &Path) -> Result<StreamInfo, DecodeError> {
260    let file = File::open(path)?;
261    let mss = MediaSourceStream::new(Box::new(file), Default::default());
262    let mut hint = Hint::new();
263    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
264        hint.with_extension(ext);
265    }
266    probe_mss(mss, &hint)
267}
268
269/// Internal: probe a `MediaSourceStream` with a hint.
270fn probe_mss(mss: MediaSourceStream, hint: &Hint) -> Result<StreamInfo, DecodeError> {
271    let probed = symphonia::default::get_probe()
272        .format(
273            hint,
274            mss,
275            &FormatOptions::default(),
276            &MetadataOptions::default(),
277        )
278        .map_err(|e| DecodeError::Decode(e.to_string()))?;
279
280    let reader = probed.format;
281    let track = reader.default_track().ok_or(DecodeError::NoTrack)?;
282    let codec_params = &track.codec_params;
283    let sample_rate = codec_params.sample_rate.unwrap_or(44100);
284    let channels = codec_params.channels.map(|c| c.count() as u16).unwrap_or(2);
285    let bit_depth = codec_params.bits_per_sample.unwrap_or(16) as u16;
286    let duration_ms = track
287        .codec_params
288        .n_frames
289        .map(|frames| frames * 1000 / sample_rate as u64)
290        .unwrap_or(0);
291    let codec = codec_name(codec_params.codec);
292
293    Ok(StreamInfo {
294        codec,
295        sample_rate,
296        channels,
297        bit_depth,
298        duration_ms,
299    })
300}
301
302// ---------------------------------------------------------------------------
303// Generic decode API (SourceEntry-based)
304// ---------------------------------------------------------------------------
305
306/// Start decoding from a `SourceEntry` into the ring buffer.
307///
308/// `first`      — the first track's source entry.
309/// `seek_ms`    — if > 0, seek to this position before decoding the first track.
310/// `next_track` — closure returning the next `SourceEntry` for gapless playback.
311///                Called on EOF. Returns None when the playlist is exhausted.
312#[allow(clippy::too_many_arguments)]
313pub fn start_decode<N, F>(
314    first: SourceEntry,
315    producer: rtrb::Producer<f32>,
316    seek_ms: u64,
317    next_track: N,
318    timeline: Arc<PlaybackTimeline>,
319    viz_buffer: Option<Arc<VizBuffer>>,
320    rg_mode: ReplayGainMode,
321    pre_amp_db: f64,
322    on_finished: F,
323) -> Result<(StreamInfo, DecodeHandle), DecodeError>
324where
325    N: Fn() -> Option<SourceEntry> + Send + 'static,
326    F: FnOnce() + Send + 'static,
327{
328    let stop = Arc::new(AtomicBool::new(false));
329    let stop_clone = stop.clone();
330
331    let thread = thread::Builder::new()
332        .name("koan-decode".into())
333        .spawn(move || {
334            decode_queue_loop(
335                first,
336                producer,
337                &stop_clone,
338                seek_ms,
339                &next_track,
340                &timeline,
341                viz_buffer.as_deref(),
342                rg_mode,
343                pre_amp_db,
344            );
345            // Notify the player that the decode loop finished (playlist
346            // exhausted or error). Only fire if we weren't explicitly stopped
347            // (i.e. this is a natural end, not a seek/skip teardown).
348            if !stop_clone.load(Ordering::Relaxed) {
349                on_finished();
350            }
351        })
352        .map_err(DecodeError::Io)?;
353
354    // Return a placeholder StreamInfo — the real info is pushed to the timeline
355    // by the decode thread immediately after probing the source.
356    let placeholder = StreamInfo {
357        codec: String::from("?"),
358        sample_rate: 44100,
359        channels: 2,
360        bit_depth: 16,
361        duration_ms: 0,
362    };
363
364    Ok((
365        placeholder,
366        DecodeHandle {
367            stop,
368            thread: Some(thread),
369        },
370    ))
371}
372
373// ---------------------------------------------------------------------------
374// File-based convenience wrapper
375// ---------------------------------------------------------------------------
376
377/// Start decoding a file into the ring buffer (convenience wrapper).
378///
379/// `initial_id` — the QueueItemId of the first track.
380/// `seek_ms` — if > 0, seek to this position before decoding the first track.
381/// `next_track` — closure returning the next (id, path) for gapless playback.
382#[allow(clippy::too_many_arguments)]
383pub fn start_decode_file<N, F>(
384    initial_id: QueueItemId,
385    path: &Path,
386    producer: rtrb::Producer<f32>,
387    seek_ms: u64,
388    next_track: N,
389    timeline: Arc<PlaybackTimeline>,
390    viz_buffer: Option<Arc<VizBuffer>>,
391    rg_mode: ReplayGainMode,
392    pre_amp_db: f64,
393    on_finished: F,
394) -> Result<(StreamInfo, DecodeHandle), DecodeError>
395where
396    N: Fn() -> Option<(QueueItemId, PathBuf)> + Send + 'static,
397    F: FnOnce() + Send + 'static,
398{
399    let info = probe_file(path)?;
400    let first = SourceEntry::from_file(initial_id, path.to_path_buf());
401    let (_, handle) = start_decode(
402        first,
403        producer,
404        seek_ms,
405        move || {
406            let (id, p) = next_track()?;
407            Some(SourceEntry::from_file(id, p))
408        },
409        timeline,
410        viz_buffer,
411        rg_mode,
412        pre_amp_db,
413        on_finished,
414    )?;
415    Ok((info, handle))
416}
417
418// ---------------------------------------------------------------------------
419// Internal decode loop
420// ---------------------------------------------------------------------------
421
422/// Gapless decode loop: decode first entry, then call next_track on EOF.
423#[allow(clippy::too_many_arguments)]
424fn decode_queue_loop<N>(
425    first: SourceEntry,
426    mut producer: rtrb::Producer<f32>,
427    stop: &AtomicBool,
428    initial_seek_ms: u64,
429    next_track: &N,
430    timeline: &PlaybackTimeline,
431    viz_buffer: Option<&VizBuffer>,
432    rg_mode: ReplayGainMode,
433    pre_amp_db: f64,
434) where
435    N: Fn() -> Option<SourceEntry>,
436{
437    let path = first.path.clone();
438    let hint = first.hint.clone();
439    let mss = match (first.make_mss)() {
440        Ok(mss) => mss,
441        Err(e) => {
442            if !stop.load(Ordering::Relaxed) {
443                log::error!("failed to open {}: {}", path.display(), e);
444            }
445            return;
446        }
447    };
448
449    if let Err(e) = decode_single(
450        first.id,
451        &path,
452        &hint,
453        mss,
454        &mut producer,
455        stop,
456        initial_seek_ms,
457        timeline,
458        viz_buffer,
459        rg_mode,
460        pre_amp_db,
461    ) {
462        if !stop.load(Ordering::Relaxed) {
463            log::error!("decode error on {}: {}", path.display(), e);
464        }
465        return;
466    }
467
468    while !stop.load(Ordering::Relaxed) {
469        let Some(entry) = (next_track)() else {
470            log::info!("playlist exhausted, decode thread finishing");
471            break;
472        };
473
474        log::info!("gapless transition → {}", entry.path.display());
475        let next_path = entry.path.clone();
476        let next_hint = entry.hint.clone();
477        let next_mss = match (entry.make_mss)() {
478            Ok(mss) => mss,
479            Err(e) => {
480                if !stop.load(Ordering::Relaxed) {
481                    log::error!("failed to open {}: {}", next_path.display(), e);
482                }
483                break;
484            }
485        };
486
487        if let Err(e) = decode_single(
488            entry.id,
489            &next_path,
490            &next_hint,
491            next_mss,
492            &mut producer,
493            stop,
494            0,
495            timeline,
496            viz_buffer,
497            rg_mode,
498            pre_amp_db,
499        ) {
500            if !stop.load(Ordering::Relaxed) {
501                log::error!("decode error on {}: {}", next_path.display(), e);
502            }
503            break;
504        }
505    }
506}
507
508// ---------------------------------------------------------------------------
509// Core decode single track
510// ---------------------------------------------------------------------------
511
512/// Decode a single source into the producer. Returns Ok(()) on clean EOF.
513#[allow(clippy::too_many_arguments)]
514fn decode_single(
515    queue_item_id: QueueItemId,
516    path: &Path,
517    hint: &Hint,
518    mss: MediaSourceStream,
519    producer: &mut rtrb::Producer<f32>,
520    stop: &AtomicBool,
521    seek_ms: u64,
522    timeline: &PlaybackTimeline,
523    viz_buffer: Option<&VizBuffer>,
524    rg_mode: ReplayGainMode,
525    pre_amp_db: f64,
526) -> Result<(), DecodeError> {
527    let format_opts = FormatOptions {
528        enable_gapless: true,
529        ..Default::default()
530    };
531
532    let probed = symphonia::default::get_probe()
533        .format(hint, mss, &format_opts, &MetadataOptions::default())
534        .map_err(|e| DecodeError::Decode(e.to_string()))?;
535
536    let mut reader = probed.format;
537    let track = reader.default_track().ok_or(DecodeError::NoTrack)?;
538    let track_id = track.id;
539    let codec_params = &track.codec_params;
540    let sample_rate = codec_params.sample_rate.unwrap_or(44100);
541    let channels = codec_params.channels.map(|c| c.count() as u16).unwrap_or(2);
542
543    let info = StreamInfo {
544        codec: codec_name(codec_params.codec),
545        sample_rate,
546        channels,
547        bit_depth: codec_params.bits_per_sample.unwrap_or(16) as u16,
548        duration_ms: codec_params
549            .n_frames
550            .map(|f| f * 1000 / sample_rate as u64)
551            .unwrap_or(0),
552    };
553
554    let seek_samples = seek_ms * sample_rate as u64 * channels as u64 / 1000;
555
556    // Record this track's boundary in the timeline.
557    let write_offset = timeline.samples_written.load(Ordering::Relaxed);
558    timeline.push_boundary(TrackBoundary {
559        id: queue_item_id,
560        path: path.to_path_buf(),
561        info,
562        sample_offset: write_offset,
563        samples_written: 0,
564        seek_samples,
565    });
566
567    let mut decoder = symphonia::default::get_codecs()
568        .make(&track.codec_params, &DecoderOptions::default())
569        .map_err(|_| DecodeError::UnsupportedCodec)?;
570
571    // Seek if requested (only for the first track usually).
572    if seek_ms > 0 {
573        let secs = seek_ms / 1000;
574        let frac = (seek_ms % 1000) as f64 / 1000.0;
575        reader
576            .seek(
577                SeekMode::Coarse,
578                SeekTo::Time {
579                    time: Time::new(secs, frac),
580                    track_id: Some(track_id),
581                },
582            )
583            .map_err(|e| DecodeError::Decode(format!("seek failed: {}", e)))?;
584        decoder.reset();
585    }
586
587    // Read ReplayGain tags and select the active gain for this track.
588    let rg_gain = if rg_mode != ReplayGainMode::Off {
589        match crate::audio::replaygain::read_tags(path) {
590            Ok(rg_info) => {
591                let selected = crate::audio::replaygain::select_gain(&rg_info, rg_mode);
592                if let Some((gain_db, _)) = selected {
593                    log::info!(
594                        "replaygain: applying {:.2} dB ({:?}) to {}",
595                        gain_db,
596                        rg_mode,
597                        path.display()
598                    );
599                }
600                selected
601            }
602            Err(e) => {
603                log::debug!("replaygain: no tags for {}: {}", path.display(), e);
604                None
605            }
606        }
607    } else {
608        None
609    };
610    let mut rg_scratch: Vec<f32> = Vec::new();
611
612    let mut sample_buf: Option<SampleBuffer<f32>> = None;
613
614    loop {
615        if stop.load(Ordering::Relaxed) {
616            return Ok(());
617        }
618
619        let packet = match reader.next_packet() {
620            Ok(p) => p,
621            Err(symphonia::core::errors::Error::IoError(ref e))
622                if e.kind() == std::io::ErrorKind::UnexpectedEof =>
623            {
624                return Ok(());
625            }
626            Err(e) => return Err(DecodeError::Decode(e.to_string())),
627        };
628
629        if packet.track_id() != track_id {
630            continue;
631        }
632
633        let decoded = match decoder.decode(&packet) {
634            Ok(d) => d,
635            Err(symphonia::core::errors::Error::DecodeError(e)) => {
636                log::warn!("decode error (skipping packet): {}", e);
637                continue;
638            }
639            Err(e) => return Err(DecodeError::Decode(e.to_string())),
640        };
641
642        let spec = *decoded.spec();
643        let duration = decoded.capacity();
644
645        let sbuf = sample_buf.get_or_insert_with(|| SampleBuffer::new(duration as u64, spec));
646
647        sbuf.copy_interleaved_ref(decoded);
648
649        // Apply ReplayGain if active. Uses a reusable scratch buffer to avoid
650        // allocating per packet. Zero overhead when RG is off.
651        let samples = if let Some((gain_db, peak)) = rg_gain {
652            rg_scratch.clear();
653            rg_scratch.extend_from_slice(sbuf.samples());
654            crate::audio::replaygain::apply_gain(&mut rg_scratch, gain_db, peak, pre_amp_db);
655            &rg_scratch[..]
656        } else {
657            sbuf.samples()
658        };
659
660        // Push samples into ring buffer, blocking if full.
661        // VizBuffer is updated incrementally inside this loop so it receives
662        // samples at the real-time audio consumption rate (paced by the audio
663        // callback draining the rtrb consumer), not in packet-sized bursts.
664        // Without this, FLAC packets (~93ms each at 44.1kHz) would update the
665        // viz buffer only ~11 times/sec, making waveform modes visibly choppy.
666        let mut offset = 0;
667        while offset < samples.len() {
668            if stop.load(Ordering::Relaxed) {
669                return Ok(());
670            }
671
672            let slots = producer.slots();
673            if slots == 0 {
674                thread::sleep(std::time::Duration::from_micros(500));
675                continue;
676            }
677
678            let chunk_size = slots.min(samples.len() - offset);
679            if let Ok(mut chunk) = producer.write_chunk_uninit(chunk_size) {
680                let to_write = &samples[offset..offset + chunk_size];
681                let (first, second) = chunk.as_mut_slices();
682                let first_len = first.len().min(to_write.len());
683                for (slot, &val) in first.iter_mut().zip(&to_write[..first_len]) {
684                    slot.write(val);
685                }
686                if first_len < to_write.len() {
687                    for (slot, &val) in second.iter_mut().zip(&to_write[first_len..]) {
688                        slot.write(val);
689                    }
690                }
691                // SAFETY: All slots in the chunk have been initialized by the
692                // two loops above — first.len() + second.len() == chunk_size,
693                // and every slot is written via MaybeUninit::write().
694                unsafe { chunk.commit_all() };
695
696                // Feed viz buffer at the same rate as rtrb consumption.
697                if let Some(viz) = viz_buffer {
698                    viz.push_samples(to_write, channels, sample_rate);
699                }
700
701                offset += chunk_size;
702            }
703        }
704
705        timeline.add_written(samples.len() as u64);
706    }
707}
708
709pub fn codec_name(codec: CodecType) -> String {
710    match codec {
711        CODEC_TYPE_FLAC => "FLAC",
712        CODEC_TYPE_MP3 => "MP3",
713        CODEC_TYPE_AAC => "AAC",
714        CODEC_TYPE_VORBIS => "Vorbis",
715        CODEC_TYPE_OPUS => "Opus",
716        CODEC_TYPE_ALAC => "ALAC",
717        CODEC_TYPE_WAVPACK => "WavPack",
718        CODEC_TYPE_PCM_S16LE => "PCM/16",
719        CODEC_TYPE_PCM_S24LE => "PCM/24",
720        CODEC_TYPE_PCM_S32LE => "PCM/32",
721        CODEC_TYPE_PCM_F32LE => "PCM/f32",
722        other => return format!("Unknown({:?})", other),
723    }
724    .to_string()
725}
726
727#[cfg(test)]
728mod tests {
729    use std::path::PathBuf;
730    use std::sync::atomic::Ordering;
731
732    use super::*;
733    use crate::player::state::QueueItemId;
734
735    fn make_info(sample_rate: u32, channels: u16) -> StreamInfo {
736        StreamInfo {
737            codec: "FLAC".to_string(),
738            sample_rate,
739            channels,
740            bit_depth: 16,
741            duration_ms: 10_000,
742        }
743    }
744
745    fn make_boundary(
746        id: QueueItemId,
747        sample_offset: u64,
748        seek_samples: u64,
749        channels: u16,
750        sample_rate: u32,
751    ) -> TrackBoundary {
752        TrackBoundary {
753            id,
754            path: PathBuf::from("/music/track.flac"),
755            info: make_info(sample_rate, channels),
756            sample_offset,
757            samples_written: 0,
758            seek_samples,
759        }
760    }
761
762    // --- PlaybackTimeline tests ---
763
764    #[test]
765    fn test_timeline_single_track() {
766        // Push one boundary at offset 0 with stereo 44100 Hz audio.
767        // After simulating 44100 frames (88200 interleaved samples) played,
768        // current_playback() should report track index 0 at position 1000 ms.
769        let timeline = PlaybackTimeline::new();
770        let id = QueueItemId::new();
771        // sample_offset=0, seek_samples=0, channels=2, sample_rate=44100
772        timeline.push_boundary(make_boundary(id, 0, 0, 2, 44100));
773        timeline.add_written(88200); // 1 second of audio
774
775        // Simulate 1 second played: 44100 frames * 2 channels = 88200 interleaved samples
776        timeline.samples_played.store(88200, Ordering::Relaxed);
777
778        let result = timeline.current_playback();
779        assert!(
780            result.is_some(),
781            "expected Some for single track with samples played"
782        );
783        let (result_id, _path, _info, position_ms) = result.unwrap();
784        assert_eq!(result_id, id);
785        assert_eq!(
786            position_ms, 1000,
787            "1 second of 44100 Hz stereo should be 1000 ms"
788        );
789    }
790
791    #[test]
792    fn test_timeline_gapless_transition() {
793        // Two tracks in gapless sequence. Track 1 ends at sample 88200 (1 sec stereo 44100 Hz).
794        // Track 2 begins at sample_offset 88200. When playback head is at 100000 (past the boundary),
795        // current_playback() should report track 2.
796        let timeline = PlaybackTimeline::new();
797        let id1 = QueueItemId::new();
798        let id2 = QueueItemId::new();
799
800        // Track 1: starts at offset 0
801        timeline.push_boundary(make_boundary(id1, 0, 0, 2, 44100));
802        timeline.add_written(88200);
803
804        // Track 2: starts at offset 88200 (immediately after track 1's samples)
805        timeline.push_boundary(make_boundary(id2, 88200, 0, 2, 44100));
806        timeline.add_written(44100); // half a second of track 2
807
808        // Set playback head past the track 1/2 boundary
809        timeline.samples_played.store(90000, Ordering::Relaxed);
810
811        let result = timeline.current_playback();
812        assert!(result.is_some());
813        let (result_id, _path, _info, position_ms) = result.unwrap();
814        assert_eq!(
815            result_id, id2,
816            "playback head past boundary should report second track"
817        );
818        // (90000 - 88200) / 2 channels * 1000 / 44100 = 900 / 44100 ≈ 20 ms
819        assert_eq!(position_ms, 20, "position within track 2 should be ~20 ms");
820    }
821
822    #[test]
823    fn test_timeline_zero_samples() {
824        // With 0 samples played and a boundary at offset 0, current_playback() should
825        // still return the first track at position 0 ms.
826        let timeline = PlaybackTimeline::new();
827        let id = QueueItemId::new();
828        timeline.push_boundary(make_boundary(id, 0, 0, 2, 44100));
829        timeline.add_written(1000);
830        timeline.samples_played.store(0, Ordering::Relaxed);
831
832        let result = timeline.current_playback();
833        assert!(
834            result.is_some(),
835            "expected Some at 0 samples played with a boundary at offset 0"
836        );
837        let (result_id, _path, _info, position_ms) = result.unwrap();
838        assert_eq!(result_id, id);
839        assert_eq!(position_ms, 0);
840    }
841
842    #[test]
843    fn test_timeline_past_all_boundaries() {
844        // When samples_played exceeds all boundaries, the last track should be reported.
845        // The binary search finds the last boundary whose sample_offset <= played.
846        let timeline = PlaybackTimeline::new();
847        let id1 = QueueItemId::new();
848        let id2 = QueueItemId::new();
849
850        timeline.push_boundary(make_boundary(id1, 0, 0, 2, 44100));
851        timeline.add_written(88200);
852        timeline.push_boundary(make_boundary(id2, 88200, 0, 2, 44100));
853        timeline.add_written(88200);
854
855        // Simulate playback far past both tracks
856        timeline
857            .samples_played
858            .store(999_999_999, Ordering::Relaxed);
859
860        let result = timeline.current_playback();
861        assert!(result.is_some());
862        let (result_id, _path, _info, _position_ms) = result.unwrap();
863        assert_eq!(
864            result_id, id2,
865            "samples past all boundaries should report the last track"
866        );
867    }
868
869    #[test]
870    fn test_timeline_seek_offset() {
871        // When a seek offset is set, position_ms should include the seek position.
872        // seek_samples = 88200 means playback started 1 second into the track.
873        // With 0 additional samples played past the boundary, position should be 1000 ms.
874        let timeline = PlaybackTimeline::new();
875        let id = QueueItemId::new();
876        let seek_samples = 88200u64; // 1 second at 44100 Hz stereo
877        timeline.push_boundary(make_boundary(id, 0, seek_samples, 2, 44100));
878        timeline.add_written(44100); // half a second written so far
879        // samples_played at the track boundary (0 frames past the track start)
880        timeline.samples_played.store(0, Ordering::Relaxed);
881
882        let result = timeline.current_playback();
883        assert!(result.is_some());
884        let (_result_id, _path, _info, position_ms) = result.unwrap();
885        // track_samples = 0 - 0 = 0; seek contribution = (88200/2)*1000/44100 = 1000 ms
886        assert_eq!(
887            position_ms, 1000,
888            "position should include seek offset of 1000 ms"
889        );
890    }
891
892    #[test]
893    fn test_timeline_reset() {
894        // After reset(), current_playback() returns None and all counters are cleared.
895        let timeline = PlaybackTimeline::new();
896        let id = QueueItemId::new();
897        timeline.push_boundary(make_boundary(id, 0, 0, 2, 44100));
898        timeline.add_written(88200);
899        timeline.samples_played.store(44100, Ordering::Relaxed);
900
901        // Sanity check: playback is live before reset
902        assert!(timeline.current_playback().is_some());
903
904        timeline.reset();
905
906        assert!(
907            timeline.current_playback().is_none(),
908            "after reset, current_playback should return None"
909        );
910        assert_eq!(
911            timeline.samples_played.load(Ordering::Relaxed),
912            0,
913            "samples_played should be 0 after reset"
914        );
915        assert_eq!(
916            timeline.samples_written.load(Ordering::Relaxed),
917            0,
918            "samples_written should be 0 after reset"
919        );
920    }
921}