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 = 13; const CALIBRATION_MAX_DRIFT_MS: f64 = 40.0; const CALIBRATION_MAX_START_MEDIAN_DISAGREEMENT_MS: f64 = 250.0; const CALIBRATION_SETTLED_SKEW_MS: f64 = 5.0; const RAW_ACTIVITY_CONFIRMS_PAIR_MAX_DISAGREEMENT_MS: f64 = 250.0; const VERDICT_MIN_PAIRED_EVENTS: usize = 3; const VERDICT_PREFERRED_P95_ABS_SKEW_MS: f64 = 35.0; const VERDICT_ACCEPTABLE_P95_ABS_SKEW_MS: f64 = 80.0; const VERDICT_GROSS_FAILURE_P95_ABS_SKEW_MS: f64 = 250.0; const VERDICT_CATASTROPHIC_MAX_ABS_SKEW_MS: f64 = 1_000.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 coded_events: bool, pub activity_start_delta_ms: f64, pub raw_first_video_activity_s: f64, pub raw_first_audio_activity_s: f64, 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, pub paired_events: Vec, } #[derive(Clone, Debug, PartialEq, Serialize)] pub struct SyncEventPair { pub event_id: usize, #[serde(skip_serializing_if = "Option::is_none")] pub server_event_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub event_code: Option, pub video_time_s: f64, pub audio_time_s: f64, pub skew_ms: f64, pub confidence: f64, } #[derive(Clone, Debug, PartialEq, Serialize)] pub struct SyncAnalysisVerdict { pub status: String, pub passed: bool, pub p95_abs_skew_ms: f64, pub max_abs_skew_ms: f64, pub preferred_p95_abs_skew_ms: f64, pub acceptable_p95_abs_skew_ms: f64, pub gross_failure_p95_abs_skew_ms: f64, pub catastrophic_max_abs_skew_ms: f64, pub reason: String, } #[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 verdict(&self) -> SyncAnalysisVerdict { let p95_abs_skew_ms = percentile_abs(&self.skews_ms, 0.95); let base = SyncAnalysisVerdict { status: String::new(), passed: false, p95_abs_skew_ms, max_abs_skew_ms: self.max_abs_skew_ms, preferred_p95_abs_skew_ms: VERDICT_PREFERRED_P95_ABS_SKEW_MS, acceptable_p95_abs_skew_ms: VERDICT_ACCEPTABLE_P95_ABS_SKEW_MS, gross_failure_p95_abs_skew_ms: VERDICT_GROSS_FAILURE_P95_ABS_SKEW_MS, catastrophic_max_abs_skew_ms: VERDICT_CATASTROPHIC_MAX_ABS_SKEW_MS, reason: String::new(), }; if self.paired_event_count < VERDICT_MIN_PAIRED_EVENTS { return SyncAnalysisVerdict { status: "insufficient_data".to_string(), reason: format!( "need at least {VERDICT_MIN_PAIRED_EVENTS} paired events; saw {}", self.paired_event_count ), ..base }; } if self.max_abs_skew_ms >= VERDICT_CATASTROPHIC_MAX_ABS_SKEW_MS { return SyncAnalysisVerdict { status: "catastrophic_failure".to_string(), reason: format!( "max skew {:.1} ms is at or above the {:.1} ms catastrophic boundary", self.max_abs_skew_ms, VERDICT_CATASTROPHIC_MAX_ABS_SKEW_MS ), ..base }; } if self.activity_start_delta_ms.abs() >= VERDICT_CATASTROPHIC_MAX_ABS_SKEW_MS && self.raw_activity_start_is_verdict_relevant() { return SyncAnalysisVerdict { status: "catastrophic_failure".to_string(), reason: format!( "audio/video activity starts are separated by {:+.1} ms, at or above the {:.1} ms catastrophic boundary", self.activity_start_delta_ms, VERDICT_CATASTROPHIC_MAX_ABS_SKEW_MS ), ..base }; } if p95_abs_skew_ms > VERDICT_GROSS_FAILURE_P95_ABS_SKEW_MS { return SyncAnalysisVerdict { status: "gross_failure".to_string(), reason: format!( "p95 skew {:.1} ms exceeds the {:.1} ms gross-failure boundary", p95_abs_skew_ms, VERDICT_GROSS_FAILURE_P95_ABS_SKEW_MS ), ..base }; } let raw_activity_note = self.raw_activity_note(); if p95_abs_skew_ms <= VERDICT_PREFERRED_P95_ABS_SKEW_MS { return SyncAnalysisVerdict { status: "preferred".to_string(), passed: true, reason: format!( "p95 skew {:.1} ms is inside the preferred {:.1} ms band{}", p95_abs_skew_ms, VERDICT_PREFERRED_P95_ABS_SKEW_MS, raw_activity_note ), ..base }; } if p95_abs_skew_ms <= VERDICT_ACCEPTABLE_P95_ABS_SKEW_MS { return SyncAnalysisVerdict { status: "acceptable".to_string(), passed: true, reason: format!( "p95 skew {:.1} ms is inside the acceptable {:.1} ms band{}", p95_abs_skew_ms, VERDICT_ACCEPTABLE_P95_ABS_SKEW_MS, raw_activity_note ), ..base }; } SyncAnalysisVerdict { status: "gross_failure".to_string(), reason: format!( "p95 skew {:.1} ms exceeds the {:.1} ms acceptable band", p95_abs_skew_ms, VERDICT_ACCEPTABLE_P95_ABS_SKEW_MS ), ..base } } #[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 start_median_disagreement_ms = self.activity_start_pair_disagreement_ms(); if self.raw_activity_start_is_verdict_relevant() && start_median_disagreement_ms.abs() > CALIBRATION_MAX_START_MEDIAN_DISAGREEMENT_MS { return SyncCalibrationRecommendation { ready: false, recommended_audio_offset_adjust_us: 0, recommended_video_offset_adjust_us: 0, note: format!( "activity start delta {:+.1} ms disagrees with median skew {:+.1} ms by {:.1} ms, above the {:.1} ms calibration-safe band", self.activity_start_delta_ms, self.median_skew_ms, start_median_disagreement_ms.abs(), CALIBRATION_MAX_START_MEDIAN_DISAGREEMENT_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 raw_activity_note = self.raw_activity_calibration_note(); 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, raw_activity_note ) } else { format!( "apply the audio offset adjustment to move median skew from {:+.1} ms toward 0.0 ms{}", self.median_skew_ms, raw_activity_note ) }; SyncCalibrationRecommendation { ready: true, recommended_audio_offset_adjust_us, recommended_video_offset_adjust_us, note, } } #[must_use] pub fn activity_start_pair_disagreement_ms(&self) -> f64 { self.activity_start_delta_ms - self.median_skew_ms } #[must_use] pub fn raw_activity_start_confirms_pairs(&self) -> bool { self.activity_start_pair_disagreement_ms().abs() <= RAW_ACTIVITY_CONFIRMS_PAIR_MAX_DISAGREEMENT_MS } pub fn raw_activity_start_is_verdict_relevant(&self) -> bool { if self.raw_activity_start_confirms_pairs() { return true; } if self.coded_events || self.paired_event_count < VERDICT_MIN_PAIRED_EVENTS { return false; } // Cadence-only pairing can alias by a whole pulse period and look // deceptively good. Use raw activity as a guard only when paired // evidence otherwise appears passable; if paired pulses already fail, // report that direct failure instead of promoting a noisy raw edge. percentile_abs(&self.skews_ms, 0.95) <= VERDICT_ACCEPTABLE_P95_ABS_SKEW_MS } fn raw_activity_note(&self) -> String { if self.activity_start_delta_ms.abs() < VERDICT_CATASTROPHIC_MAX_ABS_SKEW_MS || self.raw_activity_start_is_verdict_relevant() { String::new() } else { format!( "; raw activity start delta {:+.1} ms is reported separately because it disagrees with paired pulses by {:.1} ms", self.activity_start_delta_ms, self.activity_start_pair_disagreement_ms().abs() ) } } fn raw_activity_calibration_note(&self) -> String { if self.raw_activity_start_is_verdict_relevant() { String::new() } else { format!( "; raw activity start delta {:+.1} ms is not used for calibration because paired pulses disagree by {:.1} ms", self.activity_start_delta_ms, self.activity_start_pair_disagreement_ms().abs() ) } } } fn percentile_abs(values: &[f64], percentile: f64) -> f64 { if values.is_empty() { return 0.0; } let mut sorted = values.iter().copied().map(f64::abs).collect::>(); sorted.sort_by(|left, right| left.total_cmp(right)); let index = ((sorted.len() as f64 * percentile).ceil() as usize) .saturating_sub(1) .min(sorted.len() - 1); sorted[index] } #[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, pub event_width_codes: Vec, pub analysis_start_s: Option, pub analysis_end_s: Option, } 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, event_width_codes: Vec::new(), analysis_start_s: None, analysis_end_s: None, } } } #[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); assert!(options.event_width_codes.is_empty()); } #[test] fn calibration_recommendation_requires_enough_pairs() { let report = SyncAnalysisReport { video_event_count: 4, audio_event_count: 4, paired_event_count: 4, coded_events: false, activity_start_delta_ms: 0.0, raw_first_video_activity_s: 0.0, raw_first_audio_activity_s: 0.0, 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![], paired_events: 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 13 paired pulses") ); } #[test] fn calibration_recommendation_rejects_unstable_drift() { let report = SyncAnalysisReport { video_event_count: 12, audio_event_count: 12, paired_event_count: 14, coded_events: false, activity_start_delta_ms: 0.0, raw_first_video_activity_s: 0.0, raw_first_audio_activity_s: 0.0, 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![], paired_events: 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: 14, coded_events: false, activity_start_delta_ms: 0.0, raw_first_video_activity_s: 0.0, raw_first_audio_activity_s: 0.0, 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![], paired_events: 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_accepts_large_stable_measured_offsets() { let report = SyncAnalysisReport { video_event_count: 16, audio_event_count: 16, paired_event_count: 16, coded_events: false, activity_start_delta_ms: -766.4, raw_first_video_activity_s: 7.491, raw_first_audio_activity_s: 6.725, first_skew_ms: -766.4, last_skew_ms: -769.2, mean_skew_ms: -756.0, median_skew_ms: -766.4, max_abs_skew_ms: 775.7, drift_ms: -2.8, skews_ms: vec![-766.4; 16], video_onsets_s: vec![], audio_onsets_s: vec![], paired_events: vec![], }; let recommendation = report.calibration_recommendation(); assert!(recommendation.ready); assert_eq!(recommendation.recommended_audio_offset_adjust_us, 766_400); assert!(recommendation.note.contains("move median skew")); } #[test] fn calibration_recommendation_uses_pairs_when_raw_activity_disagrees() { let report = SyncAnalysisReport { video_event_count: 16, audio_event_count: 16, paired_event_count: 16, coded_events: false, activity_start_delta_ms: 6_735.0, raw_first_video_activity_s: 0.0, raw_first_audio_activity_s: 6.735, first_skew_ms: -90.0, last_skew_ms: -100.0, mean_skew_ms: -95.0, median_skew_ms: -99.0, max_abs_skew_ms: 120.0, drift_ms: -10.0, skews_ms: vec![-99.0; 16], video_onsets_s: vec![], audio_onsets_s: vec![], paired_events: vec![], }; let recommendation = report.calibration_recommendation(); assert!(recommendation.ready); assert_eq!(recommendation.recommended_audio_offset_adjust_us, 99_000); assert!(recommendation.note.contains("paired pulses disagree")); } #[test] fn calibration_recommendation_uses_coded_pairs_when_raw_activity_disagrees() { let report = SyncAnalysisReport { video_event_count: 14, audio_event_count: 14, paired_event_count: 13, coded_events: true, activity_start_delta_ms: -3_620.7, raw_first_video_activity_s: 9.361, raw_first_audio_activity_s: 5.740, first_skew_ms: -270.0, last_skew_ms: -230.0, mean_skew_ms: -205.0, median_skew_ms: -188.4, max_abs_skew_ms: 273.8, drift_ms: 40.0, skews_ms: vec![-270.0, -240.0, -188.4, -175.0, -130.0], video_onsets_s: vec![], audio_onsets_s: vec![], paired_events: vec![], }; let recommendation = report.calibration_recommendation(); assert!(recommendation.ready); assert_eq!(recommendation.recommended_audio_offset_adjust_us, 188_400); assert!(recommendation.note.contains("paired pulses disagree")); } #[test] fn calibration_recommendation_reports_when_skew_is_already_settled() { let report = SyncAnalysisReport { video_event_count: 14, audio_event_count: 14, paired_event_count: 14, coded_events: false, activity_start_delta_ms: 0.0, raw_first_video_activity_s: 0.0, raw_first_audio_activity_s: 0.0, 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![], paired_events: 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")); } #[test] fn verdict_passes_preferred_skew_band() { let report = SyncAnalysisReport { video_event_count: 5, audio_event_count: 5, paired_event_count: 5, coded_events: false, activity_start_delta_ms: 0.0, raw_first_video_activity_s: 0.0, raw_first_audio_activity_s: 0.0, first_skew_ms: 10.0, last_skew_ms: 20.0, mean_skew_ms: 15.0, median_skew_ms: 15.0, max_abs_skew_ms: 20.0, drift_ms: 10.0, skews_ms: vec![10.0, 12.0, 15.0, 18.0, 20.0], video_onsets_s: vec![], audio_onsets_s: vec![], paired_events: vec![], }; let verdict = report.verdict(); assert!(verdict.passed); assert_eq!(verdict.status, "preferred"); } #[test] fn verdict_flags_catastrophic_desync() { let report = SyncAnalysisReport { video_event_count: 5, audio_event_count: 5, paired_event_count: 5, coded_events: false, activity_start_delta_ms: 0.0, raw_first_video_activity_s: 0.0, raw_first_audio_activity_s: 0.0, first_skew_ms: 8_000.0, last_skew_ms: 8_000.0, mean_skew_ms: 8_000.0, median_skew_ms: 8_000.0, max_abs_skew_ms: 8_000.0, drift_ms: 0.0, skews_ms: vec![8_000.0; 5], video_onsets_s: vec![], audio_onsets_s: vec![], paired_events: vec![], }; let verdict = report.verdict(); assert!(!verdict.passed); assert_eq!(verdict.status, "catastrophic_failure"); } #[test] fn verdict_flags_catastrophic_activity_start_delta() { let report = SyncAnalysisReport { video_event_count: 20, audio_event_count: 20, paired_event_count: 20, coded_events: false, activity_start_delta_ms: 20_000.0, raw_first_video_activity_s: 0.0, raw_first_audio_activity_s: 0.0, first_skew_ms: 900.0, last_skew_ms: 900.0, mean_skew_ms: 19_900.0, median_skew_ms: 19_900.0, max_abs_skew_ms: 900.0, drift_ms: 0.0, skews_ms: vec![900.0; 20], video_onsets_s: vec![], audio_onsets_s: vec![], paired_events: vec![], }; let verdict = report.verdict(); assert!(!verdict.passed); assert_eq!(verdict.status, "catastrophic_failure"); assert!(verdict.reason.contains("activity starts")); } #[test] fn verdict_ignores_uncorroborated_raw_activity_for_coded_runs() { let report = SyncAnalysisReport { video_event_count: 20, audio_event_count: 20, paired_event_count: 20, coded_events: true, activity_start_delta_ms: -3_620.7, raw_first_video_activity_s: 9.361, raw_first_audio_activity_s: 5.740, first_skew_ms: -20.0, last_skew_ms: -18.0, mean_skew_ms: -19.0, median_skew_ms: -19.0, max_abs_skew_ms: 20.0, drift_ms: 2.0, skews_ms: vec![-20.0; 20], video_onsets_s: vec![], audio_onsets_s: vec![], paired_events: vec![], }; let verdict = report.verdict(); assert!(verdict.passed); assert_eq!(verdict.status, "preferred"); assert!(verdict.reason.contains("raw activity start delta")); } }