#![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, ); } }