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 pub fn stop(&mut self) {
60 self.signal_stop();
61 if let Some(handle) = self.thread.take()
62 && let Err(payload) = handle.join()
63 {
64 let msg = payload
65 .downcast_ref::<String>()
66 .map(|s| s.as_str())
67 .or_else(|| payload.downcast_ref::<&str>().copied())
68 .unwrap_or("unknown");
69 log::error!("decode thread panicked: {}", msg);
70 }
71 }
72}
73
74impl Drop for DecodeHandle {
75 fn drop(&mut self) {
76 self.stop();
77 }
78}
79
80#[derive(Debug, Clone)]
85pub struct TrackBoundary {
86 pub id: QueueItemId,
87 pub path: PathBuf,
88 pub info: StreamInfo,
89 pub sample_offset: u64,
93 pub samples_written: u64,
96 pub seek_samples: u64,
98}
99
100pub struct PlaybackTimeline {
104 boundaries: parking_lot::RwLock<Vec<TrackBoundary>>,
105 samples_written: AtomicU64,
107 pub samples_played: Arc<AtomicU64>,
110}
111
112impl PlaybackTimeline {
113 pub fn new() -> Arc<Self> {
114 Arc::new(Self {
115 boundaries: parking_lot::RwLock::new(Vec::new()),
116 samples_written: AtomicU64::new(0),
117 samples_played: Arc::new(AtomicU64::new(0)),
118 })
119 }
120
121 fn push_boundary(&self, boundary: TrackBoundary) {
123 self.boundaries.write().push(boundary);
124 }
125
126 fn add_written(&self, count: u64) {
128 self.samples_written.fetch_add(count, Ordering::Relaxed);
129 let mut bounds = self.boundaries.write();
131 if let Some(last) = bounds.last_mut() {
132 last.samples_written += count;
133 }
134 }
135
136 pub fn reset(&self) {
138 self.boundaries.write().clear();
139 self.samples_written.store(0, Ordering::Relaxed);
140 self.samples_played.store(0, Ordering::Relaxed);
141 }
142
143 pub fn samples_played_counter(&self) -> Arc<AtomicU64> {
145 self.samples_played.clone()
146 }
147
148 pub fn current_playback(&self) -> Option<(QueueItemId, PathBuf, StreamInfo, u64)> {
157 let bounds = self.boundaries.read();
159
160 if bounds.is_empty() {
161 return None;
162 }
163
164 let played = self.samples_played.load(Ordering::Acquire);
168
169 let idx = bounds.partition_point(|b| b.sample_offset <= played);
173 let current = if idx > 0 {
174 &bounds[idx - 1]
175 } else {
176 return None;
177 };
178
179 let ch = current.info.channels as u64;
180 let rate = current.info.sample_rate as u64;
181 if ch == 0 || rate == 0 {
182 return None;
183 }
184
185 let track_samples = played.saturating_sub(current.sample_offset);
188 let position_ms =
189 (track_samples / ch) * 1000 / rate + (current.seek_samples / ch) * 1000 / rate;
190
191 Some((
192 current.id,
193 current.path.clone(),
194 current.info.clone(),
195 position_ms,
196 ))
197 }
198}
199
200pub struct SourceEntry {
209 pub id: QueueItemId,
210 pub path: PathBuf,
212 pub hint: Hint,
214 pub make_mss: Box<dyn FnOnce() -> std::io::Result<MediaSourceStream> + Send>,
216}
217
218impl SourceEntry {
219 pub fn from_file(id: QueueItemId, path: PathBuf) -> Self {
221 let ext = path
222 .extension()
223 .and_then(|e| e.to_str())
224 .unwrap_or("")
225 .to_string();
226 let path_clone = path.clone();
227 let mut hint = Hint::new();
228 if !ext.is_empty() {
229 hint.with_extension(&ext);
230 }
231 Self {
232 id,
233 path,
234 hint,
235 make_mss: Box::new(move || {
236 let file = File::open(&path_clone)?;
237 Ok(MediaSourceStream::new(Box::new(file), Default::default()))
238 }),
239 }
240 }
241}
242
243pub fn probe_source(mss: MediaSourceStream, hint: &Hint) -> Result<StreamInfo, DecodeError> {
249 probe_mss(mss, hint)
250}
251
252pub fn probe_file(path: &Path) -> Result<StreamInfo, DecodeError> {
254 let file = File::open(path)?;
255 let mss = MediaSourceStream::new(Box::new(file), Default::default());
256 let mut hint = Hint::new();
257 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
258 hint.with_extension(ext);
259 }
260 probe_mss(mss, &hint)
261}
262
263fn probe_mss(mss: MediaSourceStream, hint: &Hint) -> Result<StreamInfo, DecodeError> {
265 let probed = symphonia::default::get_probe()
266 .format(
267 hint,
268 mss,
269 &FormatOptions::default(),
270 &MetadataOptions::default(),
271 )
272 .map_err(|e| DecodeError::Decode(e.to_string()))?;
273
274 let reader = probed.format;
275 let track = reader.default_track().ok_or(DecodeError::NoTrack)?;
276 let codec_params = &track.codec_params;
277 let sample_rate = codec_params.sample_rate.unwrap_or(44100);
278 let channels = codec_params.channels.map(|c| c.count() as u16).unwrap_or(2);
279 let bit_depth = codec_params.bits_per_sample.unwrap_or(16) as u16;
280 let duration_ms = track
281 .codec_params
282 .n_frames
283 .map(|frames| frames * 1000 / sample_rate as u64)
284 .unwrap_or(0);
285 let codec = codec_name(codec_params.codec);
286
287 Ok(StreamInfo {
288 codec,
289 sample_rate,
290 channels,
291 bit_depth,
292 duration_ms,
293 })
294}
295
296#[allow(clippy::too_many_arguments)]
307pub fn start_decode<N>(
308 first: SourceEntry,
309 producer: rtrb::Producer<f32>,
310 seek_ms: u64,
311 next_track: N,
312 timeline: Arc<PlaybackTimeline>,
313 viz_buffer: Option<Arc<VizBuffer>>,
314 rg_mode: ReplayGainMode,
315 pre_amp_db: f64,
316) -> Result<(StreamInfo, DecodeHandle), DecodeError>
317where
318 N: Fn() -> Option<SourceEntry> + Send + 'static,
319{
320 let stop = Arc::new(AtomicBool::new(false));
321 let stop_clone = stop.clone();
322
323 let thread = thread::Builder::new()
324 .name("koan-decode".into())
325 .spawn(move || {
326 decode_queue_loop(
327 first,
328 producer,
329 &stop_clone,
330 seek_ms,
331 &next_track,
332 &timeline,
333 viz_buffer.as_deref(),
334 rg_mode,
335 pre_amp_db,
336 );
337 })
338 .map_err(DecodeError::Io)?;
339
340 let placeholder = StreamInfo {
343 codec: String::from("?"),
344 sample_rate: 44100,
345 channels: 2,
346 bit_depth: 16,
347 duration_ms: 0,
348 };
349
350 Ok((
351 placeholder,
352 DecodeHandle {
353 stop,
354 thread: Some(thread),
355 },
356 ))
357}
358
359#[allow(clippy::too_many_arguments)]
369pub fn start_decode_file<N>(
370 initial_id: QueueItemId,
371 path: &Path,
372 producer: rtrb::Producer<f32>,
373 seek_ms: u64,
374 next_track: N,
375 timeline: Arc<PlaybackTimeline>,
376 viz_buffer: Option<Arc<VizBuffer>>,
377 rg_mode: ReplayGainMode,
378 pre_amp_db: f64,
379) -> Result<(StreamInfo, DecodeHandle), DecodeError>
380where
381 N: Fn() -> Option<(QueueItemId, PathBuf)> + Send + 'static,
382{
383 let info = probe_file(path)?;
384 let first = SourceEntry::from_file(initial_id, path.to_path_buf());
385 let (_, handle) = start_decode(
386 first,
387 producer,
388 seek_ms,
389 move || {
390 let (id, p) = next_track()?;
391 Some(SourceEntry::from_file(id, p))
392 },
393 timeline,
394 viz_buffer,
395 rg_mode,
396 pre_amp_db,
397 )?;
398 Ok((info, handle))
399}
400
401#[allow(clippy::too_many_arguments)]
407fn decode_queue_loop<N>(
408 first: SourceEntry,
409 mut producer: rtrb::Producer<f32>,
410 stop: &AtomicBool,
411 initial_seek_ms: u64,
412 next_track: &N,
413 timeline: &PlaybackTimeline,
414 viz_buffer: Option<&VizBuffer>,
415 rg_mode: ReplayGainMode,
416 pre_amp_db: f64,
417) where
418 N: Fn() -> Option<SourceEntry>,
419{
420 let path = first.path.clone();
421 let hint = first.hint.clone();
422 let mss = match (first.make_mss)() {
423 Ok(mss) => mss,
424 Err(e) => {
425 if !stop.load(Ordering::Relaxed) {
426 log::error!("failed to open {}: {}", path.display(), e);
427 }
428 return;
429 }
430 };
431
432 if let Err(e) = decode_single(
433 first.id,
434 &path,
435 &hint,
436 mss,
437 &mut producer,
438 stop,
439 initial_seek_ms,
440 timeline,
441 viz_buffer,
442 rg_mode,
443 pre_amp_db,
444 ) {
445 if !stop.load(Ordering::Relaxed) {
446 log::error!("decode error on {}: {}", path.display(), e);
447 }
448 return;
449 }
450
451 while !stop.load(Ordering::Relaxed) {
452 let Some(entry) = (next_track)() else {
453 log::info!("playlist exhausted, decode thread finishing");
454 break;
455 };
456
457 log::info!("gapless transition → {}", entry.path.display());
458 let next_path = entry.path.clone();
459 let next_hint = entry.hint.clone();
460 let next_mss = match (entry.make_mss)() {
461 Ok(mss) => mss,
462 Err(e) => {
463 if !stop.load(Ordering::Relaxed) {
464 log::error!("failed to open {}: {}", next_path.display(), e);
465 }
466 break;
467 }
468 };
469
470 if let Err(e) = decode_single(
471 entry.id,
472 &next_path,
473 &next_hint,
474 next_mss,
475 &mut producer,
476 stop,
477 0,
478 timeline,
479 viz_buffer,
480 rg_mode,
481 pre_amp_db,
482 ) {
483 if !stop.load(Ordering::Relaxed) {
484 log::error!("decode error on {}: {}", next_path.display(), e);
485 }
486 break;
487 }
488 }
489}
490
491#[allow(clippy::too_many_arguments)]
497fn decode_single(
498 queue_item_id: QueueItemId,
499 path: &Path,
500 hint: &Hint,
501 mss: MediaSourceStream,
502 producer: &mut rtrb::Producer<f32>,
503 stop: &AtomicBool,
504 seek_ms: u64,
505 timeline: &PlaybackTimeline,
506 viz_buffer: Option<&VizBuffer>,
507 rg_mode: ReplayGainMode,
508 pre_amp_db: f64,
509) -> Result<(), DecodeError> {
510 let format_opts = FormatOptions {
511 enable_gapless: true,
512 ..Default::default()
513 };
514
515 let probed = symphonia::default::get_probe()
516 .format(hint, mss, &format_opts, &MetadataOptions::default())
517 .map_err(|e| DecodeError::Decode(e.to_string()))?;
518
519 let mut reader = probed.format;
520 let track = reader.default_track().ok_or(DecodeError::NoTrack)?;
521 let track_id = track.id;
522 let codec_params = &track.codec_params;
523 let sample_rate = codec_params.sample_rate.unwrap_or(44100);
524 let channels = codec_params.channels.map(|c| c.count() as u16).unwrap_or(2);
525
526 let info = StreamInfo {
527 codec: codec_name(codec_params.codec),
528 sample_rate,
529 channels,
530 bit_depth: codec_params.bits_per_sample.unwrap_or(16) as u16,
531 duration_ms: codec_params
532 .n_frames
533 .map(|f| f * 1000 / sample_rate as u64)
534 .unwrap_or(0),
535 };
536
537 let seek_samples = seek_ms * sample_rate as u64 * channels as u64 / 1000;
538
539 let write_offset = timeline.samples_written.load(Ordering::Relaxed);
541 timeline.push_boundary(TrackBoundary {
542 id: queue_item_id,
543 path: path.to_path_buf(),
544 info,
545 sample_offset: write_offset,
546 samples_written: 0,
547 seek_samples,
548 });
549
550 let mut decoder = symphonia::default::get_codecs()
551 .make(&track.codec_params, &DecoderOptions::default())
552 .map_err(|_| DecodeError::UnsupportedCodec)?;
553
554 if seek_ms > 0 {
556 let secs = seek_ms / 1000;
557 let frac = (seek_ms % 1000) as f64 / 1000.0;
558 reader
559 .seek(
560 SeekMode::Coarse,
561 SeekTo::Time {
562 time: Time::new(secs, frac),
563 track_id: Some(track_id),
564 },
565 )
566 .map_err(|e| DecodeError::Decode(format!("seek failed: {}", e)))?;
567 decoder.reset();
568 }
569
570 let rg_gain = if rg_mode != ReplayGainMode::Off {
572 match crate::audio::replaygain::read_tags(path) {
573 Ok(rg_info) => {
574 let selected = crate::audio::replaygain::select_gain(&rg_info, rg_mode);
575 if let Some((gain_db, _)) = selected {
576 log::info!(
577 "replaygain: applying {:.2} dB ({:?}) to {}",
578 gain_db,
579 rg_mode,
580 path.display()
581 );
582 }
583 selected
584 }
585 Err(e) => {
586 log::debug!("replaygain: no tags for {}: {}", path.display(), e);
587 None
588 }
589 }
590 } else {
591 None
592 };
593 let mut rg_scratch: Vec<f32> = Vec::new();
594
595 let mut sample_buf: Option<SampleBuffer<f32>> = None;
596
597 loop {
598 if stop.load(Ordering::Relaxed) {
599 return Ok(());
600 }
601
602 let packet = match reader.next_packet() {
603 Ok(p) => p,
604 Err(symphonia::core::errors::Error::IoError(ref e))
605 if e.kind() == std::io::ErrorKind::UnexpectedEof =>
606 {
607 return Ok(());
608 }
609 Err(e) => return Err(DecodeError::Decode(e.to_string())),
610 };
611
612 if packet.track_id() != track_id {
613 continue;
614 }
615
616 let decoded = match decoder.decode(&packet) {
617 Ok(d) => d,
618 Err(symphonia::core::errors::Error::DecodeError(e)) => {
619 log::warn!("decode error (skipping packet): {}", e);
620 continue;
621 }
622 Err(e) => return Err(DecodeError::Decode(e.to_string())),
623 };
624
625 let spec = *decoded.spec();
626 let duration = decoded.capacity();
627
628 let sbuf = sample_buf.get_or_insert_with(|| SampleBuffer::new(duration as u64, spec));
629
630 sbuf.copy_interleaved_ref(decoded);
631
632 let samples = if let Some((gain_db, peak)) = rg_gain {
635 rg_scratch.clear();
636 rg_scratch.extend_from_slice(sbuf.samples());
637 crate::audio::replaygain::apply_gain(&mut rg_scratch, gain_db, peak, pre_amp_db);
638 &rg_scratch[..]
639 } else {
640 sbuf.samples()
641 };
642
643 if let Some(viz) = viz_buffer {
645 viz.push_samples(samples, channels, sample_rate);
646 }
647
648 let mut offset = 0;
650 while offset < samples.len() {
651 if stop.load(Ordering::Relaxed) {
652 return Ok(());
653 }
654
655 let slots = producer.slots();
656 if slots == 0 {
657 thread::sleep(std::time::Duration::from_micros(500));
658 continue;
659 }
660
661 let chunk_size = slots.min(samples.len() - offset);
662 if let Ok(mut chunk) = producer.write_chunk_uninit(chunk_size) {
663 let to_write = &samples[offset..offset + chunk_size];
664 let (first, second) = chunk.as_mut_slices();
665 let first_len = first.len().min(to_write.len());
666 for (slot, &val) in first.iter_mut().zip(&to_write[..first_len]) {
667 slot.write(val);
668 }
669 if first_len < to_write.len() {
670 for (slot, &val) in second.iter_mut().zip(&to_write[first_len..]) {
671 slot.write(val);
672 }
673 }
674 unsafe { chunk.commit_all() };
678 offset += chunk_size;
679 }
680 }
681
682 timeline.add_written(samples.len() as u64);
683 }
684}
685
686pub fn codec_name(codec: CodecType) -> String {
687 match codec {
688 CODEC_TYPE_FLAC => "FLAC",
689 CODEC_TYPE_MP3 => "MP3",
690 CODEC_TYPE_AAC => "AAC",
691 CODEC_TYPE_VORBIS => "Vorbis",
692 CODEC_TYPE_OPUS => "Opus",
693 CODEC_TYPE_ALAC => "ALAC",
694 CODEC_TYPE_WAVPACK => "WavPack",
695 CODEC_TYPE_PCM_S16LE => "PCM/16",
696 CODEC_TYPE_PCM_S24LE => "PCM/24",
697 CODEC_TYPE_PCM_S32LE => "PCM/32",
698 CODEC_TYPE_PCM_F32LE => "PCM/f32",
699 other => return format!("Unknown({:?})", other),
700 }
701 .to_string()
702}
703
704#[cfg(test)]
705mod tests {
706 use std::path::PathBuf;
707 use std::sync::atomic::Ordering;
708
709 use super::*;
710 use crate::player::state::QueueItemId;
711
712 fn make_info(sample_rate: u32, channels: u16) -> StreamInfo {
713 StreamInfo {
714 codec: "FLAC".to_string(),
715 sample_rate,
716 channels,
717 bit_depth: 16,
718 duration_ms: 10_000,
719 }
720 }
721
722 fn make_boundary(
723 id: QueueItemId,
724 sample_offset: u64,
725 seek_samples: u64,
726 channels: u16,
727 sample_rate: u32,
728 ) -> TrackBoundary {
729 TrackBoundary {
730 id,
731 path: PathBuf::from("/music/track.flac"),
732 info: make_info(sample_rate, channels),
733 sample_offset,
734 samples_written: 0,
735 seek_samples,
736 }
737 }
738
739 #[test]
742 fn test_timeline_single_track() {
743 let timeline = PlaybackTimeline::new();
747 let id = QueueItemId::new();
748 timeline.push_boundary(make_boundary(id, 0, 0, 2, 44100));
750 timeline.add_written(88200); timeline.samples_played.store(88200, Ordering::Relaxed);
754
755 let result = timeline.current_playback();
756 assert!(
757 result.is_some(),
758 "expected Some for single track with samples played"
759 );
760 let (result_id, _path, _info, position_ms) = result.unwrap();
761 assert_eq!(result_id, id);
762 assert_eq!(
763 position_ms, 1000,
764 "1 second of 44100 Hz stereo should be 1000 ms"
765 );
766 }
767
768 #[test]
769 fn test_timeline_gapless_transition() {
770 let timeline = PlaybackTimeline::new();
774 let id1 = QueueItemId::new();
775 let id2 = QueueItemId::new();
776
777 timeline.push_boundary(make_boundary(id1, 0, 0, 2, 44100));
779 timeline.add_written(88200);
780
781 timeline.push_boundary(make_boundary(id2, 88200, 0, 2, 44100));
783 timeline.add_written(44100); timeline.samples_played.store(90000, Ordering::Relaxed);
787
788 let result = timeline.current_playback();
789 assert!(result.is_some());
790 let (result_id, _path, _info, position_ms) = result.unwrap();
791 assert_eq!(
792 result_id, id2,
793 "playback head past boundary should report second track"
794 );
795 assert_eq!(position_ms, 20, "position within track 2 should be ~20 ms");
797 }
798
799 #[test]
800 fn test_timeline_zero_samples() {
801 let timeline = PlaybackTimeline::new();
804 let id = QueueItemId::new();
805 timeline.push_boundary(make_boundary(id, 0, 0, 2, 44100));
806 timeline.add_written(1000);
807 timeline.samples_played.store(0, Ordering::Relaxed);
808
809 let result = timeline.current_playback();
810 assert!(
811 result.is_some(),
812 "expected Some at 0 samples played with a boundary at offset 0"
813 );
814 let (result_id, _path, _info, position_ms) = result.unwrap();
815 assert_eq!(result_id, id);
816 assert_eq!(position_ms, 0);
817 }
818
819 #[test]
820 fn test_timeline_past_all_boundaries() {
821 let timeline = PlaybackTimeline::new();
824 let id1 = QueueItemId::new();
825 let id2 = QueueItemId::new();
826
827 timeline.push_boundary(make_boundary(id1, 0, 0, 2, 44100));
828 timeline.add_written(88200);
829 timeline.push_boundary(make_boundary(id2, 88200, 0, 2, 44100));
830 timeline.add_written(88200);
831
832 timeline
834 .samples_played
835 .store(999_999_999, Ordering::Relaxed);
836
837 let result = timeline.current_playback();
838 assert!(result.is_some());
839 let (result_id, _path, _info, _position_ms) = result.unwrap();
840 assert_eq!(
841 result_id, id2,
842 "samples past all boundaries should report the last track"
843 );
844 }
845
846 #[test]
847 fn test_timeline_seek_offset() {
848 let timeline = PlaybackTimeline::new();
852 let id = QueueItemId::new();
853 let seek_samples = 88200u64; timeline.push_boundary(make_boundary(id, 0, seek_samples, 2, 44100));
855 timeline.add_written(44100); timeline.samples_played.store(0, Ordering::Relaxed);
858
859 let result = timeline.current_playback();
860 assert!(result.is_some());
861 let (_result_id, _path, _info, position_ms) = result.unwrap();
862 assert_eq!(
864 position_ms, 1000,
865 "position should include seek offset of 1000 ms"
866 );
867 }
868
869 #[test]
870 fn test_timeline_reset() {
871 let timeline = PlaybackTimeline::new();
873 let id = QueueItemId::new();
874 timeline.push_boundary(make_boundary(id, 0, 0, 2, 44100));
875 timeline.add_written(88200);
876 timeline.samples_played.store(44100, Ordering::Relaxed);
877
878 assert!(timeline.current_playback().is_some());
880
881 timeline.reset();
882
883 assert!(
884 timeline.current_playback().is_none(),
885 "after reset, current_playback should return None"
886 );
887 assert_eq!(
888 timeline.samples_played.load(Ordering::Relaxed),
889 0,
890 "samples_played should be 0 after reset"
891 );
892 assert_eq!(
893 timeline.samples_written.load(Ordering::Relaxed),
894 0,
895 "samples_written should be 0 after reset"
896 );
897 }
898}