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>(
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) -> Result<(StreamInfo, DecodeHandle), DecodeError>
323where
324    N: Fn() -> Option<SourceEntry> + Send + 'static,
325{
326    let stop = Arc::new(AtomicBool::new(false));
327    let stop_clone = stop.clone();
328
329    let thread = thread::Builder::new()
330        .name("koan-decode".into())
331        .spawn(move || {
332            decode_queue_loop(
333                first,
334                producer,
335                &stop_clone,
336                seek_ms,
337                &next_track,
338                &timeline,
339                viz_buffer.as_deref(),
340                rg_mode,
341                pre_amp_db,
342            );
343        })
344        .map_err(DecodeError::Io)?;
345
346    // Return a placeholder StreamInfo — the real info is pushed to the timeline
347    // by the decode thread immediately after probing the source.
348    let placeholder = StreamInfo {
349        codec: String::from("?"),
350        sample_rate: 44100,
351        channels: 2,
352        bit_depth: 16,
353        duration_ms: 0,
354    };
355
356    Ok((
357        placeholder,
358        DecodeHandle {
359            stop,
360            thread: Some(thread),
361        },
362    ))
363}
364
365// ---------------------------------------------------------------------------
366// File-based convenience wrapper
367// ---------------------------------------------------------------------------
368
369/// Start decoding a file into the ring buffer (convenience wrapper).
370///
371/// `initial_id` — the QueueItemId of the first track.
372/// `seek_ms` — if > 0, seek to this position before decoding the first track.
373/// `next_track` — closure returning the next (id, path) for gapless playback.
374#[allow(clippy::too_many_arguments)]
375pub fn start_decode_file<N>(
376    initial_id: QueueItemId,
377    path: &Path,
378    producer: rtrb::Producer<f32>,
379    seek_ms: u64,
380    next_track: N,
381    timeline: Arc<PlaybackTimeline>,
382    viz_buffer: Option<Arc<VizBuffer>>,
383    rg_mode: ReplayGainMode,
384    pre_amp_db: f64,
385) -> Result<(StreamInfo, DecodeHandle), DecodeError>
386where
387    N: Fn() -> Option<(QueueItemId, PathBuf)> + Send + 'static,
388{
389    let info = probe_file(path)?;
390    let first = SourceEntry::from_file(initial_id, path.to_path_buf());
391    let (_, handle) = start_decode(
392        first,
393        producer,
394        seek_ms,
395        move || {
396            let (id, p) = next_track()?;
397            Some(SourceEntry::from_file(id, p))
398        },
399        timeline,
400        viz_buffer,
401        rg_mode,
402        pre_amp_db,
403    )?;
404    Ok((info, handle))
405}
406
407// ---------------------------------------------------------------------------
408// Internal decode loop
409// ---------------------------------------------------------------------------
410
411/// Gapless decode loop: decode first entry, then call next_track on EOF.
412#[allow(clippy::too_many_arguments)]
413fn decode_queue_loop<N>(
414    first: SourceEntry,
415    mut producer: rtrb::Producer<f32>,
416    stop: &AtomicBool,
417    initial_seek_ms: u64,
418    next_track: &N,
419    timeline: &PlaybackTimeline,
420    viz_buffer: Option<&VizBuffer>,
421    rg_mode: ReplayGainMode,
422    pre_amp_db: f64,
423) where
424    N: Fn() -> Option<SourceEntry>,
425{
426    let path = first.path.clone();
427    let hint = first.hint.clone();
428    let mss = match (first.make_mss)() {
429        Ok(mss) => mss,
430        Err(e) => {
431            if !stop.load(Ordering::Relaxed) {
432                log::error!("failed to open {}: {}", path.display(), e);
433            }
434            return;
435        }
436    };
437
438    if let Err(e) = decode_single(
439        first.id,
440        &path,
441        &hint,
442        mss,
443        &mut producer,
444        stop,
445        initial_seek_ms,
446        timeline,
447        viz_buffer,
448        rg_mode,
449        pre_amp_db,
450    ) {
451        if !stop.load(Ordering::Relaxed) {
452            log::error!("decode error on {}: {}", path.display(), e);
453        }
454        return;
455    }
456
457    while !stop.load(Ordering::Relaxed) {
458        let Some(entry) = (next_track)() else {
459            log::info!("playlist exhausted, decode thread finishing");
460            break;
461        };
462
463        log::info!("gapless transition → {}", entry.path.display());
464        let next_path = entry.path.clone();
465        let next_hint = entry.hint.clone();
466        let next_mss = match (entry.make_mss)() {
467            Ok(mss) => mss,
468            Err(e) => {
469                if !stop.load(Ordering::Relaxed) {
470                    log::error!("failed to open {}: {}", next_path.display(), e);
471                }
472                break;
473            }
474        };
475
476        if let Err(e) = decode_single(
477            entry.id,
478            &next_path,
479            &next_hint,
480            next_mss,
481            &mut producer,
482            stop,
483            0,
484            timeline,
485            viz_buffer,
486            rg_mode,
487            pre_amp_db,
488        ) {
489            if !stop.load(Ordering::Relaxed) {
490                log::error!("decode error on {}: {}", next_path.display(), e);
491            }
492            break;
493        }
494    }
495}
496
497// ---------------------------------------------------------------------------
498// Core decode single track
499// ---------------------------------------------------------------------------
500
501/// Decode a single source into the producer. Returns Ok(()) on clean EOF.
502#[allow(clippy::too_many_arguments)]
503fn decode_single(
504    queue_item_id: QueueItemId,
505    path: &Path,
506    hint: &Hint,
507    mss: MediaSourceStream,
508    producer: &mut rtrb::Producer<f32>,
509    stop: &AtomicBool,
510    seek_ms: u64,
511    timeline: &PlaybackTimeline,
512    viz_buffer: Option<&VizBuffer>,
513    rg_mode: ReplayGainMode,
514    pre_amp_db: f64,
515) -> Result<(), DecodeError> {
516    let format_opts = FormatOptions {
517        enable_gapless: true,
518        ..Default::default()
519    };
520
521    let probed = symphonia::default::get_probe()
522        .format(hint, mss, &format_opts, &MetadataOptions::default())
523        .map_err(|e| DecodeError::Decode(e.to_string()))?;
524
525    let mut reader = probed.format;
526    let track = reader.default_track().ok_or(DecodeError::NoTrack)?;
527    let track_id = track.id;
528    let codec_params = &track.codec_params;
529    let sample_rate = codec_params.sample_rate.unwrap_or(44100);
530    let channels = codec_params.channels.map(|c| c.count() as u16).unwrap_or(2);
531
532    let info = StreamInfo {
533        codec: codec_name(codec_params.codec),
534        sample_rate,
535        channels,
536        bit_depth: codec_params.bits_per_sample.unwrap_or(16) as u16,
537        duration_ms: codec_params
538            .n_frames
539            .map(|f| f * 1000 / sample_rate as u64)
540            .unwrap_or(0),
541    };
542
543    let seek_samples = seek_ms * sample_rate as u64 * channels as u64 / 1000;
544
545    // Record this track's boundary in the timeline.
546    let write_offset = timeline.samples_written.load(Ordering::Relaxed);
547    timeline.push_boundary(TrackBoundary {
548        id: queue_item_id,
549        path: path.to_path_buf(),
550        info,
551        sample_offset: write_offset,
552        samples_written: 0,
553        seek_samples,
554    });
555
556    let mut decoder = symphonia::default::get_codecs()
557        .make(&track.codec_params, &DecoderOptions::default())
558        .map_err(|_| DecodeError::UnsupportedCodec)?;
559
560    // Seek if requested (only for the first track usually).
561    if seek_ms > 0 {
562        let secs = seek_ms / 1000;
563        let frac = (seek_ms % 1000) as f64 / 1000.0;
564        reader
565            .seek(
566                SeekMode::Coarse,
567                SeekTo::Time {
568                    time: Time::new(secs, frac),
569                    track_id: Some(track_id),
570                },
571            )
572            .map_err(|e| DecodeError::Decode(format!("seek failed: {}", e)))?;
573        decoder.reset();
574    }
575
576    // Read ReplayGain tags and select the active gain for this track.
577    let rg_gain = if rg_mode != ReplayGainMode::Off {
578        match crate::audio::replaygain::read_tags(path) {
579            Ok(rg_info) => {
580                let selected = crate::audio::replaygain::select_gain(&rg_info, rg_mode);
581                if let Some((gain_db, _)) = selected {
582                    log::info!(
583                        "replaygain: applying {:.2} dB ({:?}) to {}",
584                        gain_db,
585                        rg_mode,
586                        path.display()
587                    );
588                }
589                selected
590            }
591            Err(e) => {
592                log::debug!("replaygain: no tags for {}: {}", path.display(), e);
593                None
594            }
595        }
596    } else {
597        None
598    };
599    let mut rg_scratch: Vec<f32> = Vec::new();
600
601    let mut sample_buf: Option<SampleBuffer<f32>> = None;
602
603    loop {
604        if stop.load(Ordering::Relaxed) {
605            return Ok(());
606        }
607
608        let packet = match reader.next_packet() {
609            Ok(p) => p,
610            Err(symphonia::core::errors::Error::IoError(ref e))
611                if e.kind() == std::io::ErrorKind::UnexpectedEof =>
612            {
613                return Ok(());
614            }
615            Err(e) => return Err(DecodeError::Decode(e.to_string())),
616        };
617
618        if packet.track_id() != track_id {
619            continue;
620        }
621
622        let decoded = match decoder.decode(&packet) {
623            Ok(d) => d,
624            Err(symphonia::core::errors::Error::DecodeError(e)) => {
625                log::warn!("decode error (skipping packet): {}", e);
626                continue;
627            }
628            Err(e) => return Err(DecodeError::Decode(e.to_string())),
629        };
630
631        let spec = *decoded.spec();
632        let duration = decoded.capacity();
633
634        let sbuf = sample_buf.get_or_insert_with(|| SampleBuffer::new(duration as u64, spec));
635
636        sbuf.copy_interleaved_ref(decoded);
637
638        // Apply ReplayGain if active. Uses a reusable scratch buffer to avoid
639        // allocating per packet. Zero overhead when RG is off.
640        let samples = if let Some((gain_db, peak)) = rg_gain {
641            rg_scratch.clear();
642            rg_scratch.extend_from_slice(sbuf.samples());
643            crate::audio::replaygain::apply_gain(&mut rg_scratch, gain_db, peak, pre_amp_db);
644            &rg_scratch[..]
645        } else {
646            sbuf.samples()
647        };
648
649        // Copy decoded samples to the visualization buffer (if attached).
650        if let Some(viz) = viz_buffer {
651            viz.push_samples(samples, channels, sample_rate);
652        }
653
654        // Push samples into ring buffer, blocking if full.
655        let mut offset = 0;
656        while offset < samples.len() {
657            if stop.load(Ordering::Relaxed) {
658                return Ok(());
659            }
660
661            let slots = producer.slots();
662            if slots == 0 {
663                thread::sleep(std::time::Duration::from_micros(500));
664                continue;
665            }
666
667            let chunk_size = slots.min(samples.len() - offset);
668            if let Ok(mut chunk) = producer.write_chunk_uninit(chunk_size) {
669                let to_write = &samples[offset..offset + chunk_size];
670                let (first, second) = chunk.as_mut_slices();
671                let first_len = first.len().min(to_write.len());
672                for (slot, &val) in first.iter_mut().zip(&to_write[..first_len]) {
673                    slot.write(val);
674                }
675                if first_len < to_write.len() {
676                    for (slot, &val) in second.iter_mut().zip(&to_write[first_len..]) {
677                        slot.write(val);
678                    }
679                }
680                // SAFETY: All slots in the chunk have been initialized by the
681                // two loops above — first.len() + second.len() == chunk_size,
682                // and every slot is written via MaybeUninit::write().
683                unsafe { chunk.commit_all() };
684                offset += chunk_size;
685            }
686        }
687
688        timeline.add_written(samples.len() as u64);
689    }
690}
691
692pub fn codec_name(codec: CodecType) -> String {
693    match codec {
694        CODEC_TYPE_FLAC => "FLAC",
695        CODEC_TYPE_MP3 => "MP3",
696        CODEC_TYPE_AAC => "AAC",
697        CODEC_TYPE_VORBIS => "Vorbis",
698        CODEC_TYPE_OPUS => "Opus",
699        CODEC_TYPE_ALAC => "ALAC",
700        CODEC_TYPE_WAVPACK => "WavPack",
701        CODEC_TYPE_PCM_S16LE => "PCM/16",
702        CODEC_TYPE_PCM_S24LE => "PCM/24",
703        CODEC_TYPE_PCM_S32LE => "PCM/32",
704        CODEC_TYPE_PCM_F32LE => "PCM/f32",
705        other => return format!("Unknown({:?})", other),
706    }
707    .to_string()
708}
709
710#[cfg(test)]
711mod tests {
712    use std::path::PathBuf;
713    use std::sync::atomic::Ordering;
714
715    use super::*;
716    use crate::player::state::QueueItemId;
717
718    fn make_info(sample_rate: u32, channels: u16) -> StreamInfo {
719        StreamInfo {
720            codec: "FLAC".to_string(),
721            sample_rate,
722            channels,
723            bit_depth: 16,
724            duration_ms: 10_000,
725        }
726    }
727
728    fn make_boundary(
729        id: QueueItemId,
730        sample_offset: u64,
731        seek_samples: u64,
732        channels: u16,
733        sample_rate: u32,
734    ) -> TrackBoundary {
735        TrackBoundary {
736            id,
737            path: PathBuf::from("/music/track.flac"),
738            info: make_info(sample_rate, channels),
739            sample_offset,
740            samples_written: 0,
741            seek_samples,
742        }
743    }
744
745    // --- PlaybackTimeline tests ---
746
747    #[test]
748    fn test_timeline_single_track() {
749        // Push one boundary at offset 0 with stereo 44100 Hz audio.
750        // After simulating 44100 frames (88200 interleaved samples) played,
751        // current_playback() should report track index 0 at position 1000 ms.
752        let timeline = PlaybackTimeline::new();
753        let id = QueueItemId::new();
754        // sample_offset=0, seek_samples=0, channels=2, sample_rate=44100
755        timeline.push_boundary(make_boundary(id, 0, 0, 2, 44100));
756        timeline.add_written(88200); // 1 second of audio
757
758        // Simulate 1 second played: 44100 frames * 2 channels = 88200 interleaved samples
759        timeline.samples_played.store(88200, Ordering::Relaxed);
760
761        let result = timeline.current_playback();
762        assert!(
763            result.is_some(),
764            "expected Some for single track with samples played"
765        );
766        let (result_id, _path, _info, position_ms) = result.unwrap();
767        assert_eq!(result_id, id);
768        assert_eq!(
769            position_ms, 1000,
770            "1 second of 44100 Hz stereo should be 1000 ms"
771        );
772    }
773
774    #[test]
775    fn test_timeline_gapless_transition() {
776        // Two tracks in gapless sequence. Track 1 ends at sample 88200 (1 sec stereo 44100 Hz).
777        // Track 2 begins at sample_offset 88200. When playback head is at 100000 (past the boundary),
778        // current_playback() should report track 2.
779        let timeline = PlaybackTimeline::new();
780        let id1 = QueueItemId::new();
781        let id2 = QueueItemId::new();
782
783        // Track 1: starts at offset 0
784        timeline.push_boundary(make_boundary(id1, 0, 0, 2, 44100));
785        timeline.add_written(88200);
786
787        // Track 2: starts at offset 88200 (immediately after track 1's samples)
788        timeline.push_boundary(make_boundary(id2, 88200, 0, 2, 44100));
789        timeline.add_written(44100); // half a second of track 2
790
791        // Set playback head past the track 1/2 boundary
792        timeline.samples_played.store(90000, Ordering::Relaxed);
793
794        let result = timeline.current_playback();
795        assert!(result.is_some());
796        let (result_id, _path, _info, position_ms) = result.unwrap();
797        assert_eq!(
798            result_id, id2,
799            "playback head past boundary should report second track"
800        );
801        // (90000 - 88200) / 2 channels * 1000 / 44100 = 900 / 44100 ≈ 20 ms
802        assert_eq!(position_ms, 20, "position within track 2 should be ~20 ms");
803    }
804
805    #[test]
806    fn test_timeline_zero_samples() {
807        // With 0 samples played and a boundary at offset 0, current_playback() should
808        // still return the first track at position 0 ms.
809        let timeline = PlaybackTimeline::new();
810        let id = QueueItemId::new();
811        timeline.push_boundary(make_boundary(id, 0, 0, 2, 44100));
812        timeline.add_written(1000);
813        timeline.samples_played.store(0, Ordering::Relaxed);
814
815        let result = timeline.current_playback();
816        assert!(
817            result.is_some(),
818            "expected Some at 0 samples played with a boundary at offset 0"
819        );
820        let (result_id, _path, _info, position_ms) = result.unwrap();
821        assert_eq!(result_id, id);
822        assert_eq!(position_ms, 0);
823    }
824
825    #[test]
826    fn test_timeline_past_all_boundaries() {
827        // When samples_played exceeds all boundaries, the last track should be reported.
828        // The binary search finds the last boundary whose sample_offset <= played.
829        let timeline = PlaybackTimeline::new();
830        let id1 = QueueItemId::new();
831        let id2 = QueueItemId::new();
832
833        timeline.push_boundary(make_boundary(id1, 0, 0, 2, 44100));
834        timeline.add_written(88200);
835        timeline.push_boundary(make_boundary(id2, 88200, 0, 2, 44100));
836        timeline.add_written(88200);
837
838        // Simulate playback far past both tracks
839        timeline
840            .samples_played
841            .store(999_999_999, Ordering::Relaxed);
842
843        let result = timeline.current_playback();
844        assert!(result.is_some());
845        let (result_id, _path, _info, _position_ms) = result.unwrap();
846        assert_eq!(
847            result_id, id2,
848            "samples past all boundaries should report the last track"
849        );
850    }
851
852    #[test]
853    fn test_timeline_seek_offset() {
854        // When a seek offset is set, position_ms should include the seek position.
855        // seek_samples = 88200 means playback started 1 second into the track.
856        // With 0 additional samples played past the boundary, position should be 1000 ms.
857        let timeline = PlaybackTimeline::new();
858        let id = QueueItemId::new();
859        let seek_samples = 88200u64; // 1 second at 44100 Hz stereo
860        timeline.push_boundary(make_boundary(id, 0, seek_samples, 2, 44100));
861        timeline.add_written(44100); // half a second written so far
862        // samples_played at the track boundary (0 frames past the track start)
863        timeline.samples_played.store(0, Ordering::Relaxed);
864
865        let result = timeline.current_playback();
866        assert!(result.is_some());
867        let (_result_id, _path, _info, position_ms) = result.unwrap();
868        // track_samples = 0 - 0 = 0; seek contribution = (88200/2)*1000/44100 = 1000 ms
869        assert_eq!(
870            position_ms, 1000,
871            "position should include seek offset of 1000 ms"
872        );
873    }
874
875    #[test]
876    fn test_timeline_reset() {
877        // After reset(), current_playback() returns None and all counters are cleared.
878        let timeline = PlaybackTimeline::new();
879        let id = QueueItemId::new();
880        timeline.push_boundary(make_boundary(id, 0, 0, 2, 44100));
881        timeline.add_written(88200);
882        timeline.samples_played.store(44100, Ordering::Relaxed);
883
884        // Sanity check: playback is live before reset
885        assert!(timeline.current_playback().is_some());
886
887        timeline.reset();
888
889        assert!(
890            timeline.current_playback().is_none(),
891            "after reset, current_playback should return None"
892        );
893        assert_eq!(
894            timeline.samples_played.load(Ordering::Relaxed),
895            0,
896            "samples_played should be 0 after reset"
897        );
898        assert_eq!(
899            timeline.samples_written.load(Ordering::Relaxed),
900            0,
901            "samples_written should be 0 after reset"
902        );
903    }
904}