235 lines
7.5 KiB
Rust
235 lines
7.5 KiB
Rust
#![cfg_attr(not(test), allow(dead_code))]
|
|
//! Timing helpers for the shared-clock sync probe.
|
|
|
|
use std::time::Duration;
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct PulseSchedule {
|
|
warmup: Duration,
|
|
pulse_period: Duration,
|
|
pulse_width: Duration,
|
|
marker_tick_period: u32,
|
|
}
|
|
|
|
impl PulseSchedule {
|
|
pub fn new(
|
|
warmup: Duration,
|
|
pulse_period: Duration,
|
|
pulse_width: Duration,
|
|
marker_tick_period: u32,
|
|
) -> Self {
|
|
assert!(!pulse_period.is_zero(), "pulse period must stay positive");
|
|
assert!(!pulse_width.is_zero(), "pulse width must stay positive");
|
|
assert!(
|
|
pulse_width < pulse_period,
|
|
"pulse width must stay smaller than the pulse period"
|
|
);
|
|
assert!(
|
|
marker_tick_period > 0,
|
|
"marker tick period must stay positive"
|
|
);
|
|
|
|
Self {
|
|
warmup,
|
|
pulse_period,
|
|
pulse_width,
|
|
marker_tick_period,
|
|
}
|
|
}
|
|
|
|
pub fn warmup(&self) -> Duration {
|
|
self.warmup
|
|
}
|
|
|
|
pub fn pulse_period(&self) -> Duration {
|
|
self.pulse_period
|
|
}
|
|
|
|
pub fn marker_tick_period(&self) -> u32 {
|
|
self.marker_tick_period
|
|
}
|
|
|
|
pub fn pulse_width(&self) -> Duration {
|
|
self.pulse_width
|
|
}
|
|
|
|
pub fn pulse_index(&self, pts: Duration) -> u64 {
|
|
if pts < self.warmup_boundary() {
|
|
return 0;
|
|
}
|
|
let pts = pts - self.warmup_boundary();
|
|
let period_ns = self.pulse_period.as_nanos().max(1);
|
|
(pts.as_nanos() / period_ns) as u64
|
|
}
|
|
|
|
pub fn pulse_offset(&self, pts: Duration) -> Duration {
|
|
if pts < self.warmup_boundary() {
|
|
return pts;
|
|
}
|
|
let pts = pts - self.warmup_boundary();
|
|
let period_ns = self.pulse_period.as_nanos().max(1);
|
|
let offset_ns = (pts.as_nanos() % period_ns) as u64;
|
|
Duration::from_nanos(offset_ns)
|
|
}
|
|
|
|
pub fn pulse_is_marker(&self, pts: Duration) -> bool {
|
|
pts >= self.warmup_boundary()
|
|
&& self
|
|
.pulse_index(pts)
|
|
.is_multiple_of(u64::from(self.marker_tick_period.max(1)))
|
|
}
|
|
|
|
pub fn marker_pulse_width(&self) -> Duration {
|
|
let widened = self.pulse_width.saturating_mul(2);
|
|
widened.min(self.pulse_period.saturating_sub(Duration::from_millis(1)))
|
|
}
|
|
|
|
pub fn flash_active(&self, pts: Duration) -> bool {
|
|
if pts < self.warmup_boundary() {
|
|
return false;
|
|
}
|
|
let width = if self.pulse_is_marker(pts) {
|
|
self.marker_pulse_width()
|
|
} else {
|
|
self.pulse_width
|
|
};
|
|
self.pulse_offset(pts) < width
|
|
}
|
|
|
|
pub fn warmup_boundary(&self) -> Duration {
|
|
if self.warmup.is_zero() {
|
|
return Duration::ZERO;
|
|
}
|
|
let period_ns = self.pulse_period.as_nanos().max(1) as u64;
|
|
let warmup_ns = self.warmup.as_nanos() as u64;
|
|
let rounded = ((warmup_ns + period_ns - 1) / period_ns) * period_ns;
|
|
Duration::from_nanos(rounded)
|
|
}
|
|
|
|
pub fn audio_gate_open_at(&self) -> Duration {
|
|
let lead = self.pulse_width.min(Duration::from_millis(200));
|
|
self.warmup_boundary().saturating_sub(lead)
|
|
}
|
|
|
|
pub fn frame_pts(&self, frame_index: u64, fps: u32) -> Duration {
|
|
let frame_step_ns = 1_000_000_000u64 / u64::from(fps.max(1));
|
|
Duration::from_nanos(frame_index.saturating_mul(frame_step_ns))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::PulseSchedule;
|
|
use std::time::Duration;
|
|
|
|
#[test]
|
|
fn flash_windows_follow_the_expected_pulse_boundaries() {
|
|
let schedule = PulseSchedule::new(
|
|
Duration::from_secs(4),
|
|
Duration::from_millis(1_000),
|
|
Duration::from_millis(120),
|
|
5,
|
|
);
|
|
|
|
assert!(!schedule.flash_active(Duration::from_millis(3_999)));
|
|
assert!(schedule.flash_active(Duration::from_millis(4_000)));
|
|
assert!(schedule.flash_active(Duration::from_millis(4_200)));
|
|
assert!(!schedule.flash_active(Duration::from_millis(4_240)));
|
|
assert!(!schedule.flash_active(Duration::from_millis(4_999)));
|
|
assert!(schedule.flash_active(Duration::from_millis(5_000)));
|
|
assert!(!schedule.flash_active(Duration::from_millis(5_120)));
|
|
}
|
|
|
|
#[test]
|
|
fn pulse_index_and_offset_repeat_cleanly() {
|
|
let schedule = PulseSchedule::new(
|
|
Duration::from_secs(2),
|
|
Duration::from_millis(750),
|
|
Duration::from_millis(90),
|
|
3,
|
|
);
|
|
|
|
assert_eq!(schedule.pulse_index(Duration::from_millis(0)), 0);
|
|
assert_eq!(schedule.warmup_boundary(), Duration::from_millis(2_250));
|
|
assert_eq!(schedule.pulse_index(Duration::from_millis(2_999)), 0);
|
|
assert_eq!(schedule.pulse_index(Duration::from_millis(3_000)), 1);
|
|
assert_eq!(
|
|
schedule.pulse_offset(Duration::from_millis(2_320)),
|
|
Duration::from_millis(70)
|
|
);
|
|
assert_eq!(
|
|
schedule.pulse_offset(Duration::from_millis(3_750)),
|
|
Duration::from_millis(0)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn frame_pts_respects_requested_framerate() {
|
|
let schedule = PulseSchedule::new(
|
|
Duration::ZERO,
|
|
Duration::from_secs(1),
|
|
Duration::from_millis(100),
|
|
5,
|
|
);
|
|
|
|
assert_eq!(schedule.frame_pts(0, 30), Duration::from_nanos(0));
|
|
assert_eq!(schedule.frame_pts(1, 30), Duration::from_nanos(33_333_333));
|
|
assert_eq!(
|
|
schedule.frame_pts(30, 30),
|
|
Duration::from_nanos(999_999_990)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn getters_and_zero_fps_fallback_stay_stable() {
|
|
let schedule = PulseSchedule::new(
|
|
Duration::from_secs(3),
|
|
Duration::from_millis(800),
|
|
Duration::from_millis(90),
|
|
7,
|
|
);
|
|
|
|
assert_eq!(schedule.warmup(), Duration::from_secs(3));
|
|
assert_eq!(schedule.warmup_boundary(), Duration::from_millis(3_200));
|
|
assert_eq!(schedule.audio_gate_open_at(), Duration::from_millis(3_110));
|
|
assert_eq!(schedule.pulse_period(), Duration::from_millis(800));
|
|
assert_eq!(schedule.pulse_width(), Duration::from_millis(90));
|
|
assert_eq!(schedule.marker_tick_period(), 7);
|
|
assert_eq!(schedule.frame_pts(1, 0), Duration::from_secs(1));
|
|
}
|
|
|
|
#[test]
|
|
fn marker_pulses_are_detectably_wider_than_regular_pulses() {
|
|
let schedule = PulseSchedule::new(
|
|
Duration::from_secs(1),
|
|
Duration::from_millis(1_000),
|
|
Duration::from_millis(120),
|
|
5,
|
|
);
|
|
|
|
assert!(schedule.pulse_is_marker(Duration::from_millis(1_000)));
|
|
assert_eq!(schedule.marker_pulse_width(), Duration::from_millis(240));
|
|
assert!(schedule.flash_active(Duration::from_millis(1_200)));
|
|
assert!(!schedule.flash_active(Duration::from_millis(1_241)));
|
|
assert!(!schedule.pulse_is_marker(Duration::from_millis(2_000)));
|
|
assert!(!schedule.flash_active(Duration::from_millis(2_200)));
|
|
}
|
|
|
|
#[test]
|
|
#[should_panic(expected = "pulse period must stay positive")]
|
|
fn constructor_rejects_zero_period() {
|
|
let _ = PulseSchedule::new(Duration::ZERO, Duration::ZERO, Duration::from_millis(50), 1);
|
|
}
|
|
|
|
#[test]
|
|
#[should_panic(expected = "pulse width must stay smaller than the pulse period")]
|
|
fn constructor_rejects_width_not_smaller_than_period() {
|
|
let _ = PulseSchedule::new(
|
|
Duration::ZERO,
|
|
Duration::from_millis(100),
|
|
Duration::from_millis(100),
|
|
1,
|
|
);
|
|
}
|
|
}
|