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    },
223    System {
224        started_at: Instant,
225        base_pts: Duration,
226    },
227}
228
229impl MasterClock {
230    /// Current master clock position.
231    #[allow(clippy::cast_precision_loss)]
232    pub(crate) fn current_pts(&self) -> Duration {
233        match self {
234            Self::Audio {
235                samples_consumed,
236                sample_rate,
237            } => {
238                let s = samples_consumed.load(Ordering::Relaxed);
239                Duration::from_secs_f64(s as f64 / f64::from(*sample_rate))
240            }
241            Self::System {
242                started_at,
243                base_pts,
244            } => *base_pts + started_at.elapsed(),
245        }
246    }
247
248    /// Whether A/V sync should be applied for the current frame.
249    ///
250    /// - `System`: always `true` — wall clock drives FPS pacing.
251    /// - `Audio`: `true` only after the first [`PreviewPlayer::pop_audio_samples`]
252    ///   call so that the sync loop does not sleep indefinitely before audio
253    ///   starts.
254    pub(crate) fn should_sync(&self) -> bool {
255        match self {
256            Self::System { .. } => true,
257            Self::Audio {
258                samples_consumed, ..
259            } => samples_consumed.load(Ordering::Relaxed) > 0,
260        }
261    }
262
263    /// Reset the system clock to start ticking from `base` right now.
264    ///
265    /// No-op for the `Audio` variant.
266    pub(crate) fn reset(&mut self, base: Duration) {
267        if let Self::System {
268            started_at,
269            base_pts,
270        } = self
271        {
272            *started_at = Instant::now();
273            *base_pts = base;
274        }
275    }
276}
277
278// ── Tests ─────────────────────────────────────────────────────────────────────
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use std::thread;
284
285    #[test]
286    fn clock_stopped_should_return_zero() {
287        // Newly created clock returns zero.
288        let clock = PlaybackClock::new();
289        assert_eq!(clock.current_time(), Duration::ZERO);
290
291        // Clock returns zero after stop.
292        let mut clock = PlaybackClock::new();
293        clock.start();
294        thread::sleep(Duration::from_millis(5));
295        clock.stop();
296        assert_eq!(
297            clock.current_time(),
298            Duration::ZERO,
299            "current_time() must be ZERO after stop()"
300        );
301    }
302
303    #[test]
304    fn clock_paused_should_freeze_at_pause_time() {
305        let mut clock = PlaybackClock::new();
306        clock.start();
307        thread::sleep(Duration::from_millis(10));
308        clock.pause();
309
310        let t1 = clock.current_time();
311        thread::sleep(Duration::from_millis(10));
312        let t2 = clock.current_time();
313
314        assert_eq!(t1, t2, "current_time() must not advance while paused");
315        assert!(
316            !clock.is_running(),
317            "clock must not report running while paused"
318        );
319    }
320
321    #[test]
322    fn clock_resumed_should_continue_from_pause() {
323        let mut clock = PlaybackClock::new();
324        clock.start();
325        thread::sleep(Duration::from_millis(10));
326        clock.pause();
327        let t_paused = clock.current_time();
328
329        // Wait while paused — time must not advance.
330        thread::sleep(Duration::from_millis(10));
331        assert_eq!(clock.current_time(), t_paused);
332
333        clock.resume();
334        assert!(clock.is_running());
335        thread::sleep(Duration::from_millis(10));
336
337        let t_after = clock.current_time();
338        assert!(
339            t_after > t_paused,
340            "current_time() must advance after resume(); paused={t_paused:?} after={t_after:?}"
341        );
342    }
343
344    #[test]
345    fn clock_start_should_be_noop_when_already_running() {
346        let mut clock = PlaybackClock::new();
347        clock.start();
348        thread::sleep(Duration::from_millis(10));
349        let t_before = clock.current_time();
350
351        // Second start() should not reset the clock.
352        clock.start();
353        let t_after = clock.current_time();
354
355        assert!(
356            t_after >= t_before,
357            "second start() must not reset the clock; before={t_before:?} after={t_after:?}"
358        );
359    }
360
361    #[test]
362    fn clock_resume_should_be_noop_when_not_paused() {
363        // resume() on a stopped clock: stays stopped.
364        let mut clock = PlaybackClock::new();
365        clock.resume();
366        assert!(!clock.is_running());
367        assert_eq!(clock.current_time(), Duration::ZERO);
368
369        // resume() on a running clock: no effect.
370        clock.start();
371        thread::sleep(Duration::from_millis(5));
372        let t = clock.current_time();
373        clock.resume(); // no-op
374        assert!(clock.is_running());
375        assert!(clock.current_time() >= t);
376    }
377
378    #[test]
379    fn clock_default_should_equal_new() {
380        let a = PlaybackClock::new();
381        let b = PlaybackClock::default();
382        assert_eq!(a.current_time(), b.current_time());
383        assert_eq!(a.is_running(), b.is_running());
384    }
385
386    #[test]
387    fn set_rate_should_reject_non_positive_values() {
388        let mut clock = PlaybackClock::new();
389
390        clock.set_rate(0.0);
391        assert!(
392            (clock.rate() - 1.0).abs() < f64::EPSILON,
393            "rate must remain 1.0 after set_rate(0.0)"
394        );
395
396        clock.set_rate(-1.0);
397        assert!(
398            (clock.rate() - 1.0).abs() < f64::EPSILON,
399            "rate must remain 1.0 after set_rate(-1.0)"
400        );
401    }
402
403    #[test]
404    fn set_rate_should_update_rate_when_stopped_or_paused() {
405        // Stopped → rate updates.
406        let mut clock = PlaybackClock::new();
407        clock.set_rate(0.5);
408        assert!((clock.rate() - 0.5).abs() < f64::EPSILON);
409
410        // Paused → rate updates without resuming.
411        let mut clock = PlaybackClock::new();
412        clock.start();
413        clock.pause();
414        clock.set_rate(2.0);
415        assert!((clock.rate() - 2.0).abs() < f64::EPSILON);
416        assert!(
417            !clock.is_running(),
418            "clock must remain paused after set_rate"
419        );
420    }
421
422    #[test]
423    fn set_rate_running_should_not_jump_current_time() {
424        let mut clock = PlaybackClock::new();
425        clock.start();
426        thread::sleep(Duration::from_millis(10));
427        let before = clock.current_time();
428        clock.set_rate(2.0);
429        let after = clock.current_time();
430
431        // current_time() must not jump backward or skip more than a scheduler
432        // quantum (~16 ms) forward after set_rate while running.
433        assert!(
434            after >= before,
435            "current_time() must not go backward on set_rate; before={before:?} after={after:?}"
436        );
437        assert!(
438            after - before < Duration::from_millis(20),
439            "current_time() must not jump forward on set_rate; before={before:?} after={after:?}"
440        );
441        assert!((clock.rate() - 2.0).abs() < f64::EPSILON);
442    }
443
444    #[test]
445    #[ignore = "performance thresholds are environment-dependent; run explicitly with -- --include-ignored"]
446    fn rate_two_x_should_advance_at_double_speed() {
447        let mut clock = PlaybackClock::new();
448        clock.set_rate(2.0);
449        clock.start();
450        thread::sleep(Duration::from_millis(50));
451        let elapsed = clock.current_time();
452
453        // At 2×, 50 ms wall time should produce ≥80 ms of media time.
454        assert!(
455            elapsed >= Duration::from_millis(80),
456            "2× rate: expected ≥80 ms after 50 ms wall time, got {elapsed:?}"
457        );
458    }
459
460    #[test]
461    fn set_position_should_shift_pts_by_seek_offset() {
462        let seek_target = Duration::from_secs(30);
463
464        // Stopped: current_pts() returns the offset immediately.
465        let mut clock = PlaybackClock::new();
466        clock.set_position(seek_target);
467        assert_eq!(
468            clock.current_pts(),
469            seek_target,
470            "current_pts() must reflect seek_offset when stopped"
471        );
472
473        // start() must begin from the seek position.
474        clock.start();
475        let pts = clock.current_pts();
476        assert!(
477            pts >= seek_target,
478            "current_pts() must be ≥ seek target after start(); target={seek_target:?} pts={pts:?}"
479        );
480        assert!(
481            clock.is_running(),
482            "clock must be running after set_position + start()"
483        );
484    }
485
486    #[test]
487    fn set_position_while_paused_should_update_frozen_time() {
488        let mut clock = PlaybackClock::new();
489        clock.start();
490        thread::sleep(Duration::from_millis(5));
491        clock.pause();
492
493        let seek_target = Duration::from_secs(10);
494        clock.set_position(seek_target);
495
496        let pts = clock.current_pts();
497        assert_eq!(
498            pts, seek_target,
499            "frozen time must update to seek target; expected={seek_target:?} got={pts:?}"
500        );
501        assert!(
502            !clock.is_running(),
503            "clock must remain paused after set_position"
504        );
505
506        // resume() must continue advancing from the new position.
507        clock.resume();
508        thread::sleep(Duration::from_millis(5));
509        let pts_after = clock.current_pts();
510        assert!(
511            pts_after > seek_target,
512            "current_pts() must advance past seek target after resume(); target={seek_target:?} after={pts_after:?}"
513        );
514    }
515
516    #[test]
517    fn set_position_while_running_should_continue_from_new_position() {
518        let mut clock = PlaybackClock::new();
519        clock.start();
520        thread::sleep(Duration::from_millis(5));
521
522        let seek_target = Duration::from_secs(60);
523        clock.set_position(seek_target);
524
525        let pts = clock.current_pts();
526        assert!(
527            pts >= seek_target,
528            "current_pts() must be ≥ seek target immediately after set_position while running; \
529             target={seek_target:?} pts={pts:?}"
530        );
531        assert!(
532            clock.is_running(),
533            "clock must remain running after set_position"
534        );
535    }
536
537    #[test]
538    fn stop_should_clear_seek_offset() {
539        let mut clock = PlaybackClock::new();
540        clock.set_position(Duration::from_secs(30));
541        clock.stop();
542
543        assert_eq!(
544            clock.current_pts(),
545            Duration::ZERO,
546            "stop() must reset seek_offset to ZERO"
547        );
548    }
549
550    // ── MasterClock tests ─────────────────────────────────────────────────────
551
552    #[test]
553    fn master_clock_system_should_advance_from_base_pts() {
554        let clock = MasterClock::System {
555            started_at: Instant::now(),
556            base_pts: Duration::from_secs(5),
557        };
558        let pts = clock.current_pts();
559        assert!(
560            pts >= Duration::from_secs(5),
561            "pts must be >= base_pts; got {pts:?}"
562        );
563        assert!(
564            pts < Duration::from_secs(6),
565            "pts must not advance 1 s in a unit test; got {pts:?}"
566        );
567        assert!(clock.should_sync(), "System clock must always sync");
568    }
569
570    #[test]
571    fn master_clock_system_reset_should_update_base_and_time_reference() {
572        let mut clock = MasterClock::System {
573            started_at: Instant::now() - Duration::from_secs(10),
574            base_pts: Duration::ZERO,
575        };
576        assert!(
577            clock.current_pts() >= Duration::from_secs(9),
578            "clock should show ~10 s before reset"
579        );
580        clock.reset(Duration::from_secs(5));
581        let pts = clock.current_pts();
582        assert!(
583            pts >= Duration::from_secs(5),
584            "pts must be >= new base after reset; got {pts:?}"
585        );
586        assert!(
587            pts < Duration::from_secs(6),
588            "pts must not advance 1 s in a unit test after reset; got {pts:?}"
589        );
590    }
591
592    #[test]
593    fn master_clock_audio_should_not_sync_before_first_sample() {
594        let clock = MasterClock::Audio {
595            samples_consumed: Arc::new(AtomicU64::new(0)),
596            sample_rate: 48_000,
597        };
598        assert!(
599            !clock.should_sync(),
600            "audio clock must not sync before any samples are consumed"
601        );
602        assert_eq!(
603            clock.current_pts(),
604            Duration::ZERO,
605            "audio clock PTS must be zero before any samples"
606        );
607    }
608
609    #[test]
610    fn master_clock_audio_should_sync_and_report_pts_after_samples_consumed() {
611        let consumed = Arc::new(AtomicU64::new(48_000));
612        let clock = MasterClock::Audio {
613            samples_consumed: Arc::clone(&consumed),
614            sample_rate: 48_000,
615        };
616        assert!(
617            clock.should_sync(),
618            "audio clock must sync when samples > 0"
619        );
620        assert_eq!(
621            clock.current_pts(),
622            Duration::from_secs(1),
623            "48000 samples at 48000 Hz must equal 1 second"
624        );
625    }
626}