Skip to main content

alpha2_engine/
playback.rs

1//! How real-time playback trades pictures against sound, and the clock that
2//! enforces it.
3//!
4//! Quality (how a frame is drawn) and the clock (which frame is shown when)
5//! are separate choices: a [`crate::QualityProfile`] decides the first, a
6//! [`PlaybackPolicy`] the second.
7
8use alpha2_time::{FrameRate, TICKS_PER_SECOND};
9use serde::{Deserialize, Serialize};
10
11use crate::MissingFramePolicy;
12
13/// How early a paced frame may advance, in milliseconds: a display's
14/// refresh timestamps are rounded (browsers to 0.1 ms or coarser), so a
15/// refresh that is due can read a fraction early.
16const PACING_SLACK_MS: f64 = 0.5;
17
18/// Which frame real-time playback shows, and whether sound plays.
19#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
20#[serde(rename_all = "snake_case")]
21pub enum PlaybackPolicy {
22    /// Sound leads at 1x: the picture shows the frame at the audio clock's
23    /// time, dropping frames that are late and holding the last one while
24    /// inputs are missing. At any other rate the picture follows the wall clock and
25    /// sound is silent.
26    #[default]
27    DropVideoKeepAudio,
28    /// Every frame is shown, in order, each once its inputs are ready, paced
29    /// no faster than the rate asks: playback slows rather than skips, and is
30    /// silent.
31    PreserveEveryVideoFrame,
32    /// The picture follows the wall clock at any rate, holding the last frame
33    /// while inputs are missing, and is silent: for shuttling and scrubbing.
34    SilentScrub,
35    /// Every frame, in order, each after its inputs are ready, as fast as it
36    /// renders: what an export does. Silent.
37    OfflineExact,
38}
39
40impl PlaybackPolicy {
41    /// Every policy, in the order a host lists them.
42    pub const ALL: [Self; 4] = [
43        Self::DropVideoKeepAudio,
44        Self::PreserveEveryVideoFrame,
45        Self::SilentScrub,
46        Self::OfflineExact,
47    ];
48
49    /// What a frame whose inputs are missing shows.
50    #[must_use]
51    pub const fn missing_frames(self) -> MissingFramePolicy {
52        match self {
53            Self::DropVideoKeepAudio | Self::SilentScrub => MissingFramePolicy::HoldPrevious,
54            Self::PreserveEveryVideoFrame | Self::OfflineExact => MissingFramePolicy::Wait,
55        }
56    }
57
58    /// Whether sound plays at `rate`: only when it leads, at 1x.
59    #[must_use]
60    pub fn audible(self, rate: f64) -> bool {
61        self == Self::DropVideoKeepAudio && (rate - 1.0).abs() < f64::EPSILON
62    }
63
64    /// Whether the clock waits for each frame to be shown before the next.
65    #[must_use]
66    pub const fn waits_for_frames(self) -> bool {
67        matches!(self, Self::PreserveEveryVideoFrame | Self::OfflineExact)
68    }
69}
70
71/// What the clock asks for at one moment.
72#[derive(Copy, Clone, Debug, Eq, PartialEq)]
73pub struct ClockStep {
74    /// The start of the frame to show, in ticks: a timeline at a frame rate
75    /// shows each frame over its whole interval, as export renders it.
76    pub tick: i64,
77    /// Whether sound should be playing.
78    pub audible: bool,
79    /// Whether this frame must be shown before the clock moves on.
80    pub wait_for_frame: bool,
81}
82
83/// How long the audio clock may stand still before the picture continues on
84/// the wall clock from where it stood, in milliseconds.
85const AUDIO_STALL_MS: f64 = 250.0;
86
87/// The playback clock of one run of playback: asked each display refresh
88/// which timeline time to show.
89#[derive(Clone, Debug)]
90pub struct PlaybackClock {
91    policy: PlaybackPolicy,
92    rate: f64,
93    frame_rate: FrameRate,
94    /// Wall time and tick the wall clock counts from.
95    origin_ms: f64,
96    origin_tick: i64,
97    /// The tick playback started at: neither clock runs before it.
98    start_tick: i64,
99    /// Whether the audio clock leads.
100    following_audio: bool,
101    last_audio: Option<i64>,
102    last_audio_ms: f64,
103    /// For a frame-by-frame policy: the frame shown next, and when the
104    /// current one began showing.
105    frame: i64,
106    frame_ms: f64,
107}
108
109impl PlaybackClock {
110    /// A clock starting at `tick` at wall time `now_ms`, playing at `rate`
111    /// (negative plays backwards) under `policy`.
112    #[must_use]
113    pub fn new(
114        policy: PlaybackPolicy,
115        frame_rate: FrameRate,
116        rate: f64,
117        tick: i64,
118        now_ms: f64,
119    ) -> Self {
120        let frame = frame_rate.frame_at_or_before(alpha2_time::MediaTime(tick.max(0)));
121        Self {
122            policy,
123            rate,
124            frame_rate,
125            origin_ms: now_ms,
126            origin_tick: tick,
127            start_tick: tick,
128            following_audio: true,
129            last_audio: None,
130            last_audio_ms: now_ms,
131            frame: frame.unwrap_or(0),
132            frame_ms: now_ms,
133        }
134    }
135
136    /// The policy it enforces.
137    #[must_use]
138    pub const fn policy(&self) -> PlaybackPolicy {
139        self.policy
140    }
141
142    /// The time to show at wall time `now_ms`. `heard` is the audio output's
143    /// position in ticks while it runs (a position before the clock's start
144    /// is its start); `presented` says whether the frame the clock last asked
145    /// for has been shown.
146    pub fn step(&mut self, now_ms: f64, heard: Option<i64>, presented: bool) -> ClockStep {
147        let audible = self.policy.audible(self.rate);
148        match self.policy {
149            PlaybackPolicy::DropVideoKeepAudio | PlaybackPolicy::SilentScrub => {
150                let time = if audible {
151                    self.follow_audio(now_ms, heard)
152                } else {
153                    self.wall_tick(now_ms)
154                };
155                ClockStep {
156                    tick: self.frame_start_at(time),
157                    audible,
158                    wait_for_frame: false,
159                }
160            }
161            PlaybackPolicy::PreserveEveryVideoFrame | PlaybackPolicy::OfflineExact => {
162                let paced = self.policy == PlaybackPolicy::PreserveEveryVideoFrame;
163                let period = self.frame_ms_at_rate();
164                // Carry each refresh's overshoot to the next deadline so frames average the rate on
165                // any display; a late frame is followed by at most one more. The slack absorbs
166                // rounded timestamps.
167                let due = !paced || now_ms - self.frame_ms >= period - PACING_SLACK_MS;
168                if presented && due {
169                    self.frame = (self.frame + if self.rate < 0.0 { -1 } else { 1 }).max(0);
170                    self.frame_ms = if paced {
171                        (self.frame_ms + period).max(now_ms - period)
172                    } else {
173                        now_ms
174                    };
175                }
176                ClockStep {
177                    tick: self.frame_tick(),
178                    audible: false,
179                    wait_for_frame: true,
180                }
181            }
182        }
183    }
184
185    /// The audio clock while it advances; the wall clock, from where the
186    /// audio stood, while it does not.
187    fn follow_audio(&mut self, now_ms: f64, heard: Option<i64>) -> i64 {
188        // Sound leads only at 1x, forwards. Output that starts a tick before the start (a host
189        // rounding to its nearest sample) still shows the start frame.
190        let heard = heard.map(|position| position.max(self.start_tick));
191        match heard {
192            Some(position) if Some(position) != self.last_audio => {
193                self.last_audio = Some(position);
194                self.last_audio_ms = now_ms;
195                self.following_audio = true;
196            }
197            Some(position)
198                if self.following_audio && now_ms - self.last_audio_ms > AUDIO_STALL_MS =>
199            {
200                self.following_audio = false;
201                self.origin_ms = now_ms;
202                self.origin_tick = position;
203            }
204            _ => {}
205        }
206        match heard {
207            Some(position) if self.following_audio => position,
208            _ => self.wall_tick(now_ms),
209        }
210    }
211
212    fn wall_tick(&self, now_ms: f64) -> i64 {
213        // 120,000 is exact in f64.
214        #[allow(clippy::cast_precision_loss)]
215        let ticks_per_ms = TICKS_PER_SECOND as f64 / 1000.0;
216        // A refresh can be stamped before the clock started (a browser's `requestAnimationFrame`
217        // time is the frame's start).
218        let elapsed = (now_ms - self.origin_ms).max(0.0) * ticks_per_ms;
219        // Within any timeline length; the host stops at the end.
220        #[allow(clippy::cast_possible_truncation)]
221        let advanced = (elapsed * self.rate).floor() as i64;
222        self.origin_tick.saturating_add(advanced)
223    }
224
225    /// The start of the frame containing `tick`, in either direction.
226    fn frame_start_at(&self, tick: i64) -> i64 {
227        self.frame_rate
228            .frame_at_or_before(alpha2_time::MediaTime(tick.max(0)))
229            .and_then(|frame| self.frame_rate.frame_start(frame))
230            .map_or(0, alpha2_time::MediaTime::ticks)
231    }
232
233    fn frame_tick(&self) -> i64 {
234        self.frame_rate
235            .frame_start(self.frame)
236            .map_or(0, alpha2_time::MediaTime::ticks)
237    }
238
239    /// How long one frame shows at the clock's rate, in milliseconds.
240    fn frame_ms_at_rate(&self) -> f64 {
241        let frame_ms = 1000.0 * f64::from(self.frame_rate.denominator())
242            / f64::from(self.frame_rate.numerator());
243        frame_ms / self.rate.abs().max(f64::MIN_POSITIVE)
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    fn rate() -> FrameRate {
252        FrameRate::new(25, 1).expect("rate")
253    }
254
255    #[test]
256    fn keeping_audio_follows_it_and_falls_back_to_the_wall_clock_when_it_stalls() {
257        let mut clock = PlaybackClock::new(PlaybackPolicy::DropVideoKeepAudio, rate(), 1.0, 0, 0.0);
258        // One frame at 25 fps is 4,800 ticks: the clock asks for the frame the audio is in.
259        let step = clock.step(10.0, Some(1_200), false);
260        assert_eq!(
261            (step.tick, step.audible, step.wait_for_frame),
262            (0, true, false)
263        );
264        // The audio stands still past the stall: the picture continues on the wall clock from
265        // there.
266        clock.step(100.0, Some(1_200), true);
267        let stalled = clock.step(400.0, Some(1_200), true);
268        assert_eq!(stalled.tick, 0);
269        // 1,200 + 12,000 ticks is inside frame 2.
270        let later = clock.step(500.0, Some(1_200), true);
271        assert_eq!(later.tick, 2 * 4_800);
272        assert_eq!(clock.step(510.0, Some(50_000), true).tick, 10 * 4_800);
273        // At any rate other than 1x it is silent and on the wall clock.
274        let mut fast = PlaybackClock::new(PlaybackPolicy::DropVideoKeepAudio, rate(), 2.0, 0, 0.0);
275        let step = fast.step(1_000.0, Some(7), true);
276        assert_eq!((step.tick, step.audible), (240_000, false));
277    }
278
279    #[test]
280    fn audio_heard_before_the_start_shows_the_start() {
281        // 29.97 fps, playback from frame 2 (tick 8008). A host that starts its output on the
282        // nearest sample at 48 kHz (3203, tick 8007.5) hears tick 8007 first: frame 1.
283        let ntsc = FrameRate::new(30_000, 1_001).expect("rate");
284        let mut clock =
285            PlaybackClock::new(PlaybackPolicy::DropVideoKeepAudio, ntsc, 1.0, 8_008, 0.0);
286        assert_eq!(clock.step(5.0, Some(8_007), false).tick, 8_008);
287        assert_eq!(clock.step(10.0, Some(8_007), true).tick, 8_008);
288        assert_eq!(clock.step(20.0, Some(9_000), true).tick, 8_008);
289        // A stall continues on the wall clock from where it stood, never before the start.
290        let mut stalled =
291            PlaybackClock::new(PlaybackPolicy::DropVideoKeepAudio, ntsc, 1.0, 8_008, 0.0);
292        stalled.step(1.0, Some(8_007), false);
293        assert_eq!(stalled.step(300.0, Some(8_007), true).tick, 8_008);
294        // 8,008 + 12,000 ticks is inside frame 4, which starts at 16,016.
295        assert_eq!(stalled.step(400.0, Some(8_007), true).tick, 16_016);
296    }
297
298    #[test]
299    fn preserving_every_frame_advances_one_frame_at_a_time_and_waits() {
300        let mut clock =
301            PlaybackClock::new(PlaybackPolicy::PreserveEveryVideoFrame, rate(), 1.0, 0, 0.0);
302        let frame = 4_800; // one frame at 25 fps
303        assert_eq!(clock.step(0.0, None, false).tick, 0);
304        // Shown, but its 40 ms are not up.
305        assert_eq!(clock.step(20.0, None, true).tick, 0);
306        let next = clock.step(41.0, None, true);
307        assert_eq!(
308            (next.tick, next.audible, next.wait_for_frame),
309            (frame, false, true)
310        );
311        assert_eq!(clock.step(500.0, None, false).tick, frame);
312        assert_eq!(clock.step(501.0, None, true).tick, 2 * frame);
313    }
314
315    #[test]
316    fn preserving_every_frame_keeps_the_rate_on_any_display() {
317        // Restarting each frame's wait at the refresh it advanced on would hold every frame a
318        // whole number of refreshes, so 29.97 fps would play at 20 fps on a 60 Hz display.
319        for (num, den) in [
320            (24_000, 1_001),
321            (25, 1),
322            (30_000, 1_001),
323            (30, 1),
324            (60_000, 1_001),
325            (60, 1),
326        ] {
327            let rate = FrameRate::new(num, den).expect("rate");
328            let fps = f64::from(num) / f64::from(den);
329            for hz in [59.94, 60.0, 120.0, 144.0] {
330                for grain in [0.0, 0.1, 1.0] {
331                    if fps > hz {
332                        continue;
333                    }
334                    let mut clock = PlaybackClock::new(
335                        PlaybackPolicy::PreserveEveryVideoFrame,
336                        rate,
337                        1.0,
338                        0,
339                        0.0,
340                    );
341                    let seconds = 60.0;
342                    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
343                    let refreshes = (seconds * hz) as u64;
344                    let mut tick = 0;
345                    for refresh in 0..=refreshes {
346                        #[allow(clippy::cast_precision_loss)]
347                        let exact = refresh as f64 * 1_000.0 / hz;
348                        let now = if grain > 0.0 {
349                            (exact / grain).round() * grain
350                        } else {
351                            exact
352                        };
353                        tick = clock.step(now, None, true).tick;
354                    }
355                    let shown = clock.frame;
356                    let expected = seconds * fps;
357                    #[allow(clippy::cast_precision_loss)]
358                    let short = expected - shown as f64;
359                    assert!(
360                        short.abs() <= 1.0,
361                        "{fps} fps at {hz} Hz, {grain} ms: {shown} frames in {seconds} s, not {expected} (tick {tick})"
362                    );
363                }
364            }
365        }
366    }
367
368    #[test]
369    fn offline_exact_moves_as_soon_as_each_frame_is_shown() {
370        let mut clock = PlaybackClock::new(PlaybackPolicy::OfflineExact, rate(), 1.0, 9_600, 0.0);
371        assert_eq!(clock.step(0.0, None, false).tick, 9_600);
372        assert_eq!(clock.step(0.0, None, true).tick, 14_400);
373        assert_eq!(
374            PlaybackPolicy::OfflineExact.missing_frames(),
375            MissingFramePolicy::Wait
376        );
377        assert_eq!(
378            PlaybackPolicy::SilentScrub.missing_frames(),
379            MissingFramePolicy::HoldPrevious
380        );
381    }
382
383    #[test]
384    fn the_wall_clock_holds_at_its_start_until_its_start_time() {
385        let mut clock = PlaybackClock::new(PlaybackPolicy::SilentScrub, rate(), 1.0, 0, 1_000.0);
386        assert_eq!(clock.step(992.0, None, false).tick, 0);
387        // 60,000 ticks is inside frame 12.
388        assert_eq!(clock.step(1_500.0, None, true).tick, 12 * 4_800);
389        let mut silent = PlaybackClock::new(
390            PlaybackPolicy::DropVideoKeepAudio,
391            rate(),
392            1.0,
393            9_600,
394            1_000.0,
395        );
396        assert_eq!(silent.step(990.0, None, false).tick, 9_600);
397    }
398
399    #[test]
400    fn scrubbing_is_silent_and_follows_the_wall_clock_backwards_too() {
401        let mut clock = PlaybackClock::new(PlaybackPolicy::SilentScrub, rate(), -1.0, 120_000, 0.0);
402        let step = clock.step(500.0, Some(0), true);
403        assert_eq!((step.tick, step.audible), (12 * 4_800, false));
404    }
405
406    #[test]
407    fn audio_positions_inside_one_frame_ask_for_that_frame_once() {
408        let mut clock = PlaybackClock::new(PlaybackPolicy::DropVideoKeepAudio, rate(), 1.0, 0, 0.0);
409        // Three display refreshes inside frame 1 (4,800..9,600) ask for the same tick.
410        let ticks: Vec<i64> = [(16.0, 4_800), (32.0, 6_400), (48.0, 9_599)]
411            .into_iter()
412            .map(|(now, heard)| clock.step(now, Some(heard), true).tick)
413            .collect();
414        assert_eq!(ticks, [4_800; 3]);
415        // Crossing the boundary asks for the next frame's start.
416        assert_eq!(clock.step(64.0, Some(9_600), true).tick, 9_600);
417    }
418
419    #[test]
420    fn the_wall_clock_after_an_audio_stall_is_quantized_too() {
421        let mut clock = PlaybackClock::new(PlaybackPolicy::DropVideoKeepAudio, rate(), 1.0, 0, 0.0);
422        clock.step(0.0, Some(100), false);
423        // Stalled past `AUDIO_STALL_MS`: the wall clock continues from 100 ticks.
424        assert_eq!(clock.step(300.0, Some(100), true).tick, 0);
425        // 30 ms later is 3,700 ticks, still frame 0; 40 ms later is 4,900, frame 1.
426        assert_eq!(clock.step(330.0, Some(100), true).tick, 0);
427        assert_eq!(clock.step(340.0, Some(100), true).tick, 4_800);
428    }
429
430    #[test]
431    fn reverse_play_asks_for_the_frame_containing_the_time() {
432        // From inside frame 25 (120,000..124,800) backwards: 10 ms back is 121,000, then 30 ms
433        // more is frame 24.
434        let mut clock = PlaybackClock::new(PlaybackPolicy::SilentScrub, rate(), -1.0, 122_200, 0.0);
435        assert_eq!(clock.step(10.0, None, true).tick, 120_000);
436        assert_eq!(clock.step(40.0, None, true).tick, 115_200);
437        // Past the start it holds frame 0.
438        assert_eq!(clock.step(10_000.0, None, true).tick, 0);
439    }
440}