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