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, CODEC_ID_WAVPACK,
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| DecodeError::Decode(e.to_string()))?;
357
358 let track = reader
359 .default_track(TrackType::Audio)
360 .ok_or(DecodeError::NoTrack)?;
361 let codec_params = track
362 .codec_params
363 .as_ref()
364 .and_then(|p| p.audio())
365 .ok_or(DecodeError::NoTrack)?;
366 let is_opus = codec_params.codec == CODEC_ID_OPUS;
367 let sample_rate = if is_opus {
369 48000
370 } else {
371 codec_params.sample_rate.unwrap_or(44100)
372 };
373 let channels = codec_params
374 .channels
375 .as_ref()
376 .map(|c| c.count() as u16)
377 .unwrap_or(2);
378 let bit_depth = if is_opus {
379 None
380 } else {
381 Some(codec_params.bits_per_sample.unwrap_or(16) as u16)
382 };
383 let duration_ms = track_duration_ms(&*reader, track, sample_rate);
384 let codec = codec_name(codec_params.codec);
385
386 let bitrate_kbps = estimate_bitrate_from_codec_params(codec_params);
391
392 Ok(StreamInfo {
393 codec,
394 sample_rate,
395 channels,
396 bit_depth,
397 bitrate_kbps,
398 duration_ms,
399 })
400}
401
402#[allow(clippy::too_many_arguments)]
413pub fn start_decode<N, F>(
414 first: SourceEntry,
415 producer: rtrb::Producer<f32>,
416 seek_ms: u64,
417 next_track: N,
418 timeline: Arc<PlaybackTimeline>,
419 viz_buffer: Option<Arc<VizBuffer>>,
420 rg_mode: ReplayGainMode,
421 pre_amp_db: f64,
422 on_finished: F,
423) -> Result<(StreamInfo, DecodeHandle), DecodeError>
424where
425 N: Fn() -> Option<SourceEntry> + Send + 'static,
426 F: FnOnce() + Send + 'static,
427{
428 let stop = Arc::new(AtomicBool::new(false));
429 let stop_clone = stop.clone();
430 let generation = timeline.generation();
434
435 let thread = thread::Builder::new()
436 .name("koan-decode".into())
437 .spawn(move || {
438 decode_queue_loop(
439 first,
440 producer,
441 &stop_clone,
442 seek_ms,
443 &next_track,
444 &timeline.writer(generation),
445 viz_buffer.as_deref(),
446 rg_mode,
447 pre_amp_db,
448 );
449 if !stop_clone.load(Ordering::Relaxed) {
453 on_finished();
454 }
455 })
456 .map_err(DecodeError::Io)?;
457
458 let placeholder = StreamInfo {
461 codec: String::from("?"),
462 sample_rate: 44100,
463 channels: 2,
464 bit_depth: Some(16),
465 bitrate_kbps: None,
466 duration_ms: 0,
467 };
468
469 Ok((
470 placeholder,
471 DecodeHandle {
472 stop,
473 thread: Some(thread),
474 },
475 ))
476}
477
478#[allow(clippy::too_many_arguments)]
488pub fn start_decode_file<N, F>(
489 initial_id: QueueItemId,
490 path: &Path,
491 producer: rtrb::Producer<f32>,
492 seek_ms: u64,
493 next_track: N,
494 timeline: Arc<PlaybackTimeline>,
495 viz_buffer: Option<Arc<VizBuffer>>,
496 rg_mode: ReplayGainMode,
497 pre_amp_db: f64,
498 on_finished: F,
499) -> Result<(StreamInfo, DecodeHandle), DecodeError>
500where
501 N: Fn() -> Option<(QueueItemId, PathBuf)> + Send + 'static,
502 F: FnOnce() + Send + 'static,
503{
504 let info = probe_file(path)?;
505 let first = SourceEntry::from_file(initial_id, path.to_path_buf());
506 let (_, handle) = start_decode(
507 first,
508 producer,
509 seek_ms,
510 move || {
511 let (id, p) = next_track()?;
512 Some(SourceEntry::from_file(id, p))
513 },
514 timeline,
515 viz_buffer,
516 rg_mode,
517 pre_amp_db,
518 on_finished,
519 )?;
520 Ok((info, handle))
521}
522
523const MAX_CONSECUTIVE_FAILURES: u32 = 32;
531
532#[allow(clippy::too_many_arguments)]
543fn decode_queue_loop<N>(
544 first: SourceEntry,
545 mut producer: rtrb::Producer<f32>,
546 stop: &AtomicBool,
547 initial_seek_ms: u64,
548 next_track: &N,
549 timeline: &TimelineWriter<'_>,
550 viz_buffer: Option<&VizBuffer>,
551 rg_mode: ReplayGainMode,
552 pre_amp_db: f64,
553) where
554 N: Fn() -> Option<SourceEntry>,
555{
556 if let Some(viz) = viz_buffer {
559 viz.reset();
560 }
561
562 let mut pending = Some(first);
563 let mut seek_ms = initial_seek_ms;
564 let mut format: Option<PcmFormat> = None;
565 let mut failures: u32 = 0;
566
567 while let Some(entry) = pending.take() {
568 if stop.load(Ordering::Relaxed) || !timeline.is_current() {
569 break;
570 }
571
572 let SourceEntry {
573 id,
574 path,
575 hint,
576 make_mss,
577 } = entry;
578
579 let outcome = make_mss().map_err(DecodeError::Io).and_then(|mss| {
580 decode_single(
581 id,
582 &path,
583 &hint,
584 mss,
585 &mut producer,
586 stop,
587 seek_ms,
588 timeline,
589 viz_buffer,
590 rg_mode,
591 pre_amp_db,
592 format,
593 )
594 });
595
596 match outcome {
597 Ok(Decoded::Complete(decoded_format)) => {
598 format = Some(decoded_format);
599 failures = 0;
600 }
601 Ok(Decoded::FormatMismatch) => break,
602 Err(e) => {
603 if stop.load(Ordering::Relaxed) {
604 break;
605 }
606 failures += 1;
607 log::error!("skipping {}: {}", path.display(), e);
608 if failures >= MAX_CONSECUTIVE_FAILURES {
609 log::error!(
610 "{} sources failed in a row, decode thread giving up",
611 failures
612 );
613 break;
614 }
615 }
616 }
617
618 seek_ms = 0;
619 pending = (next_track)();
620 match pending {
621 Some(ref next) => log::info!("gapless transition → {}", next.path.display()),
622 None => log::info!("playlist exhausted, decode thread finishing"),
623 }
624 }
625
626 wait_for_drain(&producer, stop);
627}
628
629fn wait_for_drain(producer: &rtrb::Producer<f32>, stop: &AtomicBool) {
635 let capacity = producer.buffer().capacity();
636 while !stop.load(Ordering::Relaxed) && !producer.is_abandoned() {
637 if producer.slots() >= capacity {
638 return;
639 }
640 thread::sleep(std::time::Duration::from_millis(2));
641 }
642}
643
644type PcmFormat = (u32, u16);
652
653enum Decoded {
655 Complete(PcmFormat),
657 FormatMismatch,
660}
661
662#[allow(clippy::too_many_arguments)]
667fn decode_single(
668 queue_item_id: QueueItemId,
669 path: &Path,
670 hint: &Hint,
671 mss: MediaSourceStream<'_>,
672 producer: &mut rtrb::Producer<f32>,
673 stop: &AtomicBool,
674 seek_ms: u64,
675 timeline: &TimelineWriter<'_>,
676 viz_buffer: Option<&VizBuffer>,
677 rg_mode: ReplayGainMode,
678 pre_amp_db: f64,
679 expected: Option<PcmFormat>,
680) -> Result<Decoded, DecodeError> {
681 let mut reader = symphonia::default::get_probe()
682 .probe(
683 hint,
684 mss,
685 FormatOptions::default(),
686 MetadataOptions::default(),
687 )
688 .map_err(|e| DecodeError::Decode(e.to_string()))?;
689
690 let track = reader
691 .default_track(TrackType::Audio)
692 .ok_or(DecodeError::NoTrack)?;
693 let track_id = track.id;
694 let time_base = track.time_base;
695 let codec_params = track
696 .codec_params
697 .as_ref()
698 .and_then(|p| p.audio())
699 .ok_or(DecodeError::NoTrack)?;
700 let is_opus_codec = codec_params.codec == CODEC_ID_OPUS;
701
702 let sample_rate = if is_opus_codec {
704 48000
705 } else {
706 codec_params.sample_rate.unwrap_or(44100)
707 };
708 let channels = codec_params
709 .channels
710 .as_ref()
711 .map(|c| c.count() as u16)
712 .unwrap_or(2);
713
714 let duration_ms = track_duration_ms(&*reader, track, sample_rate);
715
716 let mut bitrate_kbps = estimate_bitrate_from_codec_params(codec_params);
718 if bitrate_kbps.is_none()
719 && is_opus_codec
720 && let Ok(meta) = std::fs::metadata(path)
721 && duration_ms > 0
722 {
723 bitrate_kbps = Some((meta.len() * 8 / duration_ms) as u32);
724 }
725
726 let info = StreamInfo {
727 codec: codec_name(codec_params.codec),
728 sample_rate,
729 channels,
730 bit_depth: if is_opus_codec {
731 None
732 } else {
733 Some(codec_params.bits_per_sample.unwrap_or(16) as u16)
734 },
735 bitrate_kbps,
736 duration_ms,
737 };
738
739 if let Some(expected) = expected
740 && expected != (sample_rate, channels)
741 {
742 log::info!(
743 "format change at {}: {}Hz/{}ch → {}Hz/{}ch, restarting audio engine",
744 path.display(),
745 expected.0,
746 expected.1,
747 sample_rate,
748 channels
749 );
750 return Ok(Decoded::FormatMismatch);
751 }
752
753 let mut symphonia_decoder = if is_opus_codec {
755 None
756 } else {
757 Some(
758 symphonia::default::get_codecs()
759 .make_audio_decoder(codec_params, &AudioDecoderOptions::default())
760 .map_err(|_| DecodeError::UnsupportedCodec)?,
761 )
762 };
763 let mut opus_bridge = if is_opus_codec {
764 Some(OpusBridge::new(codec_params).map_err(|e| DecodeError::Decode(e.to_string()))?)
765 } else {
766 None
767 };
768
769 let mut seek_samples = 0;
779 if seek_ms > 0 {
780 let seeked = reader
781 .seek(
782 SeekMode::Accurate,
783 SeekTo::Time {
784 time: Time::from_millis_u64(seek_ms),
785 track_id: Some(track_id),
786 },
787 )
788 .map_err(|e| DecodeError::Decode(format!("seek failed: {}", e)))?;
789 seek_samples = landing_samples(time_base, seeked.actual_ts, sample_rate, channels)
790 .unwrap_or(seek_ms * sample_rate as u64 * channels as u64 / 1000);
791 if let Some(ref mut dec) = symphonia_decoder {
792 dec.reset();
793 }
794 if let Some(ref mut opus) = opus_bridge {
795 opus.reset();
796 }
797 }
798
799 let write_offset = timeline.samples_written();
801 timeline.push_boundary(TrackBoundary {
802 id: queue_item_id,
803 path: path.to_path_buf(),
804 info,
805 sample_offset: write_offset,
806 samples_written: 0,
807 seek_samples,
808 });
809
810 let rg_gain = if rg_mode != ReplayGainMode::Off {
812 match crate::audio::replaygain::read_tags(path) {
813 Ok(rg_info) => {
814 let selected = crate::audio::replaygain::select_gain(&rg_info, rg_mode);
815 if let Some((gain_db, _)) = selected {
816 log::info!(
817 "replaygain: applying {:.2} dB ({:?}) to {}",
818 gain_db,
819 rg_mode,
820 path.display()
821 );
822 }
823 selected
824 }
825 Err(e) => {
826 log::debug!("replaygain: no tags for {}: {}", path.display(), e);
827 None
828 }
829 }
830 } else {
831 None
832 };
833 let mut rg_scratch: Vec<f32> = Vec::new();
834
835 let mut sample_buf: Vec<f32> = Vec::new();
836
837 loop {
838 if stop.load(Ordering::Relaxed) || !timeline.is_current() {
839 return Ok(Decoded::Complete((sample_rate, channels)));
840 }
841
842 let packet = match reader.next_packet() {
843 Ok(Some(p)) => p,
844 Ok(None) => return Ok(Decoded::Complete((sample_rate, channels))),
845 Err(e) => return Err(DecodeError::Decode(e.to_string())),
846 };
847
848 if packet.track_id != track_id {
849 continue;
850 }
851
852 let samples: &[f32] = if let Some(ref mut opus) = opus_bridge {
854 match opus.decode_packet(&packet.data) {
855 Ok(s) => s,
856 Err(e) => {
857 log::warn!("opus decode error (skipping packet): {}", e);
858 continue;
859 }
860 }
861 } else {
862 let decoder = symphonia_decoder.as_mut().unwrap();
863 let decoded = match decoder.decode(&packet) {
864 Ok(d) => d,
865 Err(symphonia::core::errors::Error::DecodeError(e)) => {
866 log::warn!("decode error (skipping packet): {}", e);
867 continue;
868 }
869 Err(e) => return Err(DecodeError::Decode(e.to_string())),
870 };
871
872 let spec = decoded.spec();
873 let (decoded_rate, decoded_channels) = (spec.rate(), spec.channels().count() as u16);
874 if (decoded_rate, decoded_channels) != (sample_rate, channels) {
878 log::warn!(
879 "{}: decoded {}Hz/{}ch but stream declares {}Hz/{}ch, restarting audio engine",
880 path.display(),
881 decoded_rate,
882 decoded_channels,
883 sample_rate,
884 channels
885 );
886 return Ok(Decoded::FormatMismatch);
887 }
888 decoded.copy_to_vec_interleaved(&mut sample_buf);
889 &sample_buf[..]
890 };
891
892 if samples.is_empty() {
893 continue;
894 }
895
896 let samples = if let Some((gain_db, peak)) = rg_gain {
899 rg_scratch.clear();
900 rg_scratch.extend_from_slice(samples);
901 crate::audio::replaygain::apply_gain(&mut rg_scratch, gain_db, peak, pre_amp_db);
902 &rg_scratch[..]
903 } else {
904 samples
905 };
906
907 let mut offset = 0;
914 while offset < samples.len() {
915 if stop.load(Ordering::Relaxed) || !timeline.is_current() {
918 return Ok(Decoded::Complete((sample_rate, channels)));
919 }
920
921 let slots = producer.slots();
922 if slots == 0 {
923 thread::sleep(std::time::Duration::from_micros(500));
924 continue;
925 }
926
927 let chunk_size = slots.min(samples.len() - offset);
928 if let Ok(mut chunk) = producer.write_chunk_uninit(chunk_size) {
929 let to_write = &samples[offset..offset + chunk_size];
930 let (first, second) = chunk.as_mut_slices();
931 let first_len = first.len().min(to_write.len());
932 for (slot, &val) in first.iter_mut().zip(&to_write[..first_len]) {
933 slot.write(val);
934 }
935 if first_len < to_write.len() {
936 for (slot, &val) in second.iter_mut().zip(&to_write[first_len..]) {
937 slot.write(val);
938 }
939 }
940 unsafe { chunk.commit_all() };
944
945 if let Some(viz) = viz_buffer {
947 viz.push_samples(to_write, channels, sample_rate);
948 }
949
950 offset += chunk_size;
951 }
952 }
953
954 timeline.add_written(samples.len() as u64);
955 }
956}
957
958fn landing_samples(
964 time_base: Option<TimeBase>,
965 actual_ts: Timestamp,
966 sample_rate: u32,
967 channels: u16,
968) -> Option<u64> {
969 let (seconds, nanos) = time_base?.calc_time(actual_ts)?.parts();
970 let rate = sample_rate as u64;
971 let frames = seconds.max(0) as u64 * rate + (nanos as u64 * rate) / 1_000_000_000;
972 Some(frames * channels as u64)
973}
974
975pub(crate) fn track_duration_ms(
982 reader: &(impl FormatReader + ?Sized),
983 track: &Track,
984 sample_rate: u32,
985) -> u64 {
986 fn to_ms(time_base: Option<TimeBase>, duration: Option<Duration>) -> Option<u64> {
987 let time = time_base?.calc_duration(duration?)?;
988 Some(time.as_millis().max(0) as u64)
989 }
990
991 let media = reader.media_info();
992 to_ms(track.time_base, track.duration)
993 .or_else(|| to_ms(media.time_base, media.duration))
994 .or_else(|| {
995 track
996 .num_frames
997 .map(|frames| frames * 1000 / sample_rate as u64)
998 })
999 .unwrap_or(0)
1000}
1001
1002fn estimate_bitrate_from_codec_params(params: &AudioCodecParameters) -> Option<u32> {
1008 let is_lossy = matches!(
1009 params.codec,
1010 CODEC_ID_MP3 | CODEC_ID_AAC | CODEC_ID_VORBIS | CODEC_ID_OPUS
1011 );
1012 if !is_lossy {
1013 return None;
1014 }
1015
1016 let bpcs = params.bits_per_coded_sample?;
1019 let sr = params.sample_rate?;
1020 let channels = params
1021 .channels
1022 .as_ref()
1023 .map(|c| c.count() as u32)
1024 .unwrap_or(2);
1025 Some(bpcs * sr * channels / 1000)
1026}
1027
1028pub fn codec_name(codec: AudioCodecId) -> String {
1029 match codec {
1030 CODEC_ID_FLAC => "FLAC",
1031 CODEC_ID_MP3 => "MP3",
1032 CODEC_ID_AAC => "AAC",
1033 CODEC_ID_VORBIS => "Vorbis",
1034 CODEC_ID_OPUS => "Opus",
1035 CODEC_ID_ALAC => "ALAC",
1036 CODEC_ID_WAVPACK => "WavPack",
1037 CODEC_ID_PCM_S16LE => "PCM/16",
1038 CODEC_ID_PCM_S24LE => "PCM/24",
1039 CODEC_ID_PCM_S32LE => "PCM/32",
1040 CODEC_ID_PCM_F32LE => "PCM/f32",
1041 other => return format!("Unknown({:?})", other),
1042 }
1043 .to_string()
1044}
1045
1046#[cfg(test)]
1047mod tests {
1048 use std::path::PathBuf;
1049 use std::sync::atomic::Ordering;
1050
1051 use super::*;
1052 use crate::player::state::QueueItemId;
1053
1054 fn make_info(sample_rate: u32, channels: u16) -> StreamInfo {
1055 StreamInfo {
1056 codec: "FLAC".to_string(),
1057 sample_rate,
1058 channels,
1059 bit_depth: Some(16),
1060 bitrate_kbps: None,
1061 duration_ms: 10_000,
1062 }
1063 }
1064
1065 fn make_boundary(
1066 id: QueueItemId,
1067 sample_offset: u64,
1068 seek_samples: u64,
1069 channels: u16,
1070 sample_rate: u32,
1071 ) -> TrackBoundary {
1072 TrackBoundary {
1073 id,
1074 path: PathBuf::from("/music/track.flac"),
1075 info: make_info(sample_rate, channels),
1076 sample_offset,
1077 samples_written: 0,
1078 seek_samples,
1079 }
1080 }
1081
1082 fn writer(timeline: &PlaybackTimeline) -> TimelineWriter<'_> {
1084 timeline.writer(timeline.generation())
1085 }
1086
1087 #[test]
1090 fn test_timeline_single_track() {
1091 let timeline = PlaybackTimeline::new();
1095 let tl = writer(&timeline);
1096 let id = QueueItemId::new();
1097 tl.push_boundary(make_boundary(id, 0, 0, 2, 44100));
1099 tl.add_written(88200); timeline.samples_played.store(88200, Ordering::Relaxed);
1103
1104 let result = timeline.current_playback();
1105 assert!(
1106 result.is_some(),
1107 "expected Some for single track with samples played"
1108 );
1109 let (result_id, _path, _info, position_ms) = result.unwrap();
1110 assert_eq!(result_id, id);
1111 assert_eq!(
1112 position_ms, 1000,
1113 "1 second of 44100 Hz stereo should be 1000 ms"
1114 );
1115 }
1116
1117 #[test]
1118 fn test_timeline_gapless_transition() {
1119 let timeline = PlaybackTimeline::new();
1123 let tl = writer(&timeline);
1124 let id1 = QueueItemId::new();
1125 let id2 = QueueItemId::new();
1126
1127 tl.push_boundary(make_boundary(id1, 0, 0, 2, 44100));
1129 tl.add_written(88200);
1130
1131 tl.push_boundary(make_boundary(id2, 88200, 0, 2, 44100));
1133 tl.add_written(44100); timeline.samples_played.store(90000, Ordering::Relaxed);
1137
1138 let result = timeline.current_playback();
1139 assert!(result.is_some());
1140 let (result_id, _path, _info, position_ms) = result.unwrap();
1141 assert_eq!(
1142 result_id, id2,
1143 "playback head past boundary should report second track"
1144 );
1145 assert_eq!(position_ms, 20, "position within track 2 should be ~20 ms");
1147 }
1148
1149 #[test]
1150 fn test_timeline_zero_samples() {
1151 let timeline = PlaybackTimeline::new();
1154 let tl = writer(&timeline);
1155 let id = QueueItemId::new();
1156 tl.push_boundary(make_boundary(id, 0, 0, 2, 44100));
1157 tl.add_written(1000);
1158 timeline.samples_played.store(0, Ordering::Relaxed);
1159
1160 let result = timeline.current_playback();
1161 assert!(
1162 result.is_some(),
1163 "expected Some at 0 samples played with a boundary at offset 0"
1164 );
1165 let (result_id, _path, _info, position_ms) = result.unwrap();
1166 assert_eq!(result_id, id);
1167 assert_eq!(position_ms, 0);
1168 }
1169
1170 #[test]
1171 fn test_timeline_past_all_boundaries() {
1172 let timeline = PlaybackTimeline::new();
1175 let tl = writer(&timeline);
1176 let id1 = QueueItemId::new();
1177 let id2 = QueueItemId::new();
1178
1179 tl.push_boundary(make_boundary(id1, 0, 0, 2, 44100));
1180 tl.add_written(88200);
1181 tl.push_boundary(make_boundary(id2, 88200, 0, 2, 44100));
1182 tl.add_written(88200);
1183
1184 timeline
1186 .samples_played
1187 .store(999_999_999, Ordering::Relaxed);
1188
1189 let result = timeline.current_playback();
1190 assert!(result.is_some());
1191 let (result_id, _path, _info, _position_ms) = result.unwrap();
1192 assert_eq!(
1193 result_id, id2,
1194 "samples past all boundaries should report the last track"
1195 );
1196 }
1197
1198 #[test]
1199 fn test_timeline_seek_offset() {
1200 let timeline = PlaybackTimeline::new();
1204 let tl = writer(&timeline);
1205 let id = QueueItemId::new();
1206 let seek_samples = 88200u64; tl.push_boundary(make_boundary(id, 0, seek_samples, 2, 44100));
1208 tl.add_written(44100); timeline.samples_played.store(0, Ordering::Relaxed);
1211
1212 let result = timeline.current_playback();
1213 assert!(result.is_some());
1214 let (_result_id, _path, _info, position_ms) = result.unwrap();
1215 assert_eq!(
1217 position_ms, 1000,
1218 "position should include seek offset of 1000 ms"
1219 );
1220 }
1221
1222 #[test]
1223 fn test_timeline_reset() {
1224 let timeline = PlaybackTimeline::new();
1226 let tl = writer(&timeline);
1227 let id = QueueItemId::new();
1228 tl.push_boundary(make_boundary(id, 0, 0, 2, 44100));
1229 tl.add_written(88200);
1230 timeline.samples_played.store(44100, Ordering::Relaxed);
1231
1232 assert!(timeline.current_playback().is_some());
1234
1235 timeline.reset();
1236
1237 assert!(
1238 timeline.current_playback().is_none(),
1239 "after reset, current_playback should return None"
1240 );
1241 assert_eq!(
1242 timeline.samples_played.load(Ordering::Relaxed),
1243 0,
1244 "samples_played should be 0 after reset"
1245 );
1246 assert_eq!(
1247 timeline.samples_written.load(Ordering::Relaxed),
1248 0,
1249 "samples_written should be 0 after reset"
1250 );
1251 }
1252
1253 #[test]
1256 fn probe_file_extracts_stream_info() {
1257 let dir = tempfile::tempdir().unwrap();
1258 let wav_path = dir.path().join("probe_test.wav");
1259 crate::test_utils::generate_wav(&wav_path, 44100, 2, 1.0, 16);
1260
1261 let info = probe_file(&wav_path).expect("probe_file should succeed on a valid WAV");
1262 assert_eq!(info.sample_rate, 44100, "sample rate mismatch");
1263 assert_eq!(info.channels, 2, "channel count mismatch");
1264 assert_eq!(info.bit_depth, Some(16), "bit depth mismatch");
1265 assert!(
1266 info.duration_ms > 900 && info.duration_ms < 1100,
1267 "duration should be ~1000ms, got {}",
1268 info.duration_ms
1269 );
1270 assert!(
1271 info.codec.contains("PCM"),
1272 "codec should be PCM variant, got {}",
1273 info.codec
1274 );
1275 }
1276
1277 #[test]
1278 fn decode_single_produces_samples() {
1279 let dir = tempfile::tempdir().unwrap();
1280 let wav_path = dir.path().join("tone.wav");
1281 crate::test_utils::generate_wav_tone(&wav_path, 44100, 440.0, 0.1);
1283
1284 let (mut producer, mut consumer) = rtrb::RingBuffer::new(44100 * 2);
1286
1287 let timeline = PlaybackTimeline::new();
1288 let tl = writer(&timeline);
1289 let stop = Arc::new(AtomicBool::new(false));
1290
1291 let id = QueueItemId::new();
1292 let entry = SourceEntry::from_file(id, wav_path.clone());
1293 let hint = entry.hint.clone();
1294 let mss = (entry.make_mss)().expect("should open WAV file");
1295
1296 let result = decode_single(
1297 id,
1298 &wav_path,
1299 &hint,
1300 mss,
1301 &mut producer,
1302 &stop,
1303 0,
1304 &tl,
1305 None,
1306 crate::config::ReplayGainMode::Off,
1307 0.0,
1308 None,
1309 );
1310 assert!(
1311 matches!(result, Ok(Decoded::Complete((44100, 1)))),
1312 "decode_single should complete at the source format"
1313 );
1314
1315 let available = consumer.slots();
1317 assert!(available > 0, "expected samples in ring buffer, got 0");
1318
1319 let mut found_nonzero = false;
1321 while consumer.slots() > 0 {
1322 if let Ok(chunk) = consumer.read_chunk(consumer.slots().min(1024)) {
1323 let (first, second) = chunk.as_slices();
1324 for &s in first.iter().chain(second.iter()) {
1325 if s.abs() > 0.001 {
1326 found_nonzero = true;
1327 break;
1328 }
1329 }
1330 chunk.commit_all();
1331 }
1332 if found_nonzero {
1333 break;
1334 }
1335 }
1336 assert!(
1337 found_nonzero,
1338 "expected non-zero samples from 440Hz sine decode"
1339 );
1340 }
1341
1342 fn run_queue(paths: &[PathBuf]) -> Vec<TrackBoundary> {
1348 let (producer, mut consumer) = rtrb::RingBuffer::new(1 << 16);
1349 let timeline = PlaybackTimeline::new();
1350 let tl = writer(&timeline);
1351 let stop = Arc::new(AtomicBool::new(false));
1352
1353 let drain_stop = Arc::new(AtomicBool::new(false));
1354 let drain_flag = drain_stop.clone();
1355 let drainer = std::thread::spawn(move || {
1356 while !drain_flag.load(Ordering::Relaxed) {
1357 let n = consumer.slots();
1358 if n > 0
1359 && let Ok(chunk) = consumer.read_chunk(n)
1360 {
1361 chunk.commit_all();
1362 }
1363 std::thread::sleep(std::time::Duration::from_micros(200));
1364 }
1365 });
1366
1367 let rest: std::sync::Mutex<Vec<PathBuf>> = std::sync::Mutex::new(paths[1..].to_vec());
1368 let next_track = move || {
1369 let mut rest = rest.lock().ok()?;
1370 if rest.is_empty() {
1371 return None;
1372 }
1373 Some(SourceEntry::from_file(QueueItemId::new(), rest.remove(0)))
1374 };
1375
1376 decode_queue_loop(
1377 SourceEntry::from_file(QueueItemId::new(), paths[0].clone()),
1378 producer,
1379 &stop,
1380 0,
1381 &next_track,
1382 &tl,
1383 None,
1384 crate::config::ReplayGainMode::Off,
1385 0.0,
1386 );
1387
1388 drain_stop.store(true, Ordering::Relaxed);
1389 drainer.join().unwrap();
1390
1391 timeline.boundaries.read().clone()
1392 }
1393
1394 #[test]
1395 fn gapless_continues_when_format_matches() {
1396 let dir = tempfile::tempdir().unwrap();
1397 let a = dir.path().join("a.wav");
1398 let b = dir.path().join("b.wav");
1399 crate::test_utils::generate_wav(&a, 44100, 2, 0.1, 16);
1400 crate::test_utils::generate_wav(&b, 44100, 2, 0.1, 16);
1401
1402 let bounds = run_queue(&[a, b]);
1403 assert_eq!(
1404 bounds.len(),
1405 2,
1406 "same-format tracks should decode gaplessly"
1407 );
1408 }
1409
1410 #[test]
1411 fn gapless_stops_at_sample_rate_change() {
1412 let dir = tempfile::tempdir().unwrap();
1413 let a = dir.path().join("a.wav");
1414 let b = dir.path().join("b.wav");
1415 crate::test_utils::generate_wav(&a, 44100, 2, 0.1, 16);
1416 crate::test_utils::generate_wav(&b, 48000, 2, 0.1, 16);
1417
1418 let bounds = run_queue(&[a, b]);
1419 assert_eq!(
1420 bounds.len(),
1421 1,
1422 "a 48kHz track must not join a 44.1kHz ring buffer"
1423 );
1424 assert_eq!(bounds[0].info.sample_rate, 44100);
1425 }
1426
1427 #[test]
1428 fn gapless_stops_at_channel_change() {
1429 let dir = tempfile::tempdir().unwrap();
1430 let a = dir.path().join("a.wav");
1431 let b = dir.path().join("b.wav");
1432 crate::test_utils::generate_wav(&a, 44100, 2, 0.1, 16);
1433 crate::test_utils::generate_wav(&b, 44100, 1, 0.1, 16);
1434
1435 let bounds = run_queue(&[a, b]);
1436 assert_eq!(
1437 bounds.len(),
1438 1,
1439 "a mono track must not join a stereo ring buffer"
1440 );
1441 assert_eq!(bounds[0].info.channels, 2);
1442 }
1443
1444 #[test]
1445 fn drain_waits_for_the_consumer() {
1446 let (mut producer, mut consumer) = rtrb::RingBuffer::new(64);
1447 for _ in 0..64 {
1448 producer.push(0.0).unwrap();
1449 }
1450 let stop = Arc::new(AtomicBool::new(false));
1451
1452 let reader = std::thread::spawn(move || {
1453 std::thread::sleep(std::time::Duration::from_millis(20));
1454 let chunk = consumer.read_chunk(64).unwrap();
1455 chunk.commit_all();
1456 consumer
1457 });
1458
1459 wait_for_drain(&producer, &stop);
1460 assert_eq!(producer.slots(), 64, "drain must wait for an empty buffer");
1461 drop(reader.join().unwrap());
1462 }
1463
1464 #[test]
1465 fn drain_returns_when_playback_is_torn_down() {
1466 let (producer, consumer) = rtrb::RingBuffer::<f32>::new(64);
1467 let stop = Arc::new(AtomicBool::new(true));
1468 wait_for_drain(&producer, &stop);
1469 drop(consumer);
1470 }
1471
1472 #[test]
1475 fn a_writer_knows_when_its_session_has_ended() {
1476 let timeline = PlaybackTimeline::new();
1477 let tl = writer(&timeline);
1478 assert!(tl.is_current());
1479
1480 timeline.reset();
1481 assert!(!tl.is_current(), "reset must retire the outgoing writer");
1482 assert!(writer(&timeline).is_current());
1483 }
1484
1485 #[test]
1486 fn stale_writes_are_dropped_after_reset() {
1487 let timeline = PlaybackTimeline::new();
1488 let dying = writer(&timeline);
1489 dying.push_boundary(make_boundary(QueueItemId::new(), 0, 0, 2, 44100));
1490 dying.add_written(88200);
1491
1492 timeline.reset();
1493
1494 dying.add_written(4608);
1497 dying.push_boundary(make_boundary(QueueItemId::new(), 0, 0, 2, 44100));
1498
1499 assert_eq!(timeline.samples_written.load(Ordering::Relaxed), 0);
1500 assert!(timeline.boundaries.read().is_empty());
1501 }
1502
1503 #[test]
1504 fn a_dying_decode_thread_cannot_blank_the_transport() {
1505 let timeline = PlaybackTimeline::new();
1506 let dying = writer(&timeline);
1507 dying.push_boundary(make_boundary(QueueItemId::new(), 0, 0, 2, 44100));
1508 dying.add_written(88200);
1509
1510 timeline.reset();
1513 dying.add_written(4608);
1514
1515 let fresh = writer(&timeline);
1516 let id = QueueItemId::new();
1517 let write_offset = fresh.samples_written();
1518 fresh.push_boundary(make_boundary(id, write_offset, 0, 2, 44100));
1519
1520 assert_eq!(write_offset, 0, "first boundary must start at 0");
1521 let (playing, _, _, position_ms) = timeline
1522 .current_playback()
1523 .expect("transport must not go blank at samples_played = 0");
1524 assert_eq!(playing, id);
1525 assert_eq!(position_ms, 0);
1526 }
1527
1528 fn write_garbage(path: &Path) {
1532 std::fs::write(path, b"this is not a wav file").unwrap();
1533 }
1534
1535 #[test]
1536 fn an_unreadable_track_is_skipped_and_the_queue_continues() {
1537 let dir = tempfile::tempdir().unwrap();
1538 let a = dir.path().join("a.wav");
1539 let bad = dir.path().join("bad.wav");
1540 let c = dir.path().join("c.wav");
1541 crate::test_utils::generate_wav(&a, 44100, 2, 0.1, 16);
1542 write_garbage(&bad);
1543 crate::test_utils::generate_wav(&c, 44100, 2, 0.1, 16);
1544
1545 let bounds = run_queue(&[a.clone(), bad, c.clone()]);
1546 let decoded: Vec<_> = bounds.iter().map(|b| b.path.clone()).collect();
1547 assert_eq!(
1548 decoded,
1549 vec![a, c],
1550 "one bad file must not take the rest of the queue with it"
1551 );
1552 }
1553
1554 #[test]
1555 fn a_missing_track_is_skipped_and_the_queue_continues() {
1556 let dir = tempfile::tempdir().unwrap();
1557 let missing = dir.path().join("gone.wav");
1558 let b = dir.path().join("b.wav");
1559 crate::test_utils::generate_wav(&b, 44100, 2, 0.1, 16);
1560
1561 let bounds = run_queue(&[missing, b.clone()]);
1562 assert_eq!(bounds.len(), 1);
1563 assert_eq!(
1564 bounds[0].path, b,
1565 "a bad first track must not end the session"
1566 );
1567 }
1568
1569 #[test]
1570 fn an_entirely_unreadable_queue_terminates() {
1571 let dir = tempfile::tempdir().unwrap();
1572 let bad = dir.path().join("bad.wav");
1573 write_garbage(&bad);
1574
1575 let (producer, _consumer) = rtrb::RingBuffer::<f32>::new(1 << 12);
1578 let timeline = PlaybackTimeline::new();
1579 let tl = writer(&timeline);
1580 let stop = Arc::new(AtomicBool::new(false));
1581
1582 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1583 let counter = calls.clone();
1584 let bad_path = bad.clone();
1585 let next_track = move || {
1586 counter.fetch_add(1, Ordering::Relaxed);
1587 Some(SourceEntry::from_file(QueueItemId::new(), bad_path.clone()))
1588 };
1589
1590 decode_queue_loop(
1591 SourceEntry::from_file(QueueItemId::new(), bad),
1592 producer,
1593 &stop,
1594 0,
1595 &next_track,
1596 &tl,
1597 None,
1598 crate::config::ReplayGainMode::Off,
1599 0.0,
1600 );
1601
1602 assert_eq!(
1604 calls.load(Ordering::Relaxed) + 1,
1605 MAX_CONSECUTIVE_FAILURES as usize
1606 );
1607 assert!(timeline.boundaries.read().is_empty());
1608 }
1609
1610 #[test]
1613 fn landing_samples_converts_frame_timebases() {
1614 let tb = TimeBase::try_from_recip(44100).unwrap();
1616 assert_eq!(
1617 landing_samples(Some(tb), Timestamp::from(44100u32), 44100, 2),
1618 Some(88_200)
1619 );
1620 }
1621
1622 #[test]
1623 fn landing_samples_converts_millisecond_timebases() {
1624 let tb = TimeBase::try_new(1, 1000).unwrap();
1626 assert_eq!(
1627 landing_samples(Some(tb), Timestamp::from(1500u32), 48000, 2),
1628 Some(48000 * 3 / 2 * 2)
1629 );
1630 }
1631
1632 #[test]
1633 fn landing_samples_needs_a_timebase() {
1634 assert_eq!(
1635 landing_samples(None, Timestamp::from(1000u32), 44100, 2),
1636 None
1637 );
1638 }
1639
1640 #[cfg(test)]
1643 fn make_vbr_mp3(dir: &Path) -> PathBuf {
1644 let wav = dir.join("source.wav");
1645 let mp3 = dir.join("source.mp3");
1646 let ok = std::process::Command::new("sox")
1647 .args(["-n", "-r", "44100", "-c", "2"])
1648 .arg(&wav)
1649 .args([
1650 "synth", "30", "sine", "200", "vol", "0.02", ":", "synth", "270", "sine", "880",
1651 "vol", "0.9",
1652 ])
1653 .status()
1654 .expect("sox not installed")
1655 .success();
1656 assert!(ok, "sox failed");
1657 let ok = std::process::Command::new("lame")
1658 .args(["-V", "2", "--quiet"])
1659 .arg(&wav)
1660 .arg(&mp3)
1661 .status()
1662 .expect("lame not installed")
1663 .success();
1664 assert!(ok, "lame failed");
1665 mp3
1666 }
1667
1668 #[test]
1673 #[ignore = "generates a fixture with sox + lame; run with cargo test -- --ignored"]
1674 fn seek_on_vbr_reports_where_it_landed() {
1675 let dir = tempfile::tempdir().unwrap();
1676 let path = make_vbr_mp3(dir.path());
1677 let info = probe_file(&path).unwrap();
1678 let channels = info.channels as u64;
1679 let rate = info.sample_rate as u64;
1680
1681 let seek_ms = 150_000u64;
1682 let (mut producer, mut consumer) = rtrb::RingBuffer::<f32>::new(1 << 16);
1683 let stop = Arc::new(AtomicBool::new(false));
1684 let timeline = PlaybackTimeline::new();
1685 let tl = writer(&timeline);
1686
1687 let drain_stop = stop.clone();
1688 let drained = std::thread::spawn(move || {
1689 let mut total = 0u64;
1690 while !drain_stop.load(Ordering::Relaxed) {
1691 let slots = consumer.slots();
1692 if slots == 0 {
1693 std::thread::sleep(std::time::Duration::from_micros(200));
1694 continue;
1695 }
1696 let chunk = consumer.read_chunk(slots).unwrap();
1697 total += slots as u64;
1698 chunk.commit_all();
1699 }
1700 total
1701 });
1702
1703 let file = File::open(&path).unwrap();
1704 let mss = MediaSourceStream::new(Box::new(file), Default::default());
1705 let mut hint = Hint::new();
1706 hint.with_extension("mp3");
1707 decode_single(
1708 QueueItemId::new(),
1709 &path,
1710 &hint,
1711 mss,
1712 &mut producer,
1713 &stop,
1714 seek_ms,
1715 &tl,
1716 None,
1717 ReplayGainMode::Off,
1718 0.0,
1719 None,
1720 )
1721 .unwrap();
1722
1723 let written = timeline.samples_written.load(Ordering::Relaxed);
1724 stop.store(true, Ordering::Relaxed);
1725 drained.join().unwrap();
1726
1727 let reported_start_ms = {
1728 let bounds = timeline.boundaries.read();
1729 (bounds[0].seek_samples / channels) * 1000 / rate
1730 };
1731 let decoded_ms = (written / channels) * 1000 / rate;
1732
1733 let total_ms = reported_start_ms + decoded_ms;
1735 assert!(
1736 total_ms.abs_diff(info.duration_ms) < 500,
1737 "reported start {}ms + {}ms decoded = {}ms, but the file is {}ms",
1738 reported_start_ms,
1739 decoded_ms,
1740 total_ms,
1741 info.duration_ms
1742 );
1743 assert!(
1745 reported_start_ms.abs_diff(seek_ms) < 100,
1746 "seek to {}ms reported {}ms",
1747 seek_ms,
1748 reported_start_ms
1749 );
1750 }
1751}