1use alpha2_time::{FrameRate, TICKS_PER_SECOND};
7use serde::{Deserialize, Serialize};
8
9use crate::MissingFramePolicy;
10
11const PACING_SLACK_MS: f64 = 0.5;
14
15#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
17#[serde(rename_all = "snake_case")]
18pub enum PlaybackPolicy {
19 #[default]
22 DropVideoKeepAudio,
23 PreserveEveryVideoFrame,
26 SilentScrub,
29 OfflineExact,
31}
32
33impl PlaybackPolicy {
34 pub const ALL: [Self; 4] = [
36 Self::DropVideoKeepAudio,
37 Self::PreserveEveryVideoFrame,
38 Self::SilentScrub,
39 Self::OfflineExact,
40 ];
41
42 #[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 #[must_use]
53 pub fn audible(self, rate: f64) -> bool {
54 self == Self::DropVideoKeepAudio && (rate - 1.0).abs() < f64::EPSILON
55 }
56
57 #[must_use]
59 pub const fn waits_for_frames(self) -> bool {
60 matches!(self, Self::PreserveEveryVideoFrame | Self::OfflineExact)
61 }
62}
63
64#[derive(Copy, Clone, Debug, Eq, PartialEq)]
66pub struct ClockStep {
67 pub tick: i64,
69 pub audible: bool,
71 pub wait_for_frame: bool,
73}
74
75const AUDIO_STALL_MS: f64 = 250.0;
77
78const AUDIO_EXTRAPOLATE_MS: f64 = 80.0;
81
82const AUDIO_SNAP_MS: f64 = 100.0;
85
86const AUDIO_ERROR_WEIGHT: f64 = 0.0625;
88
89const AUDIO_DEADBAND_MS: f64 = 8.0;
92
93#[allow(clippy::cast_precision_loss)]
95const TICKS_PER_MS: f64 = TICKS_PER_SECOND as f64 / 1000.0;
96
97#[derive(Clone, Debug)]
99pub struct PlaybackClock {
100 policy: PlaybackPolicy,
101 rate: f64,
102 frame_rate: FrameRate,
103 origin_ms: f64,
105 origin_tick: i64,
106 start_tick: i64,
108 following_audio: bool,
110 last_audio: Option<i64>,
111 last_audio_ms: f64,
112 heard_anchor_tick: f64,
114 heard_anchor_ms: f64,
115 heard_error: f64,
117 heard_bias: f64,
121 heard_shown: i64,
123 frame: i64,
125 frame_ms: f64,
126}
127
128impl PlaybackClock {
129 #[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 #[must_use]
161 pub const fn policy(&self) -> PlaybackPolicy {
162 self.policy
163 }
164
165 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 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 fn follow_audio(&mut self, now_ms: f64, heard: Option<i64>) -> i64 {
210 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 #[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 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 fn heard_report(&mut self, now_ms: f64, position: i64) {
249 #[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 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 #[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 let elapsed = (now_ms - self.origin_ms).max(0.0) * TICKS_PER_MS;
289 #[allow(clippy::cast_possible_truncation)]
291 let advanced = (elapsed * self.rate).floor() as i64;
292 self.origin_tick.saturating_add(advanced)
293 }
294
295 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 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 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 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 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 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 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 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; assert_eq!(clock.step(0.0, None, false).tick, 0);
380 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 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 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 #[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 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 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 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 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 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 assert_eq!(clock.step(20.0, Some(240_000), true).tick, 240_000);
548 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 assert_eq!(clock.step(300.0, Some(100), true).tick, 0);
559 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 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 assert_eq!(clock.step(10_000.0, None, true).tick, 0);
573 }
574}