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