use serde::Serialize; const DEFAULT_AUDIO_WINDOW_MS: u32 = 5; const DEFAULT_MAX_PAIR_GAP_S: f64 = 0.5; const DEFAULT_PULSE_PERIOD_S: f64 = 1.0; const DEFAULT_PULSE_WIDTH_S: f64 = 0.12; const DEFAULT_MARKER_TICK_PERIOD: u32 = 5; const CALIBRATION_MIN_PAIRED_EVENTS: usize = 8; const CALIBRATION_MAX_DRIFT_MS: f64 = 40.0; const CALIBRATION_SETTLED_SKEW_MS: f64 = 5.0; #[derive(Clone, Debug, PartialEq, Serialize)] pub struct SyncAnalysisReport { pub video_event_count: usize, pub audio_event_count: usize, pub paired_event_count: usize, pub first_skew_ms: f64, pub last_skew_ms: f64, pub mean_skew_ms: f64, pub median_skew_ms: f64, pub max_abs_skew_ms: f64, pub drift_ms: f64, pub skews_ms: Vec, pub video_onsets_s: Vec, pub audio_onsets_s: Vec, } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct SyncCalibrationRecommendation { pub ready: bool, pub recommended_audio_offset_adjust_us: i64, pub recommended_video_offset_adjust_us: i64, pub note: String, } impl SyncAnalysisReport { #[must_use] pub fn calibration_recommendation(&self) -> SyncCalibrationRecommendation { if self.paired_event_count < CALIBRATION_MIN_PAIRED_EVENTS { return SyncCalibrationRecommendation { ready: false, recommended_audio_offset_adjust_us: 0, recommended_video_offset_adjust_us: 0, note: format!( "need at least {CALIBRATION_MIN_PAIRED_EVENTS} paired pulses; saw {}", self.paired_event_count ), }; } if self.drift_ms.abs() > CALIBRATION_MAX_DRIFT_MS { return SyncCalibrationRecommendation { ready: false, recommended_audio_offset_adjust_us: 0, recommended_video_offset_adjust_us: 0, note: format!( "drift {:.1} ms exceeds the {:.1} ms calibration limit", self.drift_ms, CALIBRATION_MAX_DRIFT_MS ), }; } let recommended_audio_offset_adjust_us = (-(self.median_skew_ms * 1_000.0)).round() as i64; let recommended_video_offset_adjust_us = -recommended_audio_offset_adjust_us; let note = if self.median_skew_ms.abs() <= CALIBRATION_SETTLED_SKEW_MS { format!( "median skew {:.1} ms is already within the settled {:.1} ms band", self.median_skew_ms, CALIBRATION_SETTLED_SKEW_MS ) } else { format!( "apply the audio offset adjustment to move median skew from {:+.1} ms toward 0.0 ms", self.median_skew_ms ) }; SyncCalibrationRecommendation { ready: true, recommended_audio_offset_adjust_us, recommended_video_offset_adjust_us, note, } } } #[derive(Clone, Debug, PartialEq)] pub struct SyncAnalysisOptions { pub audio_window_ms: u32, pub max_pair_gap_s: f64, pub pulse_period_s: f64, pub pulse_width_s: f64, pub marker_tick_period: u32, } impl Default for SyncAnalysisOptions { fn default() -> Self { Self { audio_window_ms: DEFAULT_AUDIO_WINDOW_MS, max_pair_gap_s: DEFAULT_MAX_PAIR_GAP_S, pulse_period_s: DEFAULT_PULSE_PERIOD_S, pulse_width_s: DEFAULT_PULSE_WIDTH_S, marker_tick_period: DEFAULT_MARKER_TICK_PERIOD, } } } #[cfg(test)] mod tests { use super::{SyncAnalysisOptions, SyncAnalysisReport}; #[test] fn default_options_match_live_probe_expectations() { let options = SyncAnalysisOptions::default(); assert_eq!(options.audio_window_ms, 5); assert!((options.max_pair_gap_s - 0.5).abs() < f64::EPSILON); assert!((options.pulse_period_s - 1.0).abs() < f64::EPSILON); assert!((options.pulse_width_s - 0.12).abs() < f64::EPSILON); assert_eq!(options.marker_tick_period, 5); } #[test] fn calibration_recommendation_requires_enough_pairs() { let report = SyncAnalysisReport { video_event_count: 4, audio_event_count: 4, paired_event_count: 4, first_skew_ms: 20.0, last_skew_ms: 20.0, mean_skew_ms: 20.0, median_skew_ms: 20.0, max_abs_skew_ms: 20.0, drift_ms: 0.0, skews_ms: vec![20.0; 4], video_onsets_s: vec![], audio_onsets_s: vec![], }; let recommendation = report.calibration_recommendation(); assert!(!recommendation.ready); assert_eq!(recommendation.recommended_audio_offset_adjust_us, 0); assert!(recommendation.note.contains("need at least 8 paired pulses")); } #[test] fn calibration_recommendation_rejects_unstable_drift() { let report = SyncAnalysisReport { video_event_count: 12, audio_event_count: 12, paired_event_count: 12, first_skew_ms: 10.0, last_skew_ms: 70.0, mean_skew_ms: 40.0, median_skew_ms: 35.0, max_abs_skew_ms: 70.0, drift_ms: 60.0, skews_ms: vec![10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0], video_onsets_s: vec![], audio_onsets_s: vec![], }; let recommendation = report.calibration_recommendation(); assert!(!recommendation.ready); assert_eq!(recommendation.recommended_audio_offset_adjust_us, 0); assert!(recommendation.note.contains("drift 60.0 ms exceeds")); } #[test] fn calibration_recommendation_maps_median_skew_to_audio_and_video_offsets() { let report = SyncAnalysisReport { video_event_count: 14, audio_event_count: 14, paired_event_count: 12, first_skew_ms: 28.0, last_skew_ms: 32.0, mean_skew_ms: 30.0, median_skew_ms: 30.0, max_abs_skew_ms: 32.0, drift_ms: 4.0, skews_ms: vec![28.0, 30.0, 32.0], video_onsets_s: vec![], audio_onsets_s: vec![], }; let recommendation = report.calibration_recommendation(); assert!(recommendation.ready); assert_eq!(recommendation.recommended_audio_offset_adjust_us, -30_000); assert_eq!(recommendation.recommended_video_offset_adjust_us, 30_000); assert!(recommendation.note.contains("move median skew")); } #[test] fn calibration_recommendation_reports_when_skew_is_already_settled() { let report = SyncAnalysisReport { video_event_count: 14, audio_event_count: 14, paired_event_count: 12, first_skew_ms: 3.0, last_skew_ms: 4.0, mean_skew_ms: 3.5, median_skew_ms: 4.0, max_abs_skew_ms: 4.0, drift_ms: 1.0, skews_ms: vec![3.0, 4.0], video_onsets_s: vec![], audio_onsets_s: vec![], }; let recommendation = report.calibration_recommendation(); assert!(recommendation.ready); assert_eq!(recommendation.recommended_audio_offset_adjust_us, -4_000); assert!(recommendation.note.contains("already within the settled")); } }