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#[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 bitrate_kbps: Option<u32>,
46 pub duration_ms: u64,
47}
48
49pub struct DecodeHandle {
51 stop: Arc<AtomicBool>,
52 thread: Option<thread::JoinHandle<()>>,
53}
54
55impl DecodeHandle {
56 pub fn signal_stop(&self) {
58 self.stop.store(true, Ordering::Relaxed);
59 }
60
61 #[cfg(test)]
63 pub fn new_for_test(stop: Arc<AtomicBool>) -> Self {
64 Self { stop, thread: None }
65 }
66
67 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#[derive(Debug, Clone)]
94pub struct TrackBoundary {
95 pub id: QueueItemId,
96 pub path: PathBuf,
97 pub info: StreamInfo,
98 pub sample_offset: u64,
102 pub samples_written: u64,
105 pub seek_samples: u64,
107}
108
109pub struct PlaybackTimeline {
113 boundaries: parking_lot::RwLock<Vec<TrackBoundary>>,
114 samples_written: AtomicU64,
116 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 fn push_boundary(&self, boundary: TrackBoundary) {
132 self.boundaries.write().push(boundary);
133 }
134
135 fn add_written(&self, count: u64) {
137 self.samples_written.fetch_add(count, Ordering::Relaxed);
138 let mut bounds = self.boundaries.write();
140 if let Some(last) = bounds.last_mut() {
141 last.samples_written += count;
142 }
143 }
144
145 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 pub fn samples_played_counter(&self) -> Arc<AtomicU64> {
154 self.samples_played.clone()
155 }
156
157 pub fn current_playback(&self) -> Option<(QueueItemId, PathBuf, StreamInfo, u64)> {
166 let bounds = self.boundaries.read();
168
169 if bounds.is_empty() {
170 return None;
171 }
172
173 let played = self.samples_played.load(Ordering::Acquire);
177
178 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 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
209pub struct SourceEntry {
218 pub id: QueueItemId,
219 pub path: PathBuf,
221 pub hint: Hint,
223 pub make_mss: Box<dyn FnOnce() -> std::io::Result<MediaSourceStream> + Send>,
225}
226
227impl SourceEntry {
228 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
252pub fn probe_source(mss: MediaSourceStream, hint: &Hint) -> Result<StreamInfo, DecodeError> {
258 probe_mss(mss, hint)
259}
260
261pub 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 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
283fn 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 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 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#[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 if !stop_clone.load(Ordering::Relaxed) {
380 on_finished();
381 }
382 })
383 .map_err(DecodeError::Io)?;
384
385 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#[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#[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#[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 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 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 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 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 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 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 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 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 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 unsafe { chunk.commit_all() };
783
784 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
797fn 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 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 #[test]
878 fn test_timeline_single_track() {
879 let timeline = PlaybackTimeline::new();
883 let id = QueueItemId::new();
884 timeline.push_boundary(make_boundary(id, 0, 0, 2, 44100));
886 timeline.add_written(88200); 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 let timeline = PlaybackTimeline::new();
910 let id1 = QueueItemId::new();
911 let id2 = QueueItemId::new();
912
913 timeline.push_boundary(make_boundary(id1, 0, 0, 2, 44100));
915 timeline.add_written(88200);
916
917 timeline.push_boundary(make_boundary(id2, 88200, 0, 2, 44100));
919 timeline.add_written(44100); 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 assert_eq!(position_ms, 20, "position within track 2 should be ~20 ms");
933 }
934
935 #[test]
936 fn test_timeline_zero_samples() {
937 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 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 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 let timeline = PlaybackTimeline::new();
988 let id = QueueItemId::new();
989 let seek_samples = 88200u64; timeline.push_boundary(make_boundary(id, 0, seek_samples, 2, 44100));
991 timeline.add_written(44100); 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 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 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 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 #[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 crate::test_utils::generate_wav_tone(&wav_path, 44100, 440.0, 0.1);
1065
1066 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 let available = consumer.slots();
1094 assert!(available > 0, "expected samples in ring buffer, got 0");
1095
1096 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}