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