Skip to main content

alpha2_engine/
playback.rs

1//! How real-time playback trades pictures against sound, and the clock that enforces it.
2//!
3//! A [`crate::QualityProfile`] decides how a frame is drawn; a [`PlaybackPolicy`] decides which
4//! frame is shown when.
5
6use alpha2_time::{FrameRate, TICKS_PER_SECOND};
7use serde::{Deserialize, Serialize};
8
9use crate::MissingFramePolicy;
10
11/// How early a paced frame may advance, in milliseconds. Display refresh timestamps are rounded
12/// (browsers to 0.1 ms or coarser), so a due refresh can read slightly early.
13const PACING_SLACK_MS: f64 = 0.5;
14
15/// Which frame real-time playback shows, and whether sound plays.
16#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
17#[serde(rename_all = "snake_case")]
18pub enum PlaybackPolicy {
19    /// Sound leads at 1x; the picture follows the audio clock, dropping late frames and holding
20    /// the last one while inputs are missing. Other rates follow the wall clock silently.
21    #[default]
22    DropVideoKeepAudio,
23    /// Every frame in order once its inputs are ready, paced no faster than the rate. Slows
24    /// rather than skips. Silent.
25    PreserveEveryVideoFrame,
26    /// The picture follows the wall clock at any rate, holding the last frame while inputs are
27    /// missing. Silent; for shuttling and scrubbing.
28    SilentScrub,
29    /// Every frame in order as fast as it renders, like an export. Silent.
30    OfflineExact,
31}
32
33impl PlaybackPolicy {
34    /// Every policy, in the order a host lists them.
35    pub const ALL: [Self; 4] = [
36        Self::DropVideoKeepAudio,
37        Self::PreserveEveryVideoFrame,
38        Self::SilentScrub,
39        Self::OfflineExact,
40    ];
41
42    /// What a frame whose inputs are missing shows.
43    #[must_use]
44    pub const fn missing_frames(self) -> MissingFramePolicy {
45        match self {
46            Self::DropVideoKeepAudio | Self::SilentScrub => MissingFramePolicy::HoldPrevious,
47            Self::PreserveEveryVideoFrame | Self::OfflineExact => MissingFramePolicy::Wait,
48        }
49    }
50
51    /// Whether sound plays at `rate` (only when it leads, at 1x).
52    #[must_use]
53    pub fn audible(self, rate: f64) -> bool {
54        self == Self::DropVideoKeepAudio && (rate - 1.0).abs() < f64::EPSILON
55    }
56
57    /// Whether the clock waits for each frame to be shown before the next.
58    #[must_use]
59    pub const fn waits_for_frames(self) -> bool {
60        matches!(self, Self::PreserveEveryVideoFrame | Self::OfflineExact)
61    }
62}
63
64/// What the clock asks for at one moment.
65#[derive(Copy, Clone, Debug, Eq, PartialEq)]
66pub struct ClockStep {
67    /// The start of the frame to show, in ticks.
68    pub tick: i64,
69    /// Whether sound should be playing.
70    pub audible: bool,
71    /// Whether this frame must be shown before the clock moves on.
72    pub wait_for_frame: bool,
73}
74
75/// Milliseconds the audio clock may stall before the picture continues on the wall clock.
76const AUDIO_STALL_MS: f64 = 250.0;
77
78/// How far past its last update the heard position is extrapolated, in milliseconds. Audio
79/// outputs report it in callback bursts of up to about 40 ms; a longer silence holds the picture.
80const AUDIO_EXTRAPOLATE_MS: f64 = 80.0;
81
82/// A heard position this far from the smoothed estimate, in milliseconds, is jumped to (a seek or
83/// an underrun) instead of averaged.
84const AUDIO_SNAP_MS: f64 = 100.0;
85
86/// The weight of each heard position in the running average of its error.
87const AUDIO_ERROR_WEIGHT: f64 = 0.0625;
88
89/// The averaged error, in milliseconds, past which the smoothed estimate is moved by it. Below it
90/// the estimate keeps an exact 1x rate, so frames advance as evenly as on the wall clock.
91const AUDIO_DEADBAND_MS: f64 = 8.0;
92
93// 120,000 is exact in f64.
94#[allow(clippy::cast_precision_loss)]
95const TICKS_PER_MS: f64 = TICKS_PER_SECOND as f64 / 1000.0;
96
97/// The clock for one playback run, asked each display refresh which time to show.
98#[derive(Clone, Debug)]
99pub struct PlaybackClock {
100    policy: PlaybackPolicy,
101    rate: f64,
102    frame_rate: FrameRate,
103    /// Wall time and tick the wall clock counts from.
104    origin_ms: f64,
105    origin_tick: i64,
106    /// The tick playback started at; neither clock runs before it.
107    start_tick: i64,
108    /// Whether the audio clock leads.
109    following_audio: bool,
110    last_audio: Option<i64>,
111    last_audio_ms: f64,
112    /// The smoothed heard time runs on the wall clock from this tick at `heard_anchor_ms`.
113    heard_anchor_tick: f64,
114    heard_anchor_ms: f64,
115    /// Running average of heard positions minus the smoothed time, in ticks.
116    heard_error: f64,
117    /// Added to the smoothed time when a frame is picked, in ticks. It is at most half a frame,
118    /// set when the line moves so that refresh falls mid-frame; at a display rate equal to the
119    /// frame rate every refresh then stays clear of frame boundaries.
120    heard_bias: f64,
121    /// The latest time shown while following audio; the smoothed time never goes back past it.
122    heard_shown: i64,
123    /// For frame-by-frame policies: the next frame and when the current one began showing.
124    frame: i64,
125    frame_ms: f64,
126}
127
128impl PlaybackClock {
129    /// A clock starting at `tick` at wall time `now_ms`, playing at `rate` (negative is backwards).
130    #[must_use]
131    pub fn new(
132        policy: PlaybackPolicy,
133        frame_rate: FrameRate,
134        rate: f64,
135        tick: i64,
136        now_ms: f64,
137    ) -> Self {
138        let frame = frame_rate.frame_at_or_before(alpha2_time::MediaTime(tick.max(0)));
139        Self {
140            policy,
141            rate,
142            frame_rate,
143            origin_ms: now_ms,
144            origin_tick: tick,
145            start_tick: tick,
146            following_audio: true,
147            last_audio: None,
148            last_audio_ms: now_ms,
149            heard_anchor_tick: 0.0,
150            heard_anchor_ms: now_ms,
151            heard_error: 0.0,
152            heard_bias: 0.0,
153            heard_shown: tick,
154            frame: frame.unwrap_or(0),
155            frame_ms: now_ms,
156        }
157    }
158
159    /// The policy it enforces.
160    #[must_use]
161    pub const fn policy(&self) -> PlaybackPolicy {
162        self.policy
163    }
164
165    /// The time to show at wall time `now_ms`. `heard` is the audio output position in ticks
166    /// while it runs; `presented` says whether the last requested frame has been shown.
167    pub fn step(&mut self, now_ms: f64, heard: Option<i64>, presented: bool) -> ClockStep {
168        let audible = self.policy.audible(self.rate);
169        match self.policy {
170            PlaybackPolicy::DropVideoKeepAudio | PlaybackPolicy::SilentScrub => {
171                let time = if audible {
172                    self.follow_audio(now_ms, heard)
173                } else {
174                    self.wall_tick(now_ms)
175                };
176                ClockStep {
177                    tick: self.frame_start_at(time),
178                    audible,
179                    wait_for_frame: false,
180                }
181            }
182            PlaybackPolicy::PreserveEveryVideoFrame | PlaybackPolicy::OfflineExact => {
183                let paced = self.policy == PlaybackPolicy::PreserveEveryVideoFrame;
184                let period = self.frame_ms_at_rate();
185                // Carry overshoot to the next deadline so frames average the rate on any display.
186                let due = !paced || now_ms - self.frame_ms >= period - PACING_SLACK_MS;
187                if presented && due {
188                    self.frame = (self.frame + if self.rate < 0.0 { -1 } else { 1 }).max(0);
189                    self.frame_ms = if paced {
190                        (self.frame_ms + period).max(now_ms - period)
191                    } else {
192                        now_ms
193                    };
194                }
195                ClockStep {
196                    tick: self.frame_tick(),
197                    audible: false,
198                    wait_for_frame: true,
199                }
200            }
201        }
202    }
203
204    /// The audio clock while it advances, else the wall clock from where the audio stopped.
205    ///
206    /// Outputs report the heard position in callback bursts, so it is smoothed. It runs on the
207    /// wall clock and moves only when the reports' averaged error leaves a dead band, so it cannot
208    /// drift from the device either.
209    fn follow_audio(&mut self, now_ms: f64, heard: Option<i64>) -> i64 {
210        // Audio a tick before the start (host sample rounding) still shows the start frame.
211        let heard = heard.map(|position| position.max(self.start_tick));
212        match heard {
213            Some(position) if Some(position) != self.last_audio => {
214                self.heard_report(now_ms, position);
215                self.last_audio = Some(position);
216                self.last_audio_ms = now_ms;
217                self.following_audio = true;
218            }
219            Some(position)
220                if self.following_audio && now_ms - self.last_audio_ms > AUDIO_STALL_MS =>
221            {
222                self.following_audio = false;
223                self.origin_ms = now_ms;
224                self.origin_tick = position.max(self.heard_shown);
225            }
226            _ => {}
227        }
228        match heard {
229            Some(_) if self.following_audio => {
230                // Within any timeline length.
231                #[allow(clippy::cast_possible_truncation)]
232                let estimate = (self.heard_estimate(now_ms) + self.heard_bias).floor() as i64;
233                self.heard_shown = self.heard_shown.max(estimate);
234                self.heard_shown
235            }
236            _ => self.wall_tick(now_ms),
237        }
238    }
239
240    /// The smoothed heard time at `now_ms`, in ticks, extrapolated at most
241    /// [`AUDIO_EXTRAPOLATE_MS`] past the last report.
242    fn heard_estimate(&self, now_ms: f64) -> f64 {
243        let until = now_ms.min(self.last_audio_ms + AUDIO_EXTRAPOLATE_MS);
244        self.heard_anchor_tick + (until - self.heard_anchor_ms).max(0.0) * TICKS_PER_MS
245    }
246
247    /// Takes in a new heard position. Call before `last_audio` and `last_audio_ms` move.
248    fn heard_report(&mut self, now_ms: f64, position: i64) {
249        // Within any timeline length.
250        #[allow(clippy::cast_precision_loss)]
251        let position = position as f64;
252        let error = position - self.heard_estimate(now_ms);
253        if self.last_audio.is_none()
254            || !self.following_audio
255            || error.abs() > AUDIO_SNAP_MS * TICKS_PER_MS
256        {
257            self.heard_anchor_tick = position;
258            self.heard_anchor_ms = now_ms;
259            self.heard_error = 0.0;
260            self.center_heard_bias(now_ms);
261            return;
262        }
263        if now_ms > self.last_audio_ms + AUDIO_EXTRAPOLATE_MS {
264            // The estimate held through a gap, so the line restarts where it stopped.
265            self.heard_anchor_tick = self.heard_estimate(now_ms);
266            self.heard_anchor_ms = now_ms;
267        }
268        self.heard_error += (error - self.heard_error) * AUDIO_ERROR_WEIGHT;
269        if self.heard_error.abs() > AUDIO_DEADBAND_MS * TICKS_PER_MS {
270            self.heard_anchor_tick += self.heard_error;
271            self.heard_error = 0.0;
272            self.center_heard_bias(now_ms);
273        }
274    }
275
276    fn center_heard_bias(&mut self, now_ms: f64) {
277        let time = self.heard_estimate(now_ms);
278        // Within any timeline length.
279        #[allow(clippy::cast_possible_truncation)]
280        let start = self.frame_start_at(time.floor() as i64);
281        #[allow(clippy::cast_precision_loss)]
282        let (start, end) = (start as f64, self.frame_end_after(start) as f64);
283        self.heard_bias = f64::midpoint(start, end) - time;
284    }
285
286    fn wall_tick(&self, now_ms: f64) -> i64 {
287        // `requestAnimationFrame` times can precede the clock's start.
288        let elapsed = (now_ms - self.origin_ms).max(0.0) * TICKS_PER_MS;
289        // Within any timeline length; the host stops at the end.
290        #[allow(clippy::cast_possible_truncation)]
291        let advanced = (elapsed * self.rate).floor() as i64;
292        self.origin_tick.saturating_add(advanced)
293    }
294
295    /// The start of the frame containing `tick`, in either direction.
296    fn frame_start_at(&self, tick: i64) -> i64 {
297        self.frame_rate
298            .frame_at_or_before(alpha2_time::MediaTime(tick.max(0)))
299            .and_then(|frame| self.frame_rate.frame_start(frame))
300            .map_or(0, alpha2_time::MediaTime::ticks)
301    }
302
303    /// The end of the frame starting at `start`.
304    fn frame_end_after(&self, start: i64) -> i64 {
305        self.frame_rate
306            .frame_at_or_before(alpha2_time::MediaTime(start.max(0)))
307            .and_then(|frame| self.frame_rate.frame_start(frame + 1))
308            .map_or(start, alpha2_time::MediaTime::ticks)
309    }
310
311    fn frame_tick(&self) -> i64 {
312        self.frame_rate
313            .frame_start(self.frame)
314            .map_or(0, alpha2_time::MediaTime::ticks)
315    }
316
317    /// How long one frame shows at the clock's rate, in milliseconds.
318    fn frame_ms_at_rate(&self) -> f64 {
319        let frame_ms = 1000.0 * f64::from(self.frame_rate.denominator())
320            / f64::from(self.frame_rate.numerator());
321        frame_ms / self.rate.abs().max(f64::MIN_POSITIVE)
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    fn rate() -> FrameRate {
330        FrameRate::new(25, 1).expect("rate")
331    }
332
333    #[test]
334    fn keeping_audio_follows_it_and_falls_back_to_the_wall_clock_when_it_stalls() {
335        let mut clock = PlaybackClock::new(PlaybackPolicy::DropVideoKeepAudio, rate(), 1.0, 0, 0.0);
336        // One frame at 25 fps is 4,800 ticks: the clock asks for the frame the audio is in.
337        let step = clock.step(10.0, Some(1_200), false);
338        assert_eq!(
339            (step.tick, step.audible, step.wait_for_frame),
340            (0, true, false)
341        );
342        // Between reports the heard time runs on for at most 80 ms (9,600 ticks), then holds.
343        assert_eq!(clock.step(100.0, Some(1_200), true).tick, 2 * 4_800);
344        let stalled = clock.step(400.0, Some(1_200), true);
345        assert_eq!(stalled.tick, 2 * 4_800);
346        // Picked mid-frame (1,200 ticks later), the held time is 12,000; the wall clock continues
347        // from it, and 100 ms later is frame 5.
348        let later = clock.step(500.0, Some(1_200), true);
349        assert_eq!(later.tick, 5 * 4_800);
350        assert_eq!(clock.step(510.0, Some(50_000), true).tick, 10 * 4_800);
351        let mut fast = PlaybackClock::new(PlaybackPolicy::DropVideoKeepAudio, rate(), 2.0, 0, 0.0);
352        let step = fast.step(1_000.0, Some(7), true);
353        assert_eq!((step.tick, step.audible), (240_000, false));
354    }
355
356    #[test]
357    fn audio_heard_before_the_start_shows_the_start() {
358        // 29.97 fps from frame 2 (tick 8008). 48 kHz output heard at tick 8007 (frame 1) still
359        // shows frame 2.
360        let ntsc = FrameRate::new(30_000, 1_001).expect("rate");
361        let mut clock =
362            PlaybackClock::new(PlaybackPolicy::DropVideoKeepAudio, ntsc, 1.0, 8_008, 0.0);
363        assert_eq!(clock.step(5.0, Some(8_007), false).tick, 8_008);
364        assert_eq!(clock.step(10.0, Some(8_007), true).tick, 8_008);
365        assert_eq!(clock.step(20.0, Some(9_000), true).tick, 8_008);
366        let mut stalled =
367            PlaybackClock::new(PlaybackPolicy::DropVideoKeepAudio, ntsc, 1.0, 8_008, 0.0);
368        stalled.step(1.0, Some(8_007), false);
369        assert_eq!(stalled.step(300.0, Some(8_007), true).tick, 8_008);
370        // Picked mid-frame, the held time is 8,508; 12,000 ticks later is inside frame 5.
371        assert_eq!(stalled.step(400.0, Some(8_007), true).tick, 20_020);
372    }
373
374    #[test]
375    fn preserving_every_frame_advances_one_frame_at_a_time_and_waits() {
376        let mut clock =
377            PlaybackClock::new(PlaybackPolicy::PreserveEveryVideoFrame, rate(), 1.0, 0, 0.0);
378        let frame = 4_800; // one frame at 25 fps
379        assert_eq!(clock.step(0.0, None, false).tick, 0);
380        // Shown, but its 40 ms are not up.
381        assert_eq!(clock.step(20.0, None, true).tick, 0);
382        let next = clock.step(41.0, None, true);
383        assert_eq!(
384            (next.tick, next.audible, next.wait_for_frame),
385            (frame, false, true)
386        );
387        assert_eq!(clock.step(500.0, None, false).tick, frame);
388        assert_eq!(clock.step(501.0, None, true).tick, 2 * frame);
389    }
390
391    #[test]
392    fn preserving_every_frame_keeps_the_rate_on_any_display() {
393        // Without carrying overshoot, 29.97 fps would play at 20 fps on a 60 Hz display.
394        for (num, den) in [
395            (24_000, 1_001),
396            (25, 1),
397            (30_000, 1_001),
398            (30, 1),
399            (60_000, 1_001),
400            (60, 1),
401        ] {
402            let rate = FrameRate::new(num, den).expect("rate");
403            let fps = f64::from(num) / f64::from(den);
404            for hz in [59.94, 60.0, 120.0, 144.0] {
405                for grain in [0.0, 0.1, 1.0] {
406                    if fps > hz {
407                        continue;
408                    }
409                    let mut clock = PlaybackClock::new(
410                        PlaybackPolicy::PreserveEveryVideoFrame,
411                        rate,
412                        1.0,
413                        0,
414                        0.0,
415                    );
416                    let seconds = 60.0;
417                    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
418                    let refreshes = (seconds * hz) as u64;
419                    let mut tick = 0;
420                    for refresh in 0..=refreshes {
421                        #[allow(clippy::cast_precision_loss)]
422                        let exact = refresh as f64 * 1_000.0 / hz;
423                        let now = if grain > 0.0 {
424                            (exact / grain).round() * grain
425                        } else {
426                            exact
427                        };
428                        tick = clock.step(now, None, true).tick;
429                    }
430                    let shown = clock.frame;
431                    let expected = seconds * fps;
432                    #[allow(clippy::cast_precision_loss)]
433                    let short = expected - shown as f64;
434                    assert!(
435                        short.abs() <= 1.0,
436                        "{fps} fps at {hz} Hz, {grain} ms: {shown} frames in {seconds} s, not {expected} (tick {tick})"
437                    );
438                }
439            }
440        }
441    }
442
443    #[test]
444    fn offline_exact_moves_as_soon_as_each_frame_is_shown() {
445        let mut clock = PlaybackClock::new(PlaybackPolicy::OfflineExact, rate(), 1.0, 9_600, 0.0);
446        assert_eq!(clock.step(0.0, None, false).tick, 9_600);
447        assert_eq!(clock.step(0.0, None, true).tick, 14_400);
448        assert_eq!(
449            PlaybackPolicy::OfflineExact.missing_frames(),
450            MissingFramePolicy::Wait
451        );
452        assert_eq!(
453            PlaybackPolicy::SilentScrub.missing_frames(),
454            MissingFramePolicy::HoldPrevious
455        );
456    }
457
458    #[test]
459    fn the_wall_clock_holds_at_its_start_until_its_start_time() {
460        let mut clock = PlaybackClock::new(PlaybackPolicy::SilentScrub, rate(), 1.0, 0, 1_000.0);
461        assert_eq!(clock.step(992.0, None, false).tick, 0);
462        // 60,000 ticks is inside frame 12.
463        assert_eq!(clock.step(1_500.0, None, true).tick, 12 * 4_800);
464        let mut silent = PlaybackClock::new(
465            PlaybackPolicy::DropVideoKeepAudio,
466            rate(),
467            1.0,
468            9_600,
469            1_000.0,
470        );
471        assert_eq!(silent.step(990.0, None, false).tick, 9_600);
472    }
473
474    #[test]
475    fn scrubbing_is_silent_and_follows_the_wall_clock_backwards_too() {
476        let mut clock = PlaybackClock::new(PlaybackPolicy::SilentScrub, rate(), -1.0, 120_000, 0.0);
477        let step = clock.step(500.0, Some(0), true);
478        assert_eq!((step.tick, step.audible), (12 * 4_800, false));
479    }
480
481    /// Frames advanced per display refresh over 10 s, skipping the first second, while the heard
482    /// position moves in bursts of `burst_ms` from a device clock running at `drift` times the
483    /// wall clock. Refresh times are rounded to 0.1 ms, as browsers report them.
484    #[allow(
485        clippy::cast_possible_truncation,
486        clippy::cast_sign_loss,
487        clippy::cast_precision_loss
488    )]
489    fn steps_per_refresh(fps: u32, hz: f64, burst_ms: f64, phase_ms: f64, drift: f64) -> Vec<i64> {
490        let frame_rate = FrameRate::new(fps, 1).expect("rate");
491        let mut clock =
492            PlaybackClock::new(PlaybackPolicy::DropVideoKeepAudio, frame_rate, 1.0, 0, 0.0);
493        let frame_ticks = TICKS_PER_SECOND / i64::from(fps);
494        let mut steps = Vec::new();
495        let mut previous = None;
496        for refresh in 0..(10.0 * hz) as u64 {
497            let now = ((phase_ms + refresh as f64 * 1_000.0 / hz) * 10.0).round() / 10.0;
498            // The output's callbacks are not aligned with the display or the frame grid.
499            let device = now * drift;
500            let callback = ((device - 5.3) / burst_ms).floor() * burst_ms + 5.3;
501            let heard = (callback.max(0.0) * TICKS_PER_MS) as i64;
502            let frame = clock.step(now, Some(heard), true).tick / frame_ticks;
503            if now >= 1_000.0
504                && let Some(previous) = previous
505            {
506                steps.push(frame - previous);
507            }
508            previous = Some(frame);
509        }
510        steps
511    }
512
513    fn count(steps: &[i64], step: i64) -> usize {
514        steps.iter().filter(|&&each| each == step).count()
515    }
516
517    #[test]
518    fn audio_reported_in_bursts_advances_one_frame_per_refresh_at_the_clip_rate() {
519        // 1024 and 1152 samples at 48 kHz, and a 40 ms output.
520        for burst_ms in [21.333, 24.0, 40.0] {
521            for phase in 0..17 {
522                let steps = steps_per_refresh(60, 60.0, burst_ms, f64::from(phase), 1.0);
523                // At most one repeat while the averaged error settles, and no skip.
524                assert!(
525                    count(&steps, 1) + 1 >= steps.len() && count(&steps, 2) == 0,
526                    "{burst_ms} ms bursts, phase {phase} ms: {steps:?}"
527                );
528            }
529        }
530        // A device 100 ppm fast moves the estimate once, repeating or skipping one frame.
531        let drifting = steps_per_refresh(60, 60.0, 21.333, 3.0, 1.000_1);
532        assert!(drifting.len() - count(&drifting, 1) <= 2, "{drifting:?}");
533        let fast_display = steps_per_refresh(60, 120.0, 21.333, 3.0, 1.0);
534        assert_eq!(count(&fast_display, 1), 540);
535        assert_eq!(count(&fast_display, 0), 540);
536        // 50 fps on 60 Hz repeats one frame in six and never skips.
537        let pulldown = steps_per_refresh(50, 60.0, 21.333, 3.0, 1.0);
538        assert_eq!(count(&pulldown, 0), 90);
539        assert_eq!(count(&pulldown, 1), 450);
540    }
541
542    #[test]
543    fn a_heard_position_far_from_the_estimate_is_jumped_to_but_never_backwards() {
544        let mut clock = PlaybackClock::new(PlaybackPolicy::DropVideoKeepAudio, rate(), 1.0, 0, 0.0);
545        clock.step(0.0, Some(0), false);
546        // 2 s ahead: snapped, not slewed.
547        assert_eq!(clock.step(20.0, Some(240_000), true).tick, 240_000);
548        // 150 ms behind what was shown: the picture holds instead of going back.
549        assert_eq!(clock.step(40.0, Some(240_000 - 18_000), true).tick, 240_000);
550    }
551
552    #[test]
553    fn the_wall_clock_after_an_audio_stall_is_quantized_too() {
554        let mut clock = PlaybackClock::new(PlaybackPolicy::DropVideoKeepAudio, rate(), 1.0, 0, 0.0);
555        clock.step(0.0, Some(100), false);
556        // Stalled past `AUDIO_STALL_MS`: the wall clock continues from the 2,400 ticks shown
557        // (100 picked mid-frame).
558        assert_eq!(clock.step(300.0, Some(100), true).tick, 0);
559        // 10 ms later is 3,600 ticks, still frame 0; 30 ms later is 6,000, frame 1.
560        assert_eq!(clock.step(310.0, Some(100), true).tick, 0);
561        assert_eq!(clock.step(330.0, Some(100), true).tick, 4_800);
562    }
563
564    #[test]
565    fn reverse_play_asks_for_the_frame_containing_the_time() {
566        // From inside frame 25 (120,000..124,800) backwards: 10 ms back is 121,000, then 30 ms
567        // more is frame 24.
568        let mut clock = PlaybackClock::new(PlaybackPolicy::SilentScrub, rate(), -1.0, 122_200, 0.0);
569        assert_eq!(clock.step(10.0, None, true).tick, 120_000);
570        assert_eq!(clock.step(40.0, None, true).tick, 115_200);
571        // Past the start it holds frame 0.
572        assert_eq!(clock.step(10_000.0, None, true).tick, 0);
573    }
574}