1use std::fs::File;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
5use std::thread;
6
7use symphonia::core::audio::SampleBuffer;
8use symphonia::core::codecs::{
9 CODEC_TYPE_AAC, CODEC_TYPE_ALAC, CODEC_TYPE_FLAC, CODEC_TYPE_MP3, CODEC_TYPE_OPUS,
10 CODEC_TYPE_PCM_F32LE, CODEC_TYPE_PCM_S16LE, CODEC_TYPE_PCM_S24LE, CODEC_TYPE_PCM_S32LE,
11 CODEC_TYPE_VORBIS, CODEC_TYPE_WAVPACK, CodecType, DecoderOptions,
12};
13use symphonia::core::formats::{FormatOptions, SeekMode, SeekTo};
14use symphonia::core::io::MediaSourceStream;
15use symphonia::core::meta::MetadataOptions;
16use symphonia::core::probe::Hint;
17use symphonia::core::units::Time;
18use thiserror::Error;
19
20use crate::audio::viz::VizBuffer;
21use crate::config::ReplayGainMode;
22use crate::player::state::QueueItemId;
23
24#[derive(Debug, Error)]
25pub enum DecodeError {
26 #[error("failed to open file: {0}")]
27 Io(#[from] std::io::Error),
28 #[error("no supported audio track found")]
29 NoTrack,
30 #[error("unsupported codec")]
31 UnsupportedCodec,
32 #[error("decode error: {0}")]
33 Decode(String),
34}
35
36#[derive(Debug, Clone)]
38pub struct StreamInfo {
39 pub codec: String,
40 pub sample_rate: u32,
41 pub channels: u16,
42 pub bit_depth: u16,
43 pub duration_ms: u64,
44}
45
46pub struct DecodeHandle {
48 stop: Arc<AtomicBool>,
49 thread: Option<thread::JoinHandle<()>>,
50}
51
52impl DecodeHandle {
53 pub fn signal_stop(&self) {
55 self.stop.store(true, Ordering::Relaxed);
56 }
57
58 #[cfg(test)]
60 pub fn new_for_test(stop: Arc<AtomicBool>) -> Self {
61 Self { stop, thread: None }
62 }
63
64 pub fn stop(&mut self) {
66 self.signal_stop();
67 if let Some(handle) = self.thread.take()
68 && let Err(payload) = handle.join()
69 {
70 let msg = payload
71 .downcast_ref::<String>()
72 .map(|s| s.as_str())
73 .or_else(|| payload.downcast_ref::<&str>().copied())
74 .unwrap_or("unknown");
75 log::error!("decode thread panicked: {}", msg);
76 }
77 }
78}
79
80impl Drop for DecodeHandle {
81 fn drop(&mut self) {
82 self.stop();
83 }
84}
85
86#[derive(Debug, Clone)]
91pub struct TrackBoundary {
92 pub id: QueueItemId,
93 pub path: PathBuf,
94 pub info: StreamInfo,
95 pub sample_offset: u64,
99 pub samples_written: u64,
102 pub seek_samples: u64,
104}
105
106pub struct PlaybackTimeline {
110 boundaries: parking_lot::RwLock<Vec<TrackBoundary>>,
111 samples_written: AtomicU64,
113 pub samples_played: Arc<AtomicU64>,
116}
117
118impl PlaybackTimeline {
119 pub fn new() -> Arc<Self> {
120 Arc::new(Self {
121 boundaries: parking_lot::RwLock::new(Vec::new()),
122 samples_written: AtomicU64::new(0),
123 samples_played: Arc::new(AtomicU64::new(0)),
124 })
125 }
126
127 fn push_boundary(&self, boundary: TrackBoundary) {
129 self.boundaries.write().push(boundary);
130 }
131
132 fn add_written(&self, count: u64) {
134 self.samples_written.fetch_add(count, Ordering::Relaxed);
135 let mut bounds = self.boundaries.write();
137 if let Some(last) = bounds.last_mut() {
138 last.samples_written += count;
139 }
140 }
141
142 pub fn reset(&self) {
144 self.boundaries.write().clear();
145 self.samples_written.store(0, Ordering::Relaxed);
146 self.samples_played.store(0, Ordering::Relaxed);
147 }
148
149 pub fn samples_played_counter(&self) -> Arc<AtomicU64> {
151 self.samples_played.clone()
152 }
153
154 pub fn current_playback(&self) -> Option<(QueueItemId, PathBuf, StreamInfo, u64)> {
163 let bounds = self.boundaries.read();
165
166 if bounds.is_empty() {
167 return None;
168 }
169
170 let played = self.samples_played.load(Ordering::Acquire);
174
175 let idx = bounds.partition_point(|b| b.sample_offset <= played);
179 let current = if idx > 0 {
180 &bounds[idx - 1]
181 } else {
182 return None;
183 };
184
185 let ch = current.info.channels as u64;
186 let rate = current.info.sample_rate as u64;
187 if ch == 0 || rate == 0 {
188 return None;
189 }
190
191 let track_samples = played.saturating_sub(current.sample_offset);
194 let position_ms =
195 (track_samples / ch) * 1000 / rate + (current.seek_samples / ch) * 1000 / rate;
196
197 Some((
198 current.id,
199 current.path.clone(),
200 current.info.clone(),
201 position_ms,
202 ))
203 }
204}
205
206pub struct SourceEntry {
215 pub id: QueueItemId,
216 pub path: PathBuf,
218 pub hint: Hint,
220 pub make_mss: Box<dyn FnOnce() -> std::io::Result<MediaSourceStream> + Send>,
222}
223
224impl SourceEntry {
225 pub fn from_file(id: QueueItemId, path: PathBuf) -> Self {
227 let ext = path
228 .extension()
229 .and_then(|e| e.to_str())
230 .unwrap_or("")
231 .to_string();
232 let path_clone = path.clone();
233 let mut hint = Hint::new();
234 if !ext.is_empty() {
235 hint.with_extension(&ext);
236 }
237 Self {
238 id,
239 path,
240 hint,
241 make_mss: Box::new(move || {
242 let file = File::open(&path_clone)?;
243 Ok(MediaSourceStream::new(Box::new(file), Default::default()))
244 }),
245 }
246 }
247}
248
249pub fn probe_source(mss: MediaSourceStream, hint: &Hint) -> Result<StreamInfo, DecodeError> {
255 probe_mss(mss, hint)
256}
257
258pub fn probe_file(path: &Path) -> Result<StreamInfo, DecodeError> {
260 let file = File::open(path)?;
261 let mss = MediaSourceStream::new(Box::new(file), Default::default());
262 let mut hint = Hint::new();
263 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
264 hint.with_extension(ext);
265 }
266 probe_mss(mss, &hint)
267}
268
269fn probe_mss(mss: MediaSourceStream, hint: &Hint) -> Result<StreamInfo, DecodeError> {
271 let probed = symphonia::default::get_probe()
272 .format(
273 hint,
274 mss,
275 &FormatOptions::default(),
276 &MetadataOptions::default(),
277 )
278 .map_err(|e| DecodeError::Decode(e.to_string()))?;
279
280 let reader = probed.format;
281 let track = reader.default_track().ok_or(DecodeError::NoTrack)?;
282 let codec_params = &track.codec_params;
283 let sample_rate = codec_params.sample_rate.unwrap_or(44100);
284 let channels = codec_params.channels.map(|c| c.count() as u16).unwrap_or(2);
285 let bit_depth = codec_params.bits_per_sample.unwrap_or(16) as u16;
286 let duration_ms = track
287 .codec_params
288 .n_frames
289 .map(|frames| frames * 1000 / sample_rate as u64)
290 .unwrap_or(0);
291 let codec = codec_name(codec_params.codec);
292
293 Ok(StreamInfo {
294 codec,
295 sample_rate,
296 channels,
297 bit_depth,
298 duration_ms,
299 })
300}
301
302#[allow(clippy::too_many_arguments)]
313pub fn start_decode<N>(
314 first: SourceEntry,
315 producer: rtrb::Producer<f32>,
316 seek_ms: u64,
317 next_track: N,
318 timeline: Arc<PlaybackTimeline>,
319 viz_buffer: Option<Arc<VizBuffer>>,
320 rg_mode: ReplayGainMode,
321 pre_amp_db: f64,
322) -> Result<(StreamInfo, DecodeHandle), DecodeError>
323where
324 N: Fn() -> Option<SourceEntry> + Send + 'static,
325{
326 let stop = Arc::new(AtomicBool::new(false));
327 let stop_clone = stop.clone();
328
329 let thread = thread::Builder::new()
330 .name("koan-decode".into())
331 .spawn(move || {
332 decode_queue_loop(
333 first,
334 producer,
335 &stop_clone,
336 seek_ms,
337 &next_track,
338 &timeline,
339 viz_buffer.as_deref(),
340 rg_mode,
341 pre_amp_db,
342 );
343 })
344 .map_err(DecodeError::Io)?;
345
346 let placeholder = StreamInfo {
349 codec: String::from("?"),
350 sample_rate: 44100,
351 channels: 2,
352 bit_depth: 16,
353 duration_ms: 0,
354 };
355
356 Ok((
357 placeholder,
358 DecodeHandle {
359 stop,
360 thread: Some(thread),
361 },
362 ))
363}
364
365#[allow(clippy::too_many_arguments)]
375pub fn start_decode_file<N>(
376 initial_id: QueueItemId,
377 path: &Path,
378 producer: rtrb::Producer<f32>,
379 seek_ms: u64,
380 next_track: N,
381 timeline: Arc<PlaybackTimeline>,
382 viz_buffer: Option<Arc<VizBuffer>>,
383 rg_mode: ReplayGainMode,
384 pre_amp_db: f64,
385) -> Result<(StreamInfo, DecodeHandle), DecodeError>
386where
387 N: Fn() -> Option<(QueueItemId, PathBuf)> + Send + 'static,
388{
389 let info = probe_file(path)?;
390 let first = SourceEntry::from_file(initial_id, path.to_path_buf());
391 let (_, handle) = start_decode(
392 first,
393 producer,
394 seek_ms,
395 move || {
396 let (id, p) = next_track()?;
397 Some(SourceEntry::from_file(id, p))
398 },
399 timeline,
400 viz_buffer,
401 rg_mode,
402 pre_amp_db,
403 )?;
404 Ok((info, handle))
405}
406
407#[allow(clippy::too_many_arguments)]
413fn decode_queue_loop<N>(
414 first: SourceEntry,
415 mut producer: rtrb::Producer<f32>,
416 stop: &AtomicBool,
417 initial_seek_ms: u64,
418 next_track: &N,
419 timeline: &PlaybackTimeline,
420 viz_buffer: Option<&VizBuffer>,
421 rg_mode: ReplayGainMode,
422 pre_amp_db: f64,
423) where
424 N: Fn() -> Option<SourceEntry>,
425{
426 let path = first.path.clone();
427 let hint = first.hint.clone();
428 let mss = match (first.make_mss)() {
429 Ok(mss) => mss,
430 Err(e) => {
431 if !stop.load(Ordering::Relaxed) {
432 log::error!("failed to open {}: {}", path.display(), e);
433 }
434 return;
435 }
436 };
437
438 if let Err(e) = decode_single(
439 first.id,
440 &path,
441 &hint,
442 mss,
443 &mut producer,
444 stop,
445 initial_seek_ms,
446 timeline,
447 viz_buffer,
448 rg_mode,
449 pre_amp_db,
450 ) {
451 if !stop.load(Ordering::Relaxed) {
452 log::error!("decode error on {}: {}", path.display(), e);
453 }
454 return;
455 }
456
457 while !stop.load(Ordering::Relaxed) {
458 let Some(entry) = (next_track)() else {
459 log::info!("playlist exhausted, decode thread finishing");
460 break;
461 };
462
463 log::info!("gapless transition → {}", entry.path.display());
464 let next_path = entry.path.clone();
465 let next_hint = entry.hint.clone();
466 let next_mss = match (entry.make_mss)() {
467 Ok(mss) => mss,
468 Err(e) => {
469 if !stop.load(Ordering::Relaxed) {
470 log::error!("failed to open {}: {}", next_path.display(), e);
471 }
472 break;
473 }
474 };
475
476 if let Err(e) = decode_single(
477 entry.id,
478 &next_path,
479 &next_hint,
480 next_mss,
481 &mut producer,
482 stop,
483 0,
484 timeline,
485 viz_buffer,
486 rg_mode,
487 pre_amp_db,
488 ) {
489 if !stop.load(Ordering::Relaxed) {
490 log::error!("decode error on {}: {}", next_path.display(), e);
491 }
492 break;
493 }
494 }
495}
496
497#[allow(clippy::too_many_arguments)]
503fn decode_single(
504 queue_item_id: QueueItemId,
505 path: &Path,
506 hint: &Hint,
507 mss: MediaSourceStream,
508 producer: &mut rtrb::Producer<f32>,
509 stop: &AtomicBool,
510 seek_ms: u64,
511 timeline: &PlaybackTimeline,
512 viz_buffer: Option<&VizBuffer>,
513 rg_mode: ReplayGainMode,
514 pre_amp_db: f64,
515) -> Result<(), DecodeError> {
516 let format_opts = FormatOptions {
517 enable_gapless: true,
518 ..Default::default()
519 };
520
521 let probed = symphonia::default::get_probe()
522 .format(hint, mss, &format_opts, &MetadataOptions::default())
523 .map_err(|e| DecodeError::Decode(e.to_string()))?;
524
525 let mut reader = probed.format;
526 let track = reader.default_track().ok_or(DecodeError::NoTrack)?;
527 let track_id = track.id;
528 let codec_params = &track.codec_params;
529 let sample_rate = codec_params.sample_rate.unwrap_or(44100);
530 let channels = codec_params.channels.map(|c| c.count() as u16).unwrap_or(2);
531
532 let info = StreamInfo {
533 codec: codec_name(codec_params.codec),
534 sample_rate,
535 channels,
536 bit_depth: codec_params.bits_per_sample.unwrap_or(16) as u16,
537 duration_ms: codec_params
538 .n_frames
539 .map(|f| f * 1000 / sample_rate as u64)
540 .unwrap_or(0),
541 };
542
543 let seek_samples = seek_ms * sample_rate as u64 * channels as u64 / 1000;
544
545 let write_offset = timeline.samples_written.load(Ordering::Relaxed);
547 timeline.push_boundary(TrackBoundary {
548 id: queue_item_id,
549 path: path.to_path_buf(),
550 info,
551 sample_offset: write_offset,
552 samples_written: 0,
553 seek_samples,
554 });
555
556 let mut decoder = symphonia::default::get_codecs()
557 .make(&track.codec_params, &DecoderOptions::default())
558 .map_err(|_| DecodeError::UnsupportedCodec)?;
559
560 if seek_ms > 0 {
562 let secs = seek_ms / 1000;
563 let frac = (seek_ms % 1000) as f64 / 1000.0;
564 reader
565 .seek(
566 SeekMode::Coarse,
567 SeekTo::Time {
568 time: Time::new(secs, frac),
569 track_id: Some(track_id),
570 },
571 )
572 .map_err(|e| DecodeError::Decode(format!("seek failed: {}", e)))?;
573 decoder.reset();
574 }
575
576 let rg_gain = if rg_mode != ReplayGainMode::Off {
578 match crate::audio::replaygain::read_tags(path) {
579 Ok(rg_info) => {
580 let selected = crate::audio::replaygain::select_gain(&rg_info, rg_mode);
581 if let Some((gain_db, _)) = selected {
582 log::info!(
583 "replaygain: applying {:.2} dB ({:?}) to {}",
584 gain_db,
585 rg_mode,
586 path.display()
587 );
588 }
589 selected
590 }
591 Err(e) => {
592 log::debug!("replaygain: no tags for {}: {}", path.display(), e);
593 None
594 }
595 }
596 } else {
597 None
598 };
599 let mut rg_scratch: Vec<f32> = Vec::new();
600
601 let mut sample_buf: Option<SampleBuffer<f32>> = None;
602
603 loop {
604 if stop.load(Ordering::Relaxed) {
605 return Ok(());
606 }
607
608 let packet = match reader.next_packet() {
609 Ok(p) => p,
610 Err(symphonia::core::errors::Error::IoError(ref e))
611 if e.kind() == std::io::ErrorKind::UnexpectedEof =>
612 {
613 return Ok(());
614 }
615 Err(e) => return Err(DecodeError::Decode(e.to_string())),
616 };
617
618 if packet.track_id() != track_id {
619 continue;
620 }
621
622 let decoded = match decoder.decode(&packet) {
623 Ok(d) => d,
624 Err(symphonia::core::errors::Error::DecodeError(e)) => {
625 log::warn!("decode error (skipping packet): {}", e);
626 continue;
627 }
628 Err(e) => return Err(DecodeError::Decode(e.to_string())),
629 };
630
631 let spec = *decoded.spec();
632 let duration = decoded.capacity();
633
634 let sbuf = sample_buf.get_or_insert_with(|| SampleBuffer::new(duration as u64, spec));
635
636 sbuf.copy_interleaved_ref(decoded);
637
638 let samples = if let Some((gain_db, peak)) = rg_gain {
641 rg_scratch.clear();
642 rg_scratch.extend_from_slice(sbuf.samples());
643 crate::audio::replaygain::apply_gain(&mut rg_scratch, gain_db, peak, pre_amp_db);
644 &rg_scratch[..]
645 } else {
646 sbuf.samples()
647 };
648
649 if let Some(viz) = viz_buffer {
651 viz.push_samples(samples, channels, sample_rate);
652 }
653
654 let mut offset = 0;
656 while offset < samples.len() {
657 if stop.load(Ordering::Relaxed) {
658 return Ok(());
659 }
660
661 let slots = producer.slots();
662 if slots == 0 {
663 thread::sleep(std::time::Duration::from_micros(500));
664 continue;
665 }
666
667 let chunk_size = slots.min(samples.len() - offset);
668 if let Ok(mut chunk) = producer.write_chunk_uninit(chunk_size) {
669 let to_write = &samples[offset..offset + chunk_size];
670 let (first, second) = chunk.as_mut_slices();
671 let first_len = first.len().min(to_write.len());
672 for (slot, &val) in first.iter_mut().zip(&to_write[..first_len]) {
673 slot.write(val);
674 }
675 if first_len < to_write.len() {
676 for (slot, &val) in second.iter_mut().zip(&to_write[first_len..]) {
677 slot.write(val);
678 }
679 }
680 unsafe { chunk.commit_all() };
684 offset += chunk_size;
685 }
686 }
687
688 timeline.add_written(samples.len() as u64);
689 }
690}
691
692pub fn codec_name(codec: CodecType) -> String {
693 match codec {
694 CODEC_TYPE_FLAC => "FLAC",
695 CODEC_TYPE_MP3 => "MP3",
696 CODEC_TYPE_AAC => "AAC",
697 CODEC_TYPE_VORBIS => "Vorbis",
698 CODEC_TYPE_OPUS => "Opus",
699 CODEC_TYPE_ALAC => "ALAC",
700 CODEC_TYPE_WAVPACK => "WavPack",
701 CODEC_TYPE_PCM_S16LE => "PCM/16",
702 CODEC_TYPE_PCM_S24LE => "PCM/24",
703 CODEC_TYPE_PCM_S32LE => "PCM/32",
704 CODEC_TYPE_PCM_F32LE => "PCM/f32",
705 other => return format!("Unknown({:?})", other),
706 }
707 .to_string()
708}
709
710#[cfg(test)]
711mod tests {
712 use std::path::PathBuf;
713 use std::sync::atomic::Ordering;
714
715 use super::*;
716 use crate::player::state::QueueItemId;
717
718 fn make_info(sample_rate: u32, channels: u16) -> StreamInfo {
719 StreamInfo {
720 codec: "FLAC".to_string(),
721 sample_rate,
722 channels,
723 bit_depth: 16,
724 duration_ms: 10_000,
725 }
726 }
727
728 fn make_boundary(
729 id: QueueItemId,
730 sample_offset: u64,
731 seek_samples: u64,
732 channels: u16,
733 sample_rate: u32,
734 ) -> TrackBoundary {
735 TrackBoundary {
736 id,
737 path: PathBuf::from("/music/track.flac"),
738 info: make_info(sample_rate, channels),
739 sample_offset,
740 samples_written: 0,
741 seek_samples,
742 }
743 }
744
745 #[test]
748 fn test_timeline_single_track() {
749 let timeline = PlaybackTimeline::new();
753 let id = QueueItemId::new();
754 timeline.push_boundary(make_boundary(id, 0, 0, 2, 44100));
756 timeline.add_written(88200); timeline.samples_played.store(88200, Ordering::Relaxed);
760
761 let result = timeline.current_playback();
762 assert!(
763 result.is_some(),
764 "expected Some for single track with samples played"
765 );
766 let (result_id, _path, _info, position_ms) = result.unwrap();
767 assert_eq!(result_id, id);
768 assert_eq!(
769 position_ms, 1000,
770 "1 second of 44100 Hz stereo should be 1000 ms"
771 );
772 }
773
774 #[test]
775 fn test_timeline_gapless_transition() {
776 let timeline = PlaybackTimeline::new();
780 let id1 = QueueItemId::new();
781 let id2 = QueueItemId::new();
782
783 timeline.push_boundary(make_boundary(id1, 0, 0, 2, 44100));
785 timeline.add_written(88200);
786
787 timeline.push_boundary(make_boundary(id2, 88200, 0, 2, 44100));
789 timeline.add_written(44100); timeline.samples_played.store(90000, Ordering::Relaxed);
793
794 let result = timeline.current_playback();
795 assert!(result.is_some());
796 let (result_id, _path, _info, position_ms) = result.unwrap();
797 assert_eq!(
798 result_id, id2,
799 "playback head past boundary should report second track"
800 );
801 assert_eq!(position_ms, 20, "position within track 2 should be ~20 ms");
803 }
804
805 #[test]
806 fn test_timeline_zero_samples() {
807 let timeline = PlaybackTimeline::new();
810 let id = QueueItemId::new();
811 timeline.push_boundary(make_boundary(id, 0, 0, 2, 44100));
812 timeline.add_written(1000);
813 timeline.samples_played.store(0, Ordering::Relaxed);
814
815 let result = timeline.current_playback();
816 assert!(
817 result.is_some(),
818 "expected Some at 0 samples played with a boundary at offset 0"
819 );
820 let (result_id, _path, _info, position_ms) = result.unwrap();
821 assert_eq!(result_id, id);
822 assert_eq!(position_ms, 0);
823 }
824
825 #[test]
826 fn test_timeline_past_all_boundaries() {
827 let timeline = PlaybackTimeline::new();
830 let id1 = QueueItemId::new();
831 let id2 = QueueItemId::new();
832
833 timeline.push_boundary(make_boundary(id1, 0, 0, 2, 44100));
834 timeline.add_written(88200);
835 timeline.push_boundary(make_boundary(id2, 88200, 0, 2, 44100));
836 timeline.add_written(88200);
837
838 timeline
840 .samples_played
841 .store(999_999_999, Ordering::Relaxed);
842
843 let result = timeline.current_playback();
844 assert!(result.is_some());
845 let (result_id, _path, _info, _position_ms) = result.unwrap();
846 assert_eq!(
847 result_id, id2,
848 "samples past all boundaries should report the last track"
849 );
850 }
851
852 #[test]
853 fn test_timeline_seek_offset() {
854 let timeline = PlaybackTimeline::new();
858 let id = QueueItemId::new();
859 let seek_samples = 88200u64; timeline.push_boundary(make_boundary(id, 0, seek_samples, 2, 44100));
861 timeline.add_written(44100); timeline.samples_played.store(0, Ordering::Relaxed);
864
865 let result = timeline.current_playback();
866 assert!(result.is_some());
867 let (_result_id, _path, _info, position_ms) = result.unwrap();
868 assert_eq!(
870 position_ms, 1000,
871 "position should include seek offset of 1000 ms"
872 );
873 }
874
875 #[test]
876 fn test_timeline_reset() {
877 let timeline = PlaybackTimeline::new();
879 let id = QueueItemId::new();
880 timeline.push_boundary(make_boundary(id, 0, 0, 2, 44100));
881 timeline.add_written(88200);
882 timeline.samples_played.store(44100, Ordering::Relaxed);
883
884 assert!(timeline.current_playback().is_some());
886
887 timeline.reset();
888
889 assert!(
890 timeline.current_playback().is_none(),
891 "after reset, current_playback should return None"
892 );
893 assert_eq!(
894 timeline.samples_played.load(Ordering::Relaxed),
895 0,
896 "samples_played should be 0 after reset"
897 );
898 assert_eq!(
899 timeline.samples_written.load(Ordering::Relaxed),
900 0,
901 "samples_written should be 0 after reset"
902 );
903 }
904}