Skip to main content

ff_preview/playback/
clock.rs

1//! Playback clock types for ff-preview.
2//!
3//! [`PlaybackClock`] is the public wall-clock API for callers that need
4//! independent timing queries. [`MasterClock`] is the crate-internal A/V sync
5//! reference driven by either consumed audio samples or an `Instant`.
6
7use std::sync::Arc;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::time::{Duration, Instant};
10
11// ── ClockState ────────────────────────────────────────────────────────────────
12
13/// Internal state machine for `PlaybackClock`.
14///
15/// Transitions:
16/// - `Stopped  → Running`:  `start()`
17/// - `Running  → Paused`:   `pause()`
18/// - `Running  → Stopped`:  `stop()`
19/// - `Paused   → Running`:  `resume()`
20/// - `Paused   → Stopped`:  `stop()`
21/// - `Running  → Running`:  `start()` is a no-op
22/// - `Paused   → Paused`:   `pause()` is a no-op
23enum ClockState {
24    Stopped,
25    Running { started_at: Instant, base: Duration },
26    Paused { frozen_at: Duration },
27}
28
29// ── PlaybackClock ─────────────────────────────────────────────────────────────
30
31/// A monotonic clock that tracks elapsed playback time.
32///
33/// The clock supports start, stop, pause, resume, and playback-rate scaling.
34/// It is used by `PreviewPlayer` internally to drive frame presentation timing
35/// and A/V synchronisation. Callers may also query it directly.
36///
37/// `PlaybackClock` is a value type — it is not `Arc<Mutex<...>>` internally.
38/// When multi-thread access is required, wrap it in a `Mutex`.
39///
40/// # Usage
41///
42/// ```ignore
43/// let mut clock = PlaybackClock::new();
44/// clock.start();
45/// let pts = clock.current_pts();
46/// clock.pause();
47/// // current_pts() is now frozen
48/// clock.resume();
49/// // current_pts() continues advancing from the frozen point
50/// clock.set_rate(2.0);          // fast-forward at 2×
51/// clock.set_position(Duration::from_secs(30)); // seek to 30 s
52/// ```
53pub struct PlaybackClock {
54    state: ClockState,
55    /// Playback rate multiplier. 1.0 = real-time.
56    rate: f64,
57    /// Pending seek position. Applied as the `base` when `start()` is called
58    /// from the `Stopped` state. Cleared by `stop()`.
59    seek_offset: Duration,
60}
61
62impl PlaybackClock {
63    /// Create a new clock in the `Stopped` state with a rate of 1.0.
64    #[must_use]
65    pub fn new() -> Self {
66        Self {
67            state: ClockState::Stopped,
68            rate: 1.0,
69            seek_offset: Duration::ZERO,
70        }
71    }
72
73    /// Start the clock from the current position.
74    ///
75    /// - If the clock is `Stopped`, it starts from the position last set by
76    ///   [`set_position`](Self::set_position), or `Duration::ZERO` if no seek
77    ///   has been performed.
78    /// - If the clock is `Paused`, it starts from the frozen position.
79    /// - If the clock is already `Running`, this is a no-op.
80    pub fn start(&mut self) {
81        let base = match &self.state {
82            ClockState::Running { .. } => return,
83            ClockState::Stopped => self.seek_offset,
84            ClockState::Paused { frozen_at } => *frozen_at,
85        };
86        self.state = ClockState::Running {
87            started_at: Instant::now(),
88            base,
89        };
90    }
91
92    /// Stop the clock and reset the position to `Duration::ZERO`.
93    ///
94    /// `current_time()` and `current_pts()` will return `Duration::ZERO`
95    /// until `start()` or `set_position()` is called again.
96    pub fn stop(&mut self) {
97        self.state = ClockState::Stopped;
98        self.seek_offset = Duration::ZERO;
99    }
100
101    /// Pause the clock at the current position.
102    ///
103    /// `current_time()` and `current_pts()` are frozen until
104    /// [`resume`](Self::resume) is called. If already `Paused` or `Stopped`, no-op.
105    pub fn pause(&mut self) {
106        if let ClockState::Running { started_at, base } = &self.state {
107            let elapsed = started_at.elapsed().mul_f64(self.rate);
108            self.state = ClockState::Paused {
109                frozen_at: *base + elapsed,
110            };
111        }
112    }
113
114    /// Resume from a paused position. No-op if not paused.
115    pub fn resume(&mut self) {
116        if let ClockState::Paused { frozen_at } = self.state {
117            self.state = ClockState::Running {
118                started_at: Instant::now(),
119                base: frozen_at,
120            };
121        }
122    }
123
124    /// Current wall-clock elapsed time since start (affected by rate).
125    ///
126    /// Equivalent to [`current_pts`](Self::current_pts) for clocks that
127    /// start at zero; use `current_pts()` when a seek offset has been set.
128    #[must_use]
129    pub fn current_time(&self) -> Duration {
130        match &self.state {
131            ClockState::Stopped => Duration::ZERO,
132            ClockState::Paused { frozen_at } => *frozen_at,
133            ClockState::Running { started_at, base } => {
134                *base + started_at.elapsed().mul_f64(self.rate)
135            }
136        }
137    }
138
139    /// Current presentation timestamp (elapsed time since position zero).
140    ///
141    /// Identical to `current_time()` when the clock was started from zero.
142    /// When a `set_position(t)` was called before `start()`, the clock
143    /// advances from `t` and this method returns values ≥ `t`.
144    #[must_use]
145    pub fn current_pts(&self) -> Duration {
146        match &self.state {
147            ClockState::Stopped => self.seek_offset,
148            _ => self.current_time(),
149        }
150    }
151
152    /// Returns `true` if the clock is actively advancing.
153    #[must_use]
154    pub fn is_running(&self) -> bool {
155        matches!(self.state, ClockState::Running { .. })
156    }
157
158    /// Set the playback rate. Values ≤ 0.0 are ignored (rate stays unchanged).
159    ///
160    /// When the clock is `Running`, the current position is re-anchored at
161    /// `Instant::now()` so that `current_time()` does not jump.
162    pub fn set_rate(&mut self, rate: f64) {
163        if rate <= 0.0 {
164            return;
165        }
166        if let ClockState::Running { started_at, base } = &mut self.state {
167            // Re-anchor to now so the position does not jump on rate change.
168            let elapsed = started_at.elapsed().mul_f64(self.rate);
169            *base += elapsed;
170            *started_at = Instant::now();
171        }
172        self.rate = rate;
173    }
174
175    /// Current playback rate (default: 1.0).
176    #[must_use]
177    pub fn rate(&self) -> f64 {
178        self.rate
179    }
180
181    /// Jump to an arbitrary position in media time.
182    ///
183    /// - `Running`: the clock continues advancing from `pts` immediately.
184    /// - `Paused`: the frozen position is updated to `pts`.
185    /// - `Stopped`: `pts` is stored and applied as the starting position when
186    ///   [`start`](Self::start) is next called.
187    ///
188    /// After `set_position(t)` + `start()`, [`current_pts`](Self::current_pts)
189    /// will immediately return values ≥ `t`.
190    pub fn set_position(&mut self, pts: Duration) {
191        // seek_offset is always updated so current_pts() is consistent for all states.
192        self.seek_offset = pts;
193        if matches!(self.state, ClockState::Running { .. }) {
194            // Re-anchor the running base at the new position.
195            self.state = ClockState::Running {
196                started_at: Instant::now(),
197                base: pts,
198            };
199        } else if matches!(self.state, ClockState::Paused { .. }) {
200            self.state = ClockState::Paused { frozen_at: pts };
201        }
202        // Stopped: seek_offset is set above; start() will use it as the initial base.
203    }
204}
205
206impl Default for PlaybackClock {
207    fn default() -> Self {
208        Self::new()
209    }
210}
211
212// ── MasterClock ───────────────────────────────────────────────────────────────
213
214/// Reference clock for the A/V sync loop in [`PreviewPlayer::run`].
215///
216/// - `Audio`: driven by consumed audio samples ÷ `sample_rate`.
217/// - `System`: driven by [`std::time::Instant`] (video-only files).
218pub(crate) enum MasterClock {
219    Audio {
220        samples_consumed: Arc<AtomicU64>,
221        sample_rate: u32,
222        /// Wall-clock fallback activated after the first presented frame when no
223        /// audio consumer has called `pop_audio_samples()`. Tuple: `(wall start, base PTS)`.
224        ///
225        /// When `Some`, `current_pts()` returns `base_pts + elapsed` instead of
226        /// `Duration::ZERO`, so video pacing runs at real time even without a cpal
227        /// consumer. If `samples_consumed` becomes non-zero later (a consumer
228        /// connects mid-playback), `current_pts()` automatically switches to the
229        /// audio-clock path with no additional coordination.
230        fallback: Option<(Instant, Duration)>,
231    },
232    System {
233        started_at: Instant,
234        base_pts: Duration,
235    },
236}
237
238impl MasterClock {
239    /// Current master clock position.
240    ///
241    /// For `Audio`: returns the maximum of the sample-based clock and the
242    /// wall-clock fallback (when set). Taking the maximum ensures that the
243    /// clock continues advancing at wall-clock rate after the audio ring
244    /// buffer drains (audio track ends before video), while also allowing
245    /// a late-connecting cpal consumer to drive the clock forward once it
246    /// overtakes the initial fallback.
247    #[allow(clippy::cast_precision_loss)]
248    pub(crate) fn current_pts(&self) -> Duration {
249        match self {
250            Self::Audio {
251                samples_consumed,
252                sample_rate,
253                fallback,
254            } => {
255                let s = samples_consumed.load(Ordering::Relaxed);
256                let sample_pts = if s > 0 {
257                    Some(Duration::from_secs_f64(s as f64 / f64::from(*sample_rate)))
258                } else {
259                    None
260                };
261                let fallback_pts = fallback
262                    .as_ref()
263                    .map(|(started_at, base_pts)| *base_pts + started_at.elapsed());
264                match (sample_pts, fallback_pts) {
265                    // Both present: use whichever is further ahead.
266                    // - During normal playback the sample clock is ahead → sample wins.
267                    // - After audio EOF (samples frozen) the wall-clock fallback
268                    //   overtakes → fallback wins.
269                    (Some(sp), Some(fp)) => sp.max(fp),
270                    (Some(sp), None) => sp,
271                    (None, Some(fp)) => fp,
272                    (None, None) => Duration::ZERO,
273                }
274            }
275            Self::System {
276                started_at,
277                base_pts,
278            } => *base_pts + started_at.elapsed(),
279        }
280    }
281
282    /// Whether A/V sync should be applied for the current frame.
283    ///
284    /// - `System`: always `true` — wall clock drives FPS pacing.
285    /// - `Audio`: `true` once any of the following holds:
286    ///   - `samples_consumed > 0` (a cpal consumer has called `pop_audio_samples`), or
287    ///   - `fallback.is_some()` (the wall-clock fallback was armed after the first frame).
288    ///
289    ///   Returns `false` only in the brief window between `run()` starting and the
290    ///   first frame being presented — this prevents an indefinite sleep before any
291    ///   clock reference is available.
292    pub(crate) fn should_sync(&self) -> bool {
293        match self {
294            Self::System { .. } => true,
295            Self::Audio {
296                samples_consumed,
297                fallback,
298                ..
299            } => samples_consumed.load(Ordering::Relaxed) > 0 || fallback.is_some(),
300        }
301    }
302
303    /// Activate the wall-clock fallback at `base_pts` if no audio samples have
304    /// been consumed yet and the fallback has not already been armed.
305    ///
306    /// Called by [`PlayerRunner::run`] immediately after the first
307    /// `present_frame()` call. Once armed, `should_sync()` returns `true` and
308    /// `current_pts()` advances in real time even when no cpal consumer is
309    /// connected.
310    ///
311    /// Idempotent: subsequent calls are no-ops.  If `samples_consumed` becomes
312    /// non-zero (a consumer connects mid-playback), `current_pts()` automatically
313    /// switches to the audio-clock path without any additional coordination.
314    ///
315    /// No-op for [`MasterClock::System`].
316    pub(crate) fn activate_fallback_if_no_audio(&mut self, base_pts: Duration) {
317        if let Self::Audio {
318            samples_consumed,
319            fallback,
320            ..
321        } = self
322            && samples_consumed.load(Ordering::Relaxed) == 0
323            && fallback.is_none()
324        {
325            *fallback = Some((Instant::now(), base_pts));
326        }
327    }
328
329    /// Re-arm the wall-clock fallback at `base_pts`, even when
330    /// `samples_consumed > 0`.
331    ///
332    /// Unlike [`activate_fallback_if_no_audio`](Self::activate_fallback_if_no_audio),
333    /// this method activates unconditionally and is intended to be called by
334    /// the pacing loop when it detects that audio has gone silent (audio track
335    /// ended before video). After re-arming, [`current_pts`](Self::current_pts)
336    /// returns the `max` of the frozen sample position and the advancing
337    /// wall-clock, so video continues at its native frame rate.
338    ///
339    /// No-op for [`MasterClock::System`].
340    pub(crate) fn rearm_fallback_at(&mut self, base_pts: Duration) {
341        if let Self::Audio { fallback, .. } = self {
342            *fallback = Some((Instant::now(), base_pts));
343        }
344    }
345
346    /// Current value of the audio sample counter, or `0` for a `System` clock.
347    ///
348    /// Used by the pacing loop to detect stalls: if this value stops
349    /// advancing for several consecutive frames while `> 0`, the audio track
350    /// has ended and `rearm_fallback_at` should be called.
351    pub(crate) fn audio_samples_snapshot(&self) -> u64 {
352        if let Self::Audio {
353            samples_consumed, ..
354        } = self
355        {
356            samples_consumed.load(Ordering::Relaxed)
357        } else {
358            0
359        }
360    }
361
362    /// Reset the clock to start ticking from `base` right now.
363    ///
364    /// For [`MasterClock::System`]: re-anchors `started_at` and sets `base_pts`.
365    ///
366    /// For [`MasterClock::Audio`]: if the wall-clock fallback is active (i.e. no
367    /// audio consumer is present), re-anchors the fallback at `(Instant::now(), base)`
368    /// so that post-seek pacing starts from the correct position. If the fallback
369    /// is not yet armed (pre-first-frame) or if `samples_consumed > 0` (audio
370    /// consumer active), this is a no-op — the seek position is reflected in the
371    /// audio buffer restart performed by `restart_audio_from`.
372    pub(crate) fn reset(&mut self, base: Duration) {
373        match self {
374            Self::System {
375                started_at,
376                base_pts,
377            } => {
378                *started_at = Instant::now();
379                *base_pts = base;
380            }
381            Self::Audio { fallback, .. } => {
382                if fallback.is_some() {
383                    *fallback = Some((Instant::now(), base));
384                }
385            }
386        }
387    }
388}
389
390// ── Tests ─────────────────────────────────────────────────────────────────────
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use std::thread;
396
397    #[test]
398    fn clock_stopped_should_return_zero() {
399        // Newly created clock returns zero.
400        let clock = PlaybackClock::new();
401        assert_eq!(clock.current_time(), Duration::ZERO);
402
403        // Clock returns zero after stop.
404        let mut clock = PlaybackClock::new();
405        clock.start();
406        thread::sleep(Duration::from_millis(5));
407        clock.stop();
408        assert_eq!(
409            clock.current_time(),
410            Duration::ZERO,
411            "current_time() must be ZERO after stop()"
412        );
413    }
414
415    #[test]
416    fn clock_paused_should_freeze_at_pause_time() {
417        let mut clock = PlaybackClock::new();
418        clock.start();
419        thread::sleep(Duration::from_millis(10));
420        clock.pause();
421
422        let t1 = clock.current_time();
423        thread::sleep(Duration::from_millis(10));
424        let t2 = clock.current_time();
425
426        assert_eq!(t1, t2, "current_time() must not advance while paused");
427        assert!(
428            !clock.is_running(),
429            "clock must not report running while paused"
430        );
431    }
432
433    #[test]
434    fn clock_resumed_should_continue_from_pause() {
435        let mut clock = PlaybackClock::new();
436        clock.start();
437        thread::sleep(Duration::from_millis(10));
438        clock.pause();
439        let t_paused = clock.current_time();
440
441        // Wait while paused — time must not advance.
442        thread::sleep(Duration::from_millis(10));
443        assert_eq!(clock.current_time(), t_paused);
444
445        clock.resume();
446        assert!(clock.is_running());
447        thread::sleep(Duration::from_millis(10));
448
449        let t_after = clock.current_time();
450        assert!(
451            t_after > t_paused,
452            "current_time() must advance after resume(); paused={t_paused:?} after={t_after:?}"
453        );
454    }
455
456    #[test]
457    fn clock_start_should_be_noop_when_already_running() {
458        let mut clock = PlaybackClock::new();
459        clock.start();
460        thread::sleep(Duration::from_millis(10));
461        let t_before = clock.current_time();
462
463        // Second start() should not reset the clock.
464        clock.start();
465        let t_after = clock.current_time();
466
467        assert!(
468            t_after >= t_before,
469            "second start() must not reset the clock; before={t_before:?} after={t_after:?}"
470        );
471    }
472
473    #[test]
474    fn clock_resume_should_be_noop_when_not_paused() {
475        // resume() on a stopped clock: stays stopped.
476        let mut clock = PlaybackClock::new();
477        clock.resume();
478        assert!(!clock.is_running());
479        assert_eq!(clock.current_time(), Duration::ZERO);
480
481        // resume() on a running clock: no effect.
482        clock.start();
483        thread::sleep(Duration::from_millis(5));
484        let t = clock.current_time();
485        clock.resume(); // no-op
486        assert!(clock.is_running());
487        assert!(clock.current_time() >= t);
488    }
489
490    #[test]
491    fn clock_default_should_equal_new() {
492        let a = PlaybackClock::new();
493        let b = PlaybackClock::default();
494        assert_eq!(a.current_time(), b.current_time());
495        assert_eq!(a.is_running(), b.is_running());
496    }
497
498    #[test]
499    fn set_rate_should_reject_non_positive_values() {
500        let mut clock = PlaybackClock::new();
501
502        clock.set_rate(0.0);
503        assert!(
504            (clock.rate() - 1.0).abs() < f64::EPSILON,
505            "rate must remain 1.0 after set_rate(0.0)"
506        );
507
508        clock.set_rate(-1.0);
509        assert!(
510            (clock.rate() - 1.0).abs() < f64::EPSILON,
511            "rate must remain 1.0 after set_rate(-1.0)"
512        );
513    }
514
515    #[test]
516    fn set_rate_should_update_rate_when_stopped_or_paused() {
517        // Stopped → rate updates.
518        let mut clock = PlaybackClock::new();
519        clock.set_rate(0.5);
520        assert!((clock.rate() - 0.5).abs() < f64::EPSILON);
521
522        // Paused → rate updates without resuming.
523        let mut clock = PlaybackClock::new();
524        clock.start();
525        clock.pause();
526        clock.set_rate(2.0);
527        assert!((clock.rate() - 2.0).abs() < f64::EPSILON);
528        assert!(
529            !clock.is_running(),
530            "clock must remain paused after set_rate"
531        );
532    }
533
534    #[test]
535    fn set_rate_running_should_not_jump_current_time() {
536        let mut clock = PlaybackClock::new();
537        clock.start();
538        thread::sleep(Duration::from_millis(10));
539        let before = clock.current_time();
540        clock.set_rate(2.0);
541        let after = clock.current_time();
542
543        // current_time() must not jump backward or skip more than a scheduler
544        // quantum (~16 ms) forward after set_rate while running.
545        assert!(
546            after >= before,
547            "current_time() must not go backward on set_rate; before={before:?} after={after:?}"
548        );
549        assert!(
550            after - before < Duration::from_millis(20),
551            "current_time() must not jump forward on set_rate; before={before:?} after={after:?}"
552        );
553        assert!((clock.rate() - 2.0).abs() < f64::EPSILON);
554    }
555
556    #[test]
557    #[ignore = "performance thresholds are environment-dependent; run explicitly with -- --include-ignored"]
558    fn rate_two_x_should_advance_at_double_speed() {
559        let mut clock = PlaybackClock::new();
560        clock.set_rate(2.0);
561        clock.start();
562        thread::sleep(Duration::from_millis(50));
563        let elapsed = clock.current_time();
564
565        // At 2×, 50 ms wall time should produce ≥80 ms of media time.
566        assert!(
567            elapsed >= Duration::from_millis(80),
568            "2× rate: expected ≥80 ms after 50 ms wall time, got {elapsed:?}"
569        );
570    }
571
572    #[test]
573    fn set_position_should_shift_pts_by_seek_offset() {
574        let seek_target = Duration::from_secs(30);
575
576        // Stopped: current_pts() returns the offset immediately.
577        let mut clock = PlaybackClock::new();
578        clock.set_position(seek_target);
579        assert_eq!(
580            clock.current_pts(),
581            seek_target,
582            "current_pts() must reflect seek_offset when stopped"
583        );
584
585        // start() must begin from the seek position.
586        clock.start();
587        let pts = clock.current_pts();
588        assert!(
589            pts >= seek_target,
590            "current_pts() must be ≥ seek target after start(); target={seek_target:?} pts={pts:?}"
591        );
592        assert!(
593            clock.is_running(),
594            "clock must be running after set_position + start()"
595        );
596    }
597
598    #[test]
599    fn set_position_while_paused_should_update_frozen_time() {
600        let mut clock = PlaybackClock::new();
601        clock.start();
602        thread::sleep(Duration::from_millis(5));
603        clock.pause();
604
605        let seek_target = Duration::from_secs(10);
606        clock.set_position(seek_target);
607
608        let pts = clock.current_pts();
609        assert_eq!(
610            pts, seek_target,
611            "frozen time must update to seek target; expected={seek_target:?} got={pts:?}"
612        );
613        assert!(
614            !clock.is_running(),
615            "clock must remain paused after set_position"
616        );
617
618        // resume() must continue advancing from the new position.
619        clock.resume();
620        thread::sleep(Duration::from_millis(5));
621        let pts_after = clock.current_pts();
622        assert!(
623            pts_after > seek_target,
624            "current_pts() must advance past seek target after resume(); target={seek_target:?} after={pts_after:?}"
625        );
626    }
627
628    #[test]
629    fn set_position_while_running_should_continue_from_new_position() {
630        let mut clock = PlaybackClock::new();
631        clock.start();
632        thread::sleep(Duration::from_millis(5));
633
634        let seek_target = Duration::from_secs(60);
635        clock.set_position(seek_target);
636
637        let pts = clock.current_pts();
638        assert!(
639            pts >= seek_target,
640            "current_pts() must be ≥ seek target immediately after set_position while running; \
641             target={seek_target:?} pts={pts:?}"
642        );
643        assert!(
644            clock.is_running(),
645            "clock must remain running after set_position"
646        );
647    }
648
649    #[test]
650    fn stop_should_clear_seek_offset() {
651        let mut clock = PlaybackClock::new();
652        clock.set_position(Duration::from_secs(30));
653        clock.stop();
654
655        assert_eq!(
656            clock.current_pts(),
657            Duration::ZERO,
658            "stop() must reset seek_offset to ZERO"
659        );
660    }
661
662    // ── MasterClock tests ─────────────────────────────────────────────────────
663
664    #[test]
665    fn master_clock_system_should_advance_from_base_pts() {
666        let clock = MasterClock::System {
667            started_at: Instant::now(),
668            base_pts: Duration::from_secs(5),
669        };
670        let pts = clock.current_pts();
671        assert!(
672            pts >= Duration::from_secs(5),
673            "pts must be >= base_pts; got {pts:?}"
674        );
675        assert!(
676            pts < Duration::from_secs(6),
677            "pts must not advance 1 s in a unit test; got {pts:?}"
678        );
679        assert!(clock.should_sync(), "System clock must always sync");
680    }
681
682    #[test]
683    fn master_clock_system_reset_should_update_base_and_time_reference() {
684        let mut clock = MasterClock::System {
685            started_at: Instant::now() - Duration::from_secs(10),
686            base_pts: Duration::ZERO,
687        };
688        assert!(
689            clock.current_pts() >= Duration::from_secs(9),
690            "clock should show ~10 s before reset"
691        );
692        clock.reset(Duration::from_secs(5));
693        let pts = clock.current_pts();
694        assert!(
695            pts >= Duration::from_secs(5),
696            "pts must be >= new base after reset; got {pts:?}"
697        );
698        assert!(
699            pts < Duration::from_secs(6),
700            "pts must not advance 1 s in a unit test after reset; got {pts:?}"
701        );
702    }
703
704    #[test]
705    fn master_clock_audio_should_not_sync_before_first_sample() {
706        let clock = MasterClock::Audio {
707            samples_consumed: Arc::new(AtomicU64::new(0)),
708            sample_rate: 48_000,
709            fallback: None,
710        };
711        assert!(
712            !clock.should_sync(),
713            "audio clock must not sync before any samples are consumed and before fallback is armed"
714        );
715        assert_eq!(
716            clock.current_pts(),
717            Duration::ZERO,
718            "audio clock PTS must be zero before any samples and before fallback is armed"
719        );
720    }
721
722    #[test]
723    fn master_clock_audio_should_sync_and_report_pts_after_samples_consumed() {
724        let consumed = Arc::new(AtomicU64::new(48_000));
725        let clock = MasterClock::Audio {
726            samples_consumed: Arc::clone(&consumed),
727            sample_rate: 48_000,
728            fallback: None,
729        };
730        assert!(
731            clock.should_sync(),
732            "audio clock must sync when samples > 0"
733        );
734        assert_eq!(
735            clock.current_pts(),
736            Duration::from_secs(1),
737            "48000 samples at 48000 Hz must equal 1 second"
738        );
739    }
740
741    #[test]
742    fn master_clock_audio_should_sync_after_fallback_activated() {
743        let mut clock = MasterClock::Audio {
744            samples_consumed: Arc::new(AtomicU64::new(0)),
745            sample_rate: 48_000,
746            fallback: None,
747        };
748        assert!(
749            !clock.should_sync(),
750            "must not sync before fallback is armed"
751        );
752        clock.activate_fallback_if_no_audio(Duration::from_secs(1));
753        assert!(
754            clock.should_sync(),
755            "must sync after fallback is activated even when samples_consumed == 0"
756        );
757    }
758
759    #[test]
760    fn master_clock_audio_fallback_current_pts_should_advance_from_base_pts() {
761        let mut clock = MasterClock::Audio {
762            samples_consumed: Arc::new(AtomicU64::new(0)),
763            sample_rate: 48_000,
764            fallback: None,
765        };
766        let base = Duration::from_secs(5);
767        clock.activate_fallback_if_no_audio(base);
768        let pts = clock.current_pts();
769        assert!(
770            pts >= base,
771            "fallback current_pts must be >= base_pts; got {pts:?}"
772        );
773        assert!(
774            pts < base + Duration::from_secs(1),
775            "fallback must not advance 1 s in a unit test; got {pts:?}"
776        );
777    }
778
779    #[test]
780    fn master_clock_audio_max_of_sample_and_fallback_should_prefer_further_ahead() {
781        // current_pts() returns max(sample_pts, fallback_pts) when both are set.
782        // Scenario: initial fallback armed at 2 s (first frame PTS=2s, no cpal
783        // consumer). Then 1 s of audio is consumed. sample_pts=1 s < fallback≈2 s,
784        // so the fallback wins and the clock reports ≈2 s.
785        let consumed = Arc::new(AtomicU64::new(0));
786        let mut clock = MasterClock::Audio {
787            samples_consumed: Arc::clone(&consumed),
788            sample_rate: 48_000,
789            fallback: None,
790        };
791        clock.activate_fallback_if_no_audio(Duration::from_secs(2));
792        assert!(clock.should_sync(), "fallback must enable sync");
793        // Audio consumer processes 1 s of audio.
794        consumed.store(48_000, Ordering::Relaxed);
795        // sample_pts=1 s, fallback_pts≈2 s → max returns ≈2 s.
796        let pts = clock.current_pts();
797        assert!(
798            pts >= Duration::from_secs(2),
799            "max() must return fallback when fallback is further ahead; got {pts:?}"
800        );
801        assert!(
802            pts < Duration::from_secs(3),
803            "fallback must not be wildly ahead of 2 s; got {pts:?}"
804        );
805    }
806
807    #[test]
808    fn master_clock_audio_activate_fallback_should_be_idempotent() {
809        let mut clock = MasterClock::Audio {
810            samples_consumed: Arc::new(AtomicU64::new(0)),
811            sample_rate: 48_000,
812            fallback: None,
813        };
814        clock.activate_fallback_if_no_audio(Duration::from_secs(1));
815        let pts1 = clock.current_pts();
816        thread::sleep(Duration::from_millis(5));
817        // Second call with a different base must be ignored.
818        clock.activate_fallback_if_no_audio(Duration::from_secs(100));
819        let pts2 = clock.current_pts();
820        assert!(
821            pts2 > pts1,
822            "clock must keep advancing from the first base after second activate; \
823             pts1={pts1:?} pts2={pts2:?}"
824        );
825        assert!(
826            pts2 < Duration::from_secs(5),
827            "second activate must not reset clock to base=100 s; pts2={pts2:?}"
828        );
829    }
830
831    #[test]
832    fn master_clock_audio_reset_should_update_fallback_base_pts() {
833        let mut clock = MasterClock::Audio {
834            samples_consumed: Arc::new(AtomicU64::new(0)),
835            sample_rate: 48_000,
836            fallback: None,
837        };
838        clock.activate_fallback_if_no_audio(Duration::from_secs(5));
839        // Simulate a seek to 10 s.
840        clock.reset(Duration::from_secs(10));
841        let pts = clock.current_pts();
842        assert!(
843            pts >= Duration::from_secs(10),
844            "after reset, fallback must advance from the new base_pts; got {pts:?}"
845        );
846        assert!(
847            pts < Duration::from_secs(11),
848            "fallback must not advance 1 s in a unit test after reset; got {pts:?}"
849        );
850    }
851
852    #[test]
853    fn master_clock_audio_reset_should_not_arm_fallback_if_not_yet_active() {
854        let mut clock = MasterClock::Audio {
855            samples_consumed: Arc::new(AtomicU64::new(0)),
856            sample_rate: 48_000,
857            fallback: None,
858        };
859        // reset() before the first frame must not arm the fallback.
860        clock.reset(Duration::ZERO);
861        assert!(
862            !clock.should_sync(),
863            "reset() before activate_fallback_if_no_audio must not arm the fallback"
864        );
865        assert_eq!(
866            clock.current_pts(),
867            Duration::ZERO,
868            "PTS must remain ZERO when fallback is not yet armed"
869        );
870    }
871
872    #[test]
873    fn master_clock_audio_rearm_should_advance_past_frozen_sample_pts() {
874        // Simulates audio-track-ended-before-video: samples_consumed is frozen
875        // at 45 222 ms worth of frames. After rearm_fallback_at(45.222s), the
876        // clock must advance beyond 45.222 s even though samples_consumed does
877        // not change.
878        let frozen_frames: u64 = (45_222 * 48_000) / 1_000; // frames for 45.222 s
879        let consumed = Arc::new(AtomicU64::new(frozen_frames));
880        let mut clock = MasterClock::Audio {
881            samples_consumed: Arc::clone(&consumed),
882            sample_rate: 48_000,
883            fallback: None,
884        };
885        let frozen_pts = Duration::from_secs_f64(frozen_frames as f64 / 48_000.0);
886        // Before rearm: clock is frozen at the audio EOF position.
887        assert_eq!(
888            clock.current_pts(),
889            frozen_pts,
890            "clock must be frozen at audio EOF position before rearm"
891        );
892        // Re-arm at the frozen position.
893        clock.rearm_fallback_at(frozen_pts);
894        thread::sleep(Duration::from_millis(10));
895        // After rearm: clock must have advanced past the frozen value.
896        let pts_after = clock.current_pts();
897        assert!(
898            pts_after > frozen_pts,
899            "clock must advance past frozen sample_pts after rearm; \
900             frozen={frozen_pts:?} after={pts_after:?}"
901        );
902        assert!(
903            pts_after < frozen_pts + Duration::from_secs(1),
904            "clock must not advance 1 s in a unit test after rearm; got {pts_after:?}"
905        );
906    }
907
908    #[test]
909    fn master_clock_audio_rearm_should_be_noop_for_system_clock() {
910        let mut clock = MasterClock::System {
911            started_at: Instant::now(),
912            base_pts: Duration::ZERO,
913        };
914        // Must not panic and System behaviour must be unchanged.
915        clock.rearm_fallback_at(Duration::from_secs(99));
916        assert!(
917            clock.should_sync(),
918            "System clock must always sync after rearm_fallback_at"
919        );
920    }
921
922    #[test]
923    fn audio_samples_snapshot_should_return_current_counter_for_audio_clock() {
924        let consumed = Arc::new(AtomicU64::new(12_345));
925        let clock = MasterClock::Audio {
926            samples_consumed: Arc::clone(&consumed),
927            sample_rate: 48_000,
928            fallback: None,
929        };
930        assert_eq!(
931            clock.audio_samples_snapshot(),
932            12_345,
933            "audio_samples_snapshot must reflect the current AtomicU64 value"
934        );
935    }
936
937    #[test]
938    fn audio_samples_snapshot_should_return_zero_for_system_clock() {
939        let clock = MasterClock::System {
940            started_at: Instant::now(),
941            base_pts: Duration::ZERO,
942        };
943        assert_eq!(
944            clock.audio_samples_snapshot(),
945            0,
946            "audio_samples_snapshot must return 0 for System clock"
947        );
948    }
949
950    #[test]
951    fn master_clock_audio_current_pts_should_advance_one_second_after_48k_frames() {
952        // After the fix, MasterClock::Audio is always constructed with
953        // sample_rate = DECODED_SAMPLE_RATE = 48_000 (the decoder output rate).
954        // 48 000 stereo frames consumed at 48 000 Hz must equal exactly 1 second.
955        let consumed = Arc::new(AtomicU64::new(48_000));
956        let clock = MasterClock::Audio {
957            samples_consumed: Arc::clone(&consumed),
958            sample_rate: 48_000,
959            fallback: None,
960        };
961        assert_eq!(
962            clock.current_pts(),
963            Duration::from_secs(1),
964            "48 000 consumed frames / 48 000 Hz must equal exactly 1.0 s"
965        );
966    }
967
968    #[test]
969    fn master_clock_audio_native_rate_mismatch_demonstrates_bug() {
970        // Documents the pre-fix behaviour: if the source file's native rate
971        // (e.g. 44 100 Hz) were used instead of the decoder's output rate,
972        // 48 000 consumed frames would yield 1.088 s — 8.8 % too fast.
973        // This test is deliberately left in to show what the wrong answer looks like.
974        let consumed = Arc::new(AtomicU64::new(48_000));
975        let clock_wrong = MasterClock::Audio {
976            samples_consumed: Arc::clone(&consumed),
977            sample_rate: 44_100, // wrong: source native rate, not decoder output rate
978            fallback: None,
979        };
980        let pts_wrong = clock_wrong.current_pts();
981        // 48 000 / 44 100 ≈ 1.0884 s — NOT 1.0 s
982        assert!(
983            pts_wrong > Duration::from_secs(1),
984            "using native rate produces a clock that runs too fast; got {pts_wrong:?}"
985        );
986        assert!(
987            pts_wrong < Duration::from_millis(1_100),
988            "drift must be bounded to ~8.8 %; got {pts_wrong:?}"
989        );
990    }
991
992    #[test]
993    fn master_clock_system_activate_fallback_should_be_noop() {
994        let mut clock = MasterClock::System {
995            started_at: Instant::now(),
996            base_pts: Duration::ZERO,
997        };
998        // Must not panic and must not change System behaviour.
999        clock.activate_fallback_if_no_audio(Duration::from_secs(99));
1000        assert!(
1001            clock.should_sync(),
1002            "System clock must always sync regardless of activate_fallback_if_no_audio"
1003        );
1004    }
1005}