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::codecs::audio::well_known::{
8 CODEC_ID_AAC, CODEC_ID_ALAC, CODEC_ID_FLAC, CODEC_ID_MP3, CODEC_ID_OPUS, CODEC_ID_PCM_F32LE,
9 CODEC_ID_PCM_S16LE, CODEC_ID_PCM_S24LE, CODEC_ID_PCM_S32LE, CODEC_ID_VORBIS,
10};
11use symphonia::core::codecs::audio::{AudioCodecId, AudioCodecParameters, AudioDecoderOptions};
12use symphonia::core::formats::probe::Hint;
13use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo, Track, TrackType};
14use symphonia::core::io::MediaSourceStream;
15use symphonia::core::meta::MetadataOptions;
16use symphonia::core::units::{Duration, Time, TimeBase, Timestamp};
17use thiserror::Error;
18
19use crate::audio::opus::OpusBridge;
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: Option<u16>,
43 pub bitrate_kbps: Option<u32>,
45 pub duration_ms: u64,
46}
47
48pub struct DecodeHandle {
50 stop: Arc<AtomicBool>,
51 thread: Option<thread::JoinHandle<()>>,
52}
53
54impl DecodeHandle {
55 pub fn signal_stop(&self) {
57 self.stop.store(true, Ordering::Relaxed);
58 }
59
60 #[cfg(test)]
62 pub fn new_for_test(stop: Arc<AtomicBool>) -> Self {
63 Self { stop, thread: None }
64 }
65
66 pub fn stop(&mut self) {
68 self.signal_stop();
69 if let Some(handle) = self.thread.take()
70 && let Err(payload) = handle.join()
71 {
72 let msg = payload
73 .downcast_ref::<String>()
74 .map(|s| s.as_str())
75 .or_else(|| payload.downcast_ref::<&str>().copied())
76 .unwrap_or("unknown");
77 log::error!("decode thread panicked: {}", msg);
78 }
79 }
80}
81
82impl Drop for DecodeHandle {
83 fn drop(&mut self) {
84 self.stop();
85 }
86}
87
88#[derive(Debug, Clone)]
93pub struct TrackBoundary {
94 pub id: QueueItemId,
95 pub path: PathBuf,
96 pub info: StreamInfo,
97 pub sample_offset: u64,
101 pub samples_written: u64,
104 pub seek_samples: u64,
106}
107
108pub struct PlaybackTimeline {
112 boundaries: parking_lot::RwLock<Vec<TrackBoundary>>,
113 samples_written: AtomicU64,
115 pub samples_played: Arc<AtomicU64>,
118 generation: AtomicU64,
121}
122
123impl PlaybackTimeline {
124 pub fn new() -> Arc<Self> {
125 Arc::new(Self {
126 boundaries: parking_lot::RwLock::new(Vec::new()),
127 samples_written: AtomicU64::new(0),
128 samples_played: Arc::new(AtomicU64::new(0)),
129 generation: AtomicU64::new(0),
130 })
131 }
132
133 pub fn generation(&self) -> u64 {
138 self.generation.load(Ordering::Acquire)
139 }
140
141 pub fn writer(&self, generation: u64) -> TimelineWriter<'_> {
143 TimelineWriter {
144 timeline: self,
145 generation,
146 }
147 }
148
149 pub fn reset(&self) {
151 let mut bounds = self.boundaries.write();
155 self.generation.fetch_add(1, Ordering::AcqRel);
156 bounds.clear();
157 self.samples_written.store(0, Ordering::Relaxed);
158 self.samples_played.store(0, Ordering::Relaxed);
159 }
160
161 pub fn samples_played_counter(&self) -> Arc<AtomicU64> {
163 self.samples_played.clone()
164 }
165
166 pub fn current_playback(&self) -> Option<(QueueItemId, PathBuf, StreamInfo, u64)> {
175 let bounds = self.boundaries.read();
177
178 if bounds.is_empty() {
179 return None;
180 }
181
182 let played = self.samples_played.load(Ordering::Acquire);
186
187 let idx = bounds.partition_point(|b| b.sample_offset <= played);
191 let current = if idx > 0 {
192 &bounds[idx - 1]
193 } else {
194 return None;
195 };
196
197 let ch = current.info.channels as u64;
198 let rate = current.info.sample_rate as u64;
199 if ch == 0 || rate == 0 {
200 return None;
201 }
202
203 let track_samples = played.saturating_sub(current.sample_offset);
206 let position_ms =
207 (track_samples / ch) * 1000 / rate + (current.seek_samples / ch) * 1000 / rate;
208
209 Some((
210 current.id,
211 current.path.clone(),
212 current.info.clone(),
213 position_ms,
214 ))
215 }
216}
217
218pub struct TimelineWriter<'a> {
232 timeline: &'a PlaybackTimeline,
233 generation: u64,
234}
235
236impl TimelineWriter<'_> {
237 pub fn is_current(&self) -> bool {
240 self.timeline.generation.load(Ordering::Acquire) == self.generation
241 }
242
243 fn samples_written(&self) -> u64 {
245 self.timeline.samples_written.load(Ordering::Relaxed)
246 }
247
248 fn push_boundary(&self, boundary: TrackBoundary) {
250 let mut bounds = self.timeline.boundaries.write();
251 if !self.is_current() {
252 return;
253 }
254 bounds.push(boundary);
255 }
256
257 fn add_written(&self, count: u64) {
259 let mut bounds = self.timeline.boundaries.write();
260 if !self.is_current() {
261 return;
262 }
263 self.timeline
264 .samples_written
265 .fetch_add(count, Ordering::Relaxed);
266 if let Some(last) = bounds.last_mut() {
268 last.samples_written += count;
269 }
270 }
271}
272
273pub struct SourceEntry {
282 pub id: QueueItemId,
283 pub path: PathBuf,
285 pub hint: Hint,
287 pub make_mss: Box<dyn FnOnce() -> std::io::Result<MediaSourceStream<'static>> + Send>,
289}
290
291impl SourceEntry {
292 pub fn from_file(id: QueueItemId, path: PathBuf) -> Self {
294 let ext = path
295 .extension()
296 .and_then(|e| e.to_str())
297 .unwrap_or("")
298 .to_string();
299 let path_clone = path.clone();
300 let mut hint = Hint::new();
301 if !ext.is_empty() {
302 hint.with_extension(&ext);
303 }
304 Self {
305 id,
306 path,
307 hint,
308 make_mss: Box::new(move || {
309 let file = File::open(&path_clone)?;
310 Ok(MediaSourceStream::new(Box::new(file), Default::default()))
311 }),
312 }
313 }
314}
315
316pub fn probe_source(mss: MediaSourceStream<'_>, hint: &Hint) -> Result<StreamInfo, DecodeError> {
322 probe_mss(mss, hint)
323}
324
325pub fn probe_file(path: &Path) -> Result<StreamInfo, DecodeError> {
327 let file_size = std::fs::metadata(path).ok().map(|m| m.len());
328 let file = File::open(path)?;
329 let mss = MediaSourceStream::new(Box::new(file), Default::default());
330 let mut hint = Hint::new();
331 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
332 hint.with_extension(ext);
333 }
334 let mut info = probe_mss(mss, &hint)?;
335 if info.bitrate_kbps.is_none()
338 && info.bit_depth.is_none()
339 && let Some(size) = file_size
340 && info.duration_ms > 0
341 {
342 info.bitrate_kbps = Some((size * 8 / info.duration_ms) as u32);
343 }
344 Ok(info)
345}
346
347fn probe_mss(mss: MediaSourceStream<'_>, hint: &Hint) -> Result<StreamInfo, DecodeError> {
349 let reader = symphonia::default::get_probe()
350 .probe(
351 hint,
352 mss,
353 FormatOptions::default(),
354 MetadataOptions::default(),
355 )
356 .map_err(|e| match e {
357 symphonia::core::errors::Error::IoError(io) => DecodeError::Io(io),
361 other => DecodeError::Decode(other.to_string()),
362 })?;
363
364 let track = reader
365 .default_track(TrackType::Audio)
366 .ok_or(DecodeError::NoTrack)?;
367 let codec_params = track
368 .codec_params
369 .as_ref()
370 .and_then(|p| p.audio())
371 .ok_or(DecodeError::NoTrack)?;
372 let is_opus = codec_params.codec == CODEC_ID_OPUS;
373 let sample_rate = if is_opus {
375 48000
376 } else {
377 codec_params.sample_rate.unwrap_or(44100)
378 };
379 let channels = codec_params
380 .channels
381 .as_ref()
382 .map(|c| c.count() as u16)
383 .unwrap_or(2);
384 let bit_depth = if is_opus {
385 None
386 } else {
387 Some(codec_params.bits_per_sample.unwrap_or(16) as u16)
388 };
389 let duration_ms = track_duration_ms(&*reader, track, sample_rate);
390 let codec = codec_name(codec_params.codec);
391
392 let bitrate_kbps = estimate_bitrate_from_codec_params(codec_params);
397
398 Ok(StreamInfo {
399 codec,
400 sample_rate,
401 channels,
402 bit_depth,
403 bitrate_kbps,
404 duration_ms,
405 })
406}
407
408#[allow(clippy::too_many_arguments)]
419pub fn start_decode<N, F>(
420 first: SourceEntry,
421 producer: rtrb::Producer<f32>,
422 seek_ms: u64,
423 next_track: N,
424 timeline: Arc<PlaybackTimeline>,
425 viz_buffer: Option<Arc<VizBuffer>>,
426 rg_mode: ReplayGainMode,
427 pre_amp_db: f64,
428 on_finished: F,
429) -> Result<(StreamInfo, DecodeHandle), DecodeError>
430where
431 N: Fn() -> Option<SourceEntry> + Send + 'static,
432 F: FnOnce() + Send + 'static,
433{
434 let stop = Arc::new(AtomicBool::new(false));
435 let stop_clone = stop.clone();
436 let generation = timeline.generation();
440
441 let thread = thread::Builder::new()
442 .name("koan-decode".into())
443 .spawn(move || {
444 decode_queue_loop(
445 first,
446 producer,
447 &stop_clone,
448 seek_ms,
449 &next_track,
450 &timeline.writer(generation),
451 viz_buffer.as_deref(),
452 rg_mode,
453 pre_amp_db,
454 );
455 if !stop_clone.load(Ordering::Relaxed) {
459 on_finished();
460 }
461 })
462 .map_err(DecodeError::Io)?;
463
464 let placeholder = StreamInfo {
467 codec: String::from("?"),
468 sample_rate: 44100,
469 channels: 2,
470 bit_depth: Some(16),
471 bitrate_kbps: None,
472 duration_ms: 0,
473 };
474
475 Ok((
476 placeholder,
477 DecodeHandle {
478 stop,
479 thread: Some(thread),
480 },
481 ))
482}
483
484#[allow(clippy::too_many_arguments)]
494pub fn start_decode_file<N, F>(
495 initial_id: QueueItemId,
496 path: &Path,
497 producer: rtrb::Producer<f32>,
498 seek_ms: u64,
499 next_track: N,
500 timeline: Arc<PlaybackTimeline>,
501 viz_buffer: Option<Arc<VizBuffer>>,
502 rg_mode: ReplayGainMode,
503 pre_amp_db: f64,
504 on_finished: F,
505) -> Result<(StreamInfo, DecodeHandle), DecodeError>
506where
507 N: Fn() -> Option<(QueueItemId, PathBuf)> + Send + 'static,
508 F: FnOnce() + Send + 'static,
509{
510 let info = probe_file(path)?;
511 let first = SourceEntry::from_file(initial_id, path.to_path_buf());
512 let (_, handle) = start_decode(
513 first,
514 producer,
515 seek_ms,
516 move || {
517 let (id, p) = next_track()?;
518 Some(SourceEntry::from_file(id, p))
519 },
520 timeline,
521 viz_buffer,
522 rg_mode,
523 pre_amp_db,
524 on_finished,
525 )?;
526 Ok((info, handle))
527}
528
529const MAX_CONSECUTIVE_FAILURES: u32 = 32;
537
538#[allow(clippy::too_many_arguments)]
549fn decode_queue_loop<N>(
550 first: SourceEntry,
551 mut producer: rtrb::Producer<f32>,
552 stop: &AtomicBool,
553 initial_seek_ms: u64,
554 next_track: &N,
555 timeline: &TimelineWriter<'_>,
556 viz_buffer: Option<&VizBuffer>,
557 rg_mode: ReplayGainMode,
558 pre_amp_db: f64,
559) where
560 N: Fn() -> Option<SourceEntry>,
561{
562 if let Some(viz) = viz_buffer {
565 viz.reset();
566 }
567
568 let mut pending = Some(first);
569 let mut seek_ms = initial_seek_ms;
570 let mut format: Option<PcmFormat> = None;
571 let mut failures: u32 = 0;
572
573 while let Some(entry) = pending.take() {
574 if stop.load(Ordering::Relaxed) || !timeline.is_current() {
575 break;
576 }
577
578 let SourceEntry {
579 id,
580 path,
581 hint,
582 make_mss,
583 } = entry;
584
585 let outcome = make_mss().map_err(DecodeError::Io).and_then(|mss| {
586 decode_single(
587 id,
588 &path,
589 &hint,
590 mss,
591 &mut producer,
592 stop,
593 seek_ms,
594 timeline,
595 viz_buffer,
596 rg_mode,
597 pre_amp_db,
598 format,
599 )
600 });
601
602 match outcome {
603 Ok(Decoded::Complete(decoded_format)) => {
604 format = Some(decoded_format);
605 failures = 0;
606 }
607 Ok(Decoded::FormatMismatch) => break,
608 Err(e) => {
609 if stop.load(Ordering::Relaxed) {
610 break;
611 }
612 failures += 1;
613 log::error!("skipping {}: {}", path.display(), e);
614 if failures >= MAX_CONSECUTIVE_FAILURES {
615 log::error!(
616 "{} sources failed in a row, decode thread giving up",
617 failures
618 );
619 break;
620 }
621 }
622 }
623
624 seek_ms = 0;
625 pending = (next_track)();
626 match pending {
627 Some(ref next) => log::info!("gapless transition → {}", next.path.display()),
628 None => log::info!("playlist exhausted, decode thread finishing"),
629 }
630 }
631
632 wait_for_drain(&producer, stop);
633}
634
635fn wait_for_drain(producer: &rtrb::Producer<f32>, stop: &AtomicBool) {
641 let capacity = producer.buffer().capacity();
642 while !stop.load(Ordering::Relaxed) && !producer.is_abandoned() {
643 if producer.slots() >= capacity {
644 return;
645 }
646 thread::sleep(std::time::Duration::from_millis(2));
647 }
648}
649
650type PcmFormat = (u32, u16);
658
659enum Decoded {
661 Complete(PcmFormat),
663 FormatMismatch,
666}
667
668#[allow(clippy::too_many_arguments)]
673fn decode_single(
674 queue_item_id: QueueItemId,
675 path: &Path,
676 hint: &Hint,
677 mss: MediaSourceStream<'_>,
678 producer: &mut rtrb::Producer<f32>,
679 stop: &AtomicBool,
680 seek_ms: u64,
681 timeline: &TimelineWriter<'_>,
682 viz_buffer: Option<&VizBuffer>,
683 rg_mode: ReplayGainMode,
684 pre_amp_db: f64,
685 expected: Option<PcmFormat>,
686) -> Result<Decoded, DecodeError> {
687 let mut reader = symphonia::default::get_probe()
688 .probe(
689 hint,
690 mss,
691 FormatOptions::default(),
692 MetadataOptions::default(),
693 )
694 .map_err(|e| DecodeError::Decode(e.to_string()))?;
695
696 let track = reader
697 .default_track(TrackType::Audio)
698 .ok_or(DecodeError::NoTrack)?;
699 let track_id = track.id;
700 let time_base = track.time_base;
701 let codec_params = track
702 .codec_params
703 .as_ref()
704 .and_then(|p| p.audio())
705 .ok_or(DecodeError::NoTrack)?;
706 let is_opus_codec = codec_params.codec == CODEC_ID_OPUS;
707
708 let sample_rate = if is_opus_codec {
710 48000
711 } else {
712 codec_params.sample_rate.unwrap_or(44100)
713 };
714 let channels = codec_params
715 .channels
716 .as_ref()
717 .map(|c| c.count() as u16)
718 .unwrap_or(2);
719
720 let duration_ms = track_duration_ms(&*reader, track, sample_rate);
721
722 let mut bitrate_kbps = estimate_bitrate_from_codec_params(codec_params);
724 if bitrate_kbps.is_none()
725 && is_opus_codec
726 && let Ok(meta) = std::fs::metadata(path)
727 && duration_ms > 0
728 {
729 bitrate_kbps = Some((meta.len() * 8 / duration_ms) as u32);
730 }
731
732 let info = StreamInfo {
733 codec: codec_name(codec_params.codec),
734 sample_rate,
735 channels,
736 bit_depth: if is_opus_codec {
737 None
738 } else {
739 Some(codec_params.bits_per_sample.unwrap_or(16) as u16)
740 },
741 bitrate_kbps,
742 duration_ms,
743 };
744
745 if let Some(expected) = expected
746 && expected != (sample_rate, channels)
747 {
748 log::info!(
749 "format change at {}: {}Hz/{}ch → {}Hz/{}ch, restarting audio engine",
750 path.display(),
751 expected.0,
752 expected.1,
753 sample_rate,
754 channels
755 );
756 return Ok(Decoded::FormatMismatch);
757 }
758
759 let mut symphonia_decoder = if is_opus_codec {
761 None
762 } else {
763 Some(
764 symphonia::default::get_codecs()
765 .make_audio_decoder(codec_params, &AudioDecoderOptions::default())
766 .map_err(|_| DecodeError::UnsupportedCodec)?,
767 )
768 };
769 let mut opus_bridge = if is_opus_codec {
770 Some(OpusBridge::new(codec_params).map_err(|e| DecodeError::Decode(e.to_string()))?)
771 } else {
772 None
773 };
774
775 let mut seek_samples = 0;
785 if seek_ms > 0 {
786 let seeked = reader
787 .seek(
788 SeekMode::Accurate,
789 SeekTo::Time {
790 time: Time::from_millis_u64(seek_ms),
791 track_id: Some(track_id),
792 },
793 )
794 .map_err(|e| DecodeError::Decode(format!("seek failed: {}", e)))?;
795 seek_samples = landing_samples(time_base, seeked.actual_ts, sample_rate, channels)
796 .unwrap_or(seek_ms * sample_rate as u64 * channels as u64 / 1000);
797 if let Some(ref mut dec) = symphonia_decoder {
798 dec.reset();
799 }
800 if let Some(ref mut opus) = opus_bridge {
801 opus.reset();
802 }
803 }
804
805 let write_offset = timeline.samples_written();
807 timeline.push_boundary(TrackBoundary {
808 id: queue_item_id,
809 path: path.to_path_buf(),
810 info,
811 sample_offset: write_offset,
812 samples_written: 0,
813 seek_samples,
814 });
815
816 let rg_gain = if rg_mode != ReplayGainMode::Off {
818 match crate::audio::replaygain::read_tags(path) {
819 Ok(rg_info) => {
820 let selected = crate::audio::replaygain::select_gain(&rg_info, rg_mode);
821 if let Some((gain_db, _)) = selected {
822 log::info!(
823 "replaygain: applying {:.2} dB ({:?}) to {}",
824 gain_db,
825 rg_mode,
826 path.display()
827 );
828 }
829 selected
830 }
831 Err(e) => {
832 log::debug!("replaygain: no tags for {}: {}", path.display(), e);
833 None
834 }
835 }
836 } else {
837 None
838 };
839 let mut rg_scratch: Vec<f32> = Vec::new();
840
841 let mut sample_buf: Vec<f32> = Vec::new();
842
843 loop {
844 if stop.load(Ordering::Relaxed) || !timeline.is_current() {
845 return Ok(Decoded::Complete((sample_rate, channels)));
846 }
847
848 let packet = match reader.next_packet() {
849 Ok(Some(p)) => p,
850 Ok(None) => return Ok(Decoded::Complete((sample_rate, channels))),
851 Err(e) => return Err(DecodeError::Decode(e.to_string())),
852 };
853
854 if packet.track_id != track_id {
855 continue;
856 }
857
858 let samples: &[f32] = if let Some(ref mut opus) = opus_bridge {
860 match opus.decode_packet(&packet.data) {
861 Ok(s) => s,
862 Err(e) => {
863 log::warn!("opus decode error (skipping packet): {}", e);
864 continue;
865 }
866 }
867 } else {
868 let decoder = symphonia_decoder.as_mut().unwrap();
869 let decoded = match decoder.decode(&packet) {
870 Ok(d) => d,
871 Err(symphonia::core::errors::Error::DecodeError(e)) => {
872 log::warn!("decode error (skipping packet): {}", e);
873 continue;
874 }
875 Err(e) => return Err(DecodeError::Decode(e.to_string())),
876 };
877
878 let spec = decoded.spec();
879 let (decoded_rate, decoded_channels) = (spec.rate(), spec.channels().count() as u16);
880 if (decoded_rate, decoded_channels) != (sample_rate, channels) {
884 log::warn!(
885 "{}: decoded {}Hz/{}ch but stream declares {}Hz/{}ch, restarting audio engine",
886 path.display(),
887 decoded_rate,
888 decoded_channels,
889 sample_rate,
890 channels
891 );
892 return Ok(Decoded::FormatMismatch);
893 }
894 decoded.copy_to_vec_interleaved(&mut sample_buf);
895 &sample_buf[..]
896 };
897
898 if samples.is_empty() {
899 continue;
900 }
901
902 let samples = if let Some((gain_db, peak)) = rg_gain {
905 rg_scratch.clear();
906 rg_scratch.extend_from_slice(samples);
907 crate::audio::replaygain::apply_gain(&mut rg_scratch, gain_db, peak, pre_amp_db);
908 &rg_scratch[..]
909 } else {
910 samples
911 };
912
913 let mut offset = 0;
920 while offset < samples.len() {
921 if stop.load(Ordering::Relaxed) || !timeline.is_current() {
924 return Ok(Decoded::Complete((sample_rate, channels)));
925 }
926
927 let slots = producer.slots();
928 if slots == 0 {
929 thread::sleep(std::time::Duration::from_micros(500));
930 continue;
931 }
932
933 let chunk_size = slots.min(samples.len() - offset);
934 if let Ok(mut chunk) = producer.write_chunk_uninit(chunk_size) {
935 let to_write = &samples[offset..offset + chunk_size];
936 let (first, second) = chunk.as_mut_slices();
937 let first_len = first.len().min(to_write.len());
938 for (slot, &val) in first.iter_mut().zip(&to_write[..first_len]) {
939 slot.write(val);
940 }
941 if first_len < to_write.len() {
942 for (slot, &val) in second.iter_mut().zip(&to_write[first_len..]) {
943 slot.write(val);
944 }
945 }
946 unsafe { chunk.commit_all() };
950
951 if let Some(viz) = viz_buffer {
953 viz.push_samples(to_write, channels, sample_rate);
954 }
955
956 offset += chunk_size;
957 }
958 }
959
960 timeline.add_written(samples.len() as u64);
961 }
962}
963
964fn landing_samples(
970 time_base: Option<TimeBase>,
971 actual_ts: Timestamp,
972 sample_rate: u32,
973 channels: u16,
974) -> Option<u64> {
975 let (seconds, nanos) = time_base?.calc_time(actual_ts)?.parts();
976 let rate = sample_rate as u64;
977 let frames = seconds.max(0) as u64 * rate + (nanos as u64 * rate) / 1_000_000_000;
978 Some(frames * channels as u64)
979}
980
981pub(crate) fn track_duration_ms(
988 reader: &(impl FormatReader + ?Sized),
989 track: &Track,
990 sample_rate: u32,
991) -> u64 {
992 fn to_ms(time_base: Option<TimeBase>, duration: Option<Duration>) -> Option<u64> {
993 let time = time_base?.calc_duration(duration?)?;
994 Some(time.as_millis().max(0) as u64)
995 }
996
997 let media = reader.media_info();
998 to_ms(track.time_base, track.duration)
999 .or_else(|| to_ms(media.time_base, media.duration))
1000 .or_else(|| {
1001 track
1002 .num_frames
1003 .map(|frames| frames * 1000 / sample_rate as u64)
1004 })
1005 .unwrap_or(0)
1006}
1007
1008fn estimate_bitrate_from_codec_params(params: &AudioCodecParameters) -> Option<u32> {
1014 let is_lossy = matches!(
1015 params.codec,
1016 CODEC_ID_MP3 | CODEC_ID_AAC | CODEC_ID_VORBIS | CODEC_ID_OPUS
1017 );
1018 if !is_lossy {
1019 return None;
1020 }
1021
1022 let bpcs = params.bits_per_coded_sample?;
1025 let sr = params.sample_rate?;
1026 let channels = params
1027 .channels
1028 .as_ref()
1029 .map(|c| c.count() as u32)
1030 .unwrap_or(2);
1031 Some(bpcs * sr * channels / 1000)
1032}
1033
1034pub fn codec_name(codec: AudioCodecId) -> String {
1035 match codec {
1036 CODEC_ID_FLAC => "FLAC",
1037 CODEC_ID_MP3 => "MP3",
1038 CODEC_ID_AAC => "AAC",
1039 CODEC_ID_VORBIS => "Vorbis",
1040 CODEC_ID_OPUS => "Opus",
1041 CODEC_ID_ALAC => "ALAC",
1042 CODEC_ID_PCM_S16LE => "PCM/16",
1043 CODEC_ID_PCM_S24LE => "PCM/24",
1044 CODEC_ID_PCM_S32LE => "PCM/32",
1045 CODEC_ID_PCM_F32LE => "PCM/f32",
1046 other => return format!("Unknown({:?})", other),
1047 }
1048 .to_string()
1049}
1050
1051#[cfg(test)]
1052mod tests {
1053 use std::path::PathBuf;
1054 use std::sync::atomic::Ordering;
1055
1056 use super::*;
1057 use crate::player::state::QueueItemId;
1058
1059 fn make_info(sample_rate: u32, channels: u16) -> StreamInfo {
1060 StreamInfo {
1061 codec: "FLAC".to_string(),
1062 sample_rate,
1063 channels,
1064 bit_depth: Some(16),
1065 bitrate_kbps: None,
1066 duration_ms: 10_000,
1067 }
1068 }
1069
1070 fn make_boundary(
1071 id: QueueItemId,
1072 sample_offset: u64,
1073 seek_samples: u64,
1074 channels: u16,
1075 sample_rate: u32,
1076 ) -> TrackBoundary {
1077 TrackBoundary {
1078 id,
1079 path: PathBuf::from("/music/track.flac"),
1080 info: make_info(sample_rate, channels),
1081 sample_offset,
1082 samples_written: 0,
1083 seek_samples,
1084 }
1085 }
1086
1087 fn writer(timeline: &PlaybackTimeline) -> TimelineWriter<'_> {
1089 timeline.writer(timeline.generation())
1090 }
1091
1092 #[test]
1095 fn test_timeline_single_track() {
1096 let timeline = PlaybackTimeline::new();
1100 let tl = writer(&timeline);
1101 let id = QueueItemId::new();
1102 tl.push_boundary(make_boundary(id, 0, 0, 2, 44100));
1104 tl.add_written(88200); timeline.samples_played.store(88200, Ordering::Relaxed);
1108
1109 let result = timeline.current_playback();
1110 assert!(
1111 result.is_some(),
1112 "expected Some for single track with samples played"
1113 );
1114 let (result_id, _path, _info, position_ms) = result.unwrap();
1115 assert_eq!(result_id, id);
1116 assert_eq!(
1117 position_ms, 1000,
1118 "1 second of 44100 Hz stereo should be 1000 ms"
1119 );
1120 }
1121
1122 #[test]
1123 fn test_timeline_gapless_transition() {
1124 let timeline = PlaybackTimeline::new();
1128 let tl = writer(&timeline);
1129 let id1 = QueueItemId::new();
1130 let id2 = QueueItemId::new();
1131
1132 tl.push_boundary(make_boundary(id1, 0, 0, 2, 44100));
1134 tl.add_written(88200);
1135
1136 tl.push_boundary(make_boundary(id2, 88200, 0, 2, 44100));
1138 tl.add_written(44100); timeline.samples_played.store(90000, Ordering::Relaxed);
1142
1143 let result = timeline.current_playback();
1144 assert!(result.is_some());
1145 let (result_id, _path, _info, position_ms) = result.unwrap();
1146 assert_eq!(
1147 result_id, id2,
1148 "playback head past boundary should report second track"
1149 );
1150 assert_eq!(position_ms, 20, "position within track 2 should be ~20 ms");
1152 }
1153
1154 #[test]
1155 fn test_timeline_zero_samples() {
1156 let timeline = PlaybackTimeline::new();
1159 let tl = writer(&timeline);
1160 let id = QueueItemId::new();
1161 tl.push_boundary(make_boundary(id, 0, 0, 2, 44100));
1162 tl.add_written(1000);
1163 timeline.samples_played.store(0, Ordering::Relaxed);
1164
1165 let result = timeline.current_playback();
1166 assert!(
1167 result.is_some(),
1168 "expected Some at 0 samples played with a boundary at offset 0"
1169 );
1170 let (result_id, _path, _info, position_ms) = result.unwrap();
1171 assert_eq!(result_id, id);
1172 assert_eq!(position_ms, 0);
1173 }
1174
1175 #[test]
1176 fn test_timeline_past_all_boundaries() {
1177 let timeline = PlaybackTimeline::new();
1180 let tl = writer(&timeline);
1181 let id1 = QueueItemId::new();
1182 let id2 = QueueItemId::new();
1183
1184 tl.push_boundary(make_boundary(id1, 0, 0, 2, 44100));
1185 tl.add_written(88200);
1186 tl.push_boundary(make_boundary(id2, 88200, 0, 2, 44100));
1187 tl.add_written(88200);
1188
1189 timeline
1191 .samples_played
1192 .store(999_999_999, Ordering::Relaxed);
1193
1194 let result = timeline.current_playback();
1195 assert!(result.is_some());
1196 let (result_id, _path, _info, _position_ms) = result.unwrap();
1197 assert_eq!(
1198 result_id, id2,
1199 "samples past all boundaries should report the last track"
1200 );
1201 }
1202
1203 #[test]
1204 fn test_timeline_seek_offset() {
1205 let timeline = PlaybackTimeline::new();
1209 let tl = writer(&timeline);
1210 let id = QueueItemId::new();
1211 let seek_samples = 88200u64; tl.push_boundary(make_boundary(id, 0, seek_samples, 2, 44100));
1213 tl.add_written(44100); timeline.samples_played.store(0, Ordering::Relaxed);
1216
1217 let result = timeline.current_playback();
1218 assert!(result.is_some());
1219 let (_result_id, _path, _info, position_ms) = result.unwrap();
1220 assert_eq!(
1222 position_ms, 1000,
1223 "position should include seek offset of 1000 ms"
1224 );
1225 }
1226
1227 #[test]
1228 fn test_timeline_reset() {
1229 let timeline = PlaybackTimeline::new();
1231 let tl = writer(&timeline);
1232 let id = QueueItemId::new();
1233 tl.push_boundary(make_boundary(id, 0, 0, 2, 44100));
1234 tl.add_written(88200);
1235 timeline.samples_played.store(44100, Ordering::Relaxed);
1236
1237 assert!(timeline.current_playback().is_some());
1239
1240 timeline.reset();
1241
1242 assert!(
1243 timeline.current_playback().is_none(),
1244 "after reset, current_playback should return None"
1245 );
1246 assert_eq!(
1247 timeline.samples_played.load(Ordering::Relaxed),
1248 0,
1249 "samples_played should be 0 after reset"
1250 );
1251 assert_eq!(
1252 timeline.samples_written.load(Ordering::Relaxed),
1253 0,
1254 "samples_written should be 0 after reset"
1255 );
1256 }
1257
1258 #[test]
1261 fn probe_file_extracts_stream_info() {
1262 let dir = tempfile::tempdir().unwrap();
1263 let wav_path = dir.path().join("probe_test.wav");
1264 crate::test_utils::generate_wav(&wav_path, 44100, 2, 1.0, 16);
1265
1266 let info = probe_file(&wav_path).expect("probe_file should succeed on a valid WAV");
1267 assert_eq!(info.sample_rate, 44100, "sample rate mismatch");
1268 assert_eq!(info.channels, 2, "channel count mismatch");
1269 assert_eq!(info.bit_depth, Some(16), "bit depth mismatch");
1270 assert!(
1271 info.duration_ms > 900 && info.duration_ms < 1100,
1272 "duration should be ~1000ms, got {}",
1273 info.duration_ms
1274 );
1275 assert!(
1276 info.codec.contains("PCM"),
1277 "codec should be PCM variant, got {}",
1278 info.codec
1279 );
1280 }
1281
1282 #[test]
1283 fn decode_single_produces_samples() {
1284 let dir = tempfile::tempdir().unwrap();
1285 let wav_path = dir.path().join("tone.wav");
1286 crate::test_utils::generate_wav_tone(&wav_path, 44100, 440.0, 0.1);
1288
1289 let (mut producer, mut consumer) = rtrb::RingBuffer::new(44100 * 2);
1291
1292 let timeline = PlaybackTimeline::new();
1293 let tl = writer(&timeline);
1294 let stop = Arc::new(AtomicBool::new(false));
1295
1296 let id = QueueItemId::new();
1297 let entry = SourceEntry::from_file(id, wav_path.clone());
1298 let hint = entry.hint.clone();
1299 let mss = (entry.make_mss)().expect("should open WAV file");
1300
1301 let result = decode_single(
1302 id,
1303 &wav_path,
1304 &hint,
1305 mss,
1306 &mut producer,
1307 &stop,
1308 0,
1309 &tl,
1310 None,
1311 crate::config::ReplayGainMode::Off,
1312 0.0,
1313 None,
1314 );
1315 assert!(
1316 matches!(result, Ok(Decoded::Complete((44100, 1)))),
1317 "decode_single should complete at the source format"
1318 );
1319
1320 let available = consumer.slots();
1322 assert!(available > 0, "expected samples in ring buffer, got 0");
1323
1324 let mut found_nonzero = false;
1326 while consumer.slots() > 0 {
1327 if let Ok(chunk) = consumer.read_chunk(consumer.slots().min(1024)) {
1328 let (first, second) = chunk.as_slices();
1329 for &s in first.iter().chain(second.iter()) {
1330 if s.abs() > 0.001 {
1331 found_nonzero = true;
1332 break;
1333 }
1334 }
1335 chunk.commit_all();
1336 }
1337 if found_nonzero {
1338 break;
1339 }
1340 }
1341 assert!(
1342 found_nonzero,
1343 "expected non-zero samples from 440Hz sine decode"
1344 );
1345 }
1346
1347 fn run_queue(paths: &[PathBuf]) -> Vec<TrackBoundary> {
1353 let (producer, mut consumer) = rtrb::RingBuffer::new(1 << 16);
1354 let timeline = PlaybackTimeline::new();
1355 let tl = writer(&timeline);
1356 let stop = Arc::new(AtomicBool::new(false));
1357
1358 let drain_stop = Arc::new(AtomicBool::new(false));
1359 let drain_flag = drain_stop.clone();
1360 let drainer = std::thread::spawn(move || {
1361 while !drain_flag.load(Ordering::Relaxed) {
1362 let n = consumer.slots();
1363 if n > 0
1364 && let Ok(chunk) = consumer.read_chunk(n)
1365 {
1366 chunk.commit_all();
1367 }
1368 std::thread::sleep(std::time::Duration::from_micros(200));
1369 }
1370 });
1371
1372 let rest: std::sync::Mutex<Vec<PathBuf>> = std::sync::Mutex::new(paths[1..].to_vec());
1373 let next_track = move || {
1374 let mut rest = rest.lock().ok()?;
1375 if rest.is_empty() {
1376 return None;
1377 }
1378 Some(SourceEntry::from_file(QueueItemId::new(), rest.remove(0)))
1379 };
1380
1381 decode_queue_loop(
1382 SourceEntry::from_file(QueueItemId::new(), paths[0].clone()),
1383 producer,
1384 &stop,
1385 0,
1386 &next_track,
1387 &tl,
1388 None,
1389 crate::config::ReplayGainMode::Off,
1390 0.0,
1391 );
1392
1393 drain_stop.store(true, Ordering::Relaxed);
1394 drainer.join().unwrap();
1395
1396 timeline.boundaries.read().clone()
1397 }
1398
1399 #[test]
1400 fn gapless_continues_when_format_matches() {
1401 let dir = tempfile::tempdir().unwrap();
1402 let a = dir.path().join("a.wav");
1403 let b = dir.path().join("b.wav");
1404 crate::test_utils::generate_wav(&a, 44100, 2, 0.1, 16);
1405 crate::test_utils::generate_wav(&b, 44100, 2, 0.1, 16);
1406
1407 let bounds = run_queue(&[a, b]);
1408 assert_eq!(
1409 bounds.len(),
1410 2,
1411 "same-format tracks should decode gaplessly"
1412 );
1413 }
1414
1415 #[test]
1416 fn gapless_stops_at_sample_rate_change() {
1417 let dir = tempfile::tempdir().unwrap();
1418 let a = dir.path().join("a.wav");
1419 let b = dir.path().join("b.wav");
1420 crate::test_utils::generate_wav(&a, 44100, 2, 0.1, 16);
1421 crate::test_utils::generate_wav(&b, 48000, 2, 0.1, 16);
1422
1423 let bounds = run_queue(&[a, b]);
1424 assert_eq!(
1425 bounds.len(),
1426 1,
1427 "a 48kHz track must not join a 44.1kHz ring buffer"
1428 );
1429 assert_eq!(bounds[0].info.sample_rate, 44100);
1430 }
1431
1432 #[test]
1433 fn gapless_stops_at_channel_change() {
1434 let dir = tempfile::tempdir().unwrap();
1435 let a = dir.path().join("a.wav");
1436 let b = dir.path().join("b.wav");
1437 crate::test_utils::generate_wav(&a, 44100, 2, 0.1, 16);
1438 crate::test_utils::generate_wav(&b, 44100, 1, 0.1, 16);
1439
1440 let bounds = run_queue(&[a, b]);
1441 assert_eq!(
1442 bounds.len(),
1443 1,
1444 "a mono track must not join a stereo ring buffer"
1445 );
1446 assert_eq!(bounds[0].info.channels, 2);
1447 }
1448
1449 #[test]
1450 fn drain_waits_for_the_consumer() {
1451 let (mut producer, mut consumer) = rtrb::RingBuffer::new(64);
1452 for _ in 0..64 {
1453 producer.push(0.0).unwrap();
1454 }
1455 let stop = Arc::new(AtomicBool::new(false));
1456
1457 let reader = std::thread::spawn(move || {
1458 std::thread::sleep(std::time::Duration::from_millis(20));
1459 let chunk = consumer.read_chunk(64).unwrap();
1460 chunk.commit_all();
1461 consumer
1462 });
1463
1464 wait_for_drain(&producer, &stop);
1465 assert_eq!(producer.slots(), 64, "drain must wait for an empty buffer");
1466 drop(reader.join().unwrap());
1467 }
1468
1469 #[test]
1470 fn drain_returns_when_playback_is_torn_down() {
1471 let (producer, consumer) = rtrb::RingBuffer::<f32>::new(64);
1472 let stop = Arc::new(AtomicBool::new(true));
1473 wait_for_drain(&producer, &stop);
1474 drop(consumer);
1475 }
1476
1477 #[test]
1480 fn a_writer_knows_when_its_session_has_ended() {
1481 let timeline = PlaybackTimeline::new();
1482 let tl = writer(&timeline);
1483 assert!(tl.is_current());
1484
1485 timeline.reset();
1486 assert!(!tl.is_current(), "reset must retire the outgoing writer");
1487 assert!(writer(&timeline).is_current());
1488 }
1489
1490 #[test]
1491 fn stale_writes_are_dropped_after_reset() {
1492 let timeline = PlaybackTimeline::new();
1493 let dying = writer(&timeline);
1494 dying.push_boundary(make_boundary(QueueItemId::new(), 0, 0, 2, 44100));
1495 dying.add_written(88200);
1496
1497 timeline.reset();
1498
1499 dying.add_written(4608);
1502 dying.push_boundary(make_boundary(QueueItemId::new(), 0, 0, 2, 44100));
1503
1504 assert_eq!(timeline.samples_written.load(Ordering::Relaxed), 0);
1505 assert!(timeline.boundaries.read().is_empty());
1506 }
1507
1508 #[test]
1509 fn a_dying_decode_thread_cannot_blank_the_transport() {
1510 let timeline = PlaybackTimeline::new();
1511 let dying = writer(&timeline);
1512 dying.push_boundary(make_boundary(QueueItemId::new(), 0, 0, 2, 44100));
1513 dying.add_written(88200);
1514
1515 timeline.reset();
1518 dying.add_written(4608);
1519
1520 let fresh = writer(&timeline);
1521 let id = QueueItemId::new();
1522 let write_offset = fresh.samples_written();
1523 fresh.push_boundary(make_boundary(id, write_offset, 0, 2, 44100));
1524
1525 assert_eq!(write_offset, 0, "first boundary must start at 0");
1526 let (playing, _, _, position_ms) = timeline
1527 .current_playback()
1528 .expect("transport must not go blank at samples_played = 0");
1529 assert_eq!(playing, id);
1530 assert_eq!(position_ms, 0);
1531 }
1532
1533 fn write_garbage(path: &Path) {
1537 std::fs::write(path, b"this is not a wav file").unwrap();
1538 }
1539
1540 #[test]
1541 fn an_unreadable_track_is_skipped_and_the_queue_continues() {
1542 let dir = tempfile::tempdir().unwrap();
1543 let a = dir.path().join("a.wav");
1544 let bad = dir.path().join("bad.wav");
1545 let c = dir.path().join("c.wav");
1546 crate::test_utils::generate_wav(&a, 44100, 2, 0.1, 16);
1547 write_garbage(&bad);
1548 crate::test_utils::generate_wav(&c, 44100, 2, 0.1, 16);
1549
1550 let bounds = run_queue(&[a.clone(), bad, c.clone()]);
1551 let decoded: Vec<_> = bounds.iter().map(|b| b.path.clone()).collect();
1552 assert_eq!(
1553 decoded,
1554 vec![a, c],
1555 "one bad file must not take the rest of the queue with it"
1556 );
1557 }
1558
1559 #[test]
1560 fn a_missing_track_is_skipped_and_the_queue_continues() {
1561 let dir = tempfile::tempdir().unwrap();
1562 let missing = dir.path().join("gone.wav");
1563 let b = dir.path().join("b.wav");
1564 crate::test_utils::generate_wav(&b, 44100, 2, 0.1, 16);
1565
1566 let bounds = run_queue(&[missing, b.clone()]);
1567 assert_eq!(bounds.len(), 1);
1568 assert_eq!(
1569 bounds[0].path, b,
1570 "a bad first track must not end the session"
1571 );
1572 }
1573
1574 #[test]
1575 fn an_entirely_unreadable_queue_terminates() {
1576 let dir = tempfile::tempdir().unwrap();
1577 let bad = dir.path().join("bad.wav");
1578 write_garbage(&bad);
1579
1580 let (producer, _consumer) = rtrb::RingBuffer::<f32>::new(1 << 12);
1583 let timeline = PlaybackTimeline::new();
1584 let tl = writer(&timeline);
1585 let stop = Arc::new(AtomicBool::new(false));
1586
1587 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1588 let counter = calls.clone();
1589 let bad_path = bad.clone();
1590 let next_track = move || {
1591 counter.fetch_add(1, Ordering::Relaxed);
1592 Some(SourceEntry::from_file(QueueItemId::new(), bad_path.clone()))
1593 };
1594
1595 decode_queue_loop(
1596 SourceEntry::from_file(QueueItemId::new(), bad),
1597 producer,
1598 &stop,
1599 0,
1600 &next_track,
1601 &tl,
1602 None,
1603 crate::config::ReplayGainMode::Off,
1604 0.0,
1605 );
1606
1607 assert_eq!(
1609 calls.load(Ordering::Relaxed) + 1,
1610 MAX_CONSECUTIVE_FAILURES as usize
1611 );
1612 assert!(timeline.boundaries.read().is_empty());
1613 }
1614
1615 #[test]
1618 fn landing_samples_converts_frame_timebases() {
1619 let tb = TimeBase::try_from_recip(44100).unwrap();
1621 assert_eq!(
1622 landing_samples(Some(tb), Timestamp::from(44100u32), 44100, 2),
1623 Some(88_200)
1624 );
1625 }
1626
1627 #[test]
1628 fn landing_samples_converts_millisecond_timebases() {
1629 let tb = TimeBase::try_new(1, 1000).unwrap();
1631 assert_eq!(
1632 landing_samples(Some(tb), Timestamp::from(1500u32), 48000, 2),
1633 Some(48000 * 3 / 2 * 2)
1634 );
1635 }
1636
1637 #[test]
1638 fn landing_samples_needs_a_timebase() {
1639 assert_eq!(
1640 landing_samples(None, Timestamp::from(1000u32), 44100, 2),
1641 None
1642 );
1643 }
1644
1645 #[cfg(test)]
1648 fn make_vbr_mp3(dir: &Path) -> PathBuf {
1649 let wav = dir.join("source.wav");
1650 let mp3 = dir.join("source.mp3");
1651 let ok = std::process::Command::new("sox")
1652 .args(["-n", "-r", "44100", "-c", "2"])
1653 .arg(&wav)
1654 .args([
1655 "synth", "30", "sine", "200", "vol", "0.02", ":", "synth", "270", "sine", "880",
1656 "vol", "0.9",
1657 ])
1658 .status()
1659 .expect("sox not installed")
1660 .success();
1661 assert!(ok, "sox failed");
1662 let ok = std::process::Command::new("lame")
1663 .args(["-V", "2", "--quiet"])
1664 .arg(&wav)
1665 .arg(&mp3)
1666 .status()
1667 .expect("lame not installed")
1668 .success();
1669 assert!(ok, "lame failed");
1670 mp3
1671 }
1672
1673 #[test]
1678 #[ignore = "generates a fixture with sox + lame; run with cargo test -- --ignored"]
1679 fn seek_on_vbr_reports_where_it_landed() {
1680 let dir = tempfile::tempdir().unwrap();
1681 let path = make_vbr_mp3(dir.path());
1682 let info = probe_file(&path).unwrap();
1683 let channels = info.channels as u64;
1684 let rate = info.sample_rate as u64;
1685
1686 let seek_ms = 150_000u64;
1687 let (mut producer, mut consumer) = rtrb::RingBuffer::<f32>::new(1 << 16);
1688 let stop = Arc::new(AtomicBool::new(false));
1689 let timeline = PlaybackTimeline::new();
1690 let tl = writer(&timeline);
1691
1692 let drain_stop = stop.clone();
1693 let drained = std::thread::spawn(move || {
1694 let mut total = 0u64;
1695 while !drain_stop.load(Ordering::Relaxed) {
1696 let slots = consumer.slots();
1697 if slots == 0 {
1698 std::thread::sleep(std::time::Duration::from_micros(200));
1699 continue;
1700 }
1701 let chunk = consumer.read_chunk(slots).unwrap();
1702 total += slots as u64;
1703 chunk.commit_all();
1704 }
1705 total
1706 });
1707
1708 let file = File::open(&path).unwrap();
1709 let mss = MediaSourceStream::new(Box::new(file), Default::default());
1710 let mut hint = Hint::new();
1711 hint.with_extension("mp3");
1712 decode_single(
1713 QueueItemId::new(),
1714 &path,
1715 &hint,
1716 mss,
1717 &mut producer,
1718 &stop,
1719 seek_ms,
1720 &tl,
1721 None,
1722 ReplayGainMode::Off,
1723 0.0,
1724 None,
1725 )
1726 .unwrap();
1727
1728 let written = timeline.samples_written.load(Ordering::Relaxed);
1729 stop.store(true, Ordering::Relaxed);
1730 drained.join().unwrap();
1731
1732 let reported_start_ms = {
1733 let bounds = timeline.boundaries.read();
1734 (bounds[0].seek_samples / channels) * 1000 / rate
1735 };
1736 let decoded_ms = (written / channels) * 1000 / rate;
1737
1738 let total_ms = reported_start_ms + decoded_ms;
1740 assert!(
1741 total_ms.abs_diff(info.duration_ms) < 500,
1742 "reported start {}ms + {}ms decoded = {}ms, but the file is {}ms",
1743 reported_start_ms,
1744 decoded_ms,
1745 total_ms,
1746 info.duration_ms
1747 );
1748 assert!(
1750 reported_start_ms.abs_diff(seek_ms) < 100,
1751 "seek to {}ms reported {}ms",
1752 seek_ms,
1753 reported_start_ms
1754 );
1755 }
1756}