2026-04-24 14:49:57 -03:00
|
|
|
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;
|
2026-04-25 16:48:20 -03:00
|
|
|
const CALIBRATION_MIN_PAIRED_EVENTS: usize = 8;
|
|
|
|
|
const CALIBRATION_MAX_DRIFT_MS: f64 = 40.0;
|
|
|
|
|
const CALIBRATION_SETTLED_SKEW_MS: f64 = 5.0;
|
2026-04-30 22:23:29 -03:00
|
|
|
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;
|
2026-04-24 14:49:57 -03:00
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, PartialEq, Serialize)]
|
|
|
|
|
pub struct SyncAnalysisReport {
|
|
|
|
|
pub video_event_count: usize,
|
|
|
|
|
pub audio_event_count: usize,
|
|
|
|
|
pub paired_event_count: usize,
|
2026-05-01 01:26:24 -03:00
|
|
|
pub activity_start_delta_ms: f64,
|
2026-04-24 14:49:57 -03:00
|
|
|
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<f64>,
|
|
|
|
|
pub video_onsets_s: Vec<f64>,
|
|
|
|
|
pub audio_onsets_s: Vec<f64>,
|
2026-04-30 22:23:29 -03:00
|
|
|
pub paired_events: Vec<SyncEventPair>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, PartialEq, Serialize)]
|
|
|
|
|
pub struct SyncEventPair {
|
|
|
|
|
pub event_id: usize,
|
|
|
|
|
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,
|
2026-04-24 14:49:57 -03:00
|
|
|
}
|
|
|
|
|
|
2026-04-25 16:48:20 -03:00
|
|
|
#[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 {
|
2026-04-30 22:23:29 -03:00
|
|
|
#[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(),
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-01 01:26:24 -03:00
|
|
|
if self.activity_start_delta_ms.abs() >= VERDICT_CATASTROPHIC_MAX_ABS_SKEW_MS {
|
|
|
|
|
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
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-30 22:23:29 -03:00
|
|
|
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 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
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
),
|
|
|
|
|
..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
|
|
|
|
|
),
|
|
|
|
|
..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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 16:48:20 -03:00
|
|
|
#[must_use]
|
|
|
|
|
pub fn calibration_recommendation(&self) -> SyncCalibrationRecommendation {
|
2026-05-01 01:26:24 -03:00
|
|
|
if self.activity_start_delta_ms.abs() >= VERDICT_ACCEPTABLE_P95_ABS_SKEW_MS {
|
|
|
|
|
return SyncCalibrationRecommendation {
|
|
|
|
|
ready: false,
|
|
|
|
|
recommended_audio_offset_adjust_us: 0,
|
|
|
|
|
recommended_video_offset_adjust_us: 0,
|
|
|
|
|
note: format!(
|
|
|
|
|
"activity start delta {:+.1} ms exceeds the {:.1} ms calibration-safe band",
|
|
|
|
|
self.activity_start_delta_ms, VERDICT_ACCEPTABLE_P95_ABS_SKEW_MS
|
|
|
|
|
),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 16:48:20 -03:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-30 22:23:29 -03:00
|
|
|
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::<Vec<_>>();
|
|
|
|
|
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]
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 14:49:57 -03:00
|
|
|
#[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,
|
2026-05-01 02:05:07 -03:00
|
|
|
pub event_width_codes: Vec<u32>,
|
2026-04-24 14:49:57 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
2026-05-01 02:05:07 -03:00
|
|
|
event_width_codes: Vec::new(),
|
2026-04-24 14:49:57 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
2026-04-25 16:48:20 -03:00
|
|
|
use super::{SyncAnalysisOptions, SyncAnalysisReport};
|
2026-04-24 14:49:57 -03:00
|
|
|
|
|
|
|
|
#[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);
|
2026-05-01 02:05:07 -03:00
|
|
|
assert!(options.event_width_codes.is_empty());
|
2026-04-24 14:49:57 -03:00
|
|
|
}
|
2026-04-25 16:48:20 -03:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn calibration_recommendation_requires_enough_pairs() {
|
|
|
|
|
let report = SyncAnalysisReport {
|
|
|
|
|
video_event_count: 4,
|
|
|
|
|
audio_event_count: 4,
|
|
|
|
|
paired_event_count: 4,
|
2026-05-01 01:26:24 -03:00
|
|
|
activity_start_delta_ms: 0.0,
|
2026-04-25 16:48:20 -03:00
|
|
|
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![],
|
2026-04-30 22:23:29 -03:00
|
|
|
paired_events: vec![],
|
2026-04-25 16:48:20 -03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let recommendation = report.calibration_recommendation();
|
|
|
|
|
assert!(!recommendation.ready);
|
|
|
|
|
assert_eq!(recommendation.recommended_audio_offset_adjust_us, 0);
|
2026-04-25 22:25:24 -03:00
|
|
|
assert!(
|
|
|
|
|
recommendation
|
|
|
|
|
.note
|
|
|
|
|
.contains("need at least 8 paired pulses")
|
|
|
|
|
);
|
2026-04-25 16:48:20 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn calibration_recommendation_rejects_unstable_drift() {
|
|
|
|
|
let report = SyncAnalysisReport {
|
|
|
|
|
video_event_count: 12,
|
|
|
|
|
audio_event_count: 12,
|
|
|
|
|
paired_event_count: 12,
|
2026-05-01 01:26:24 -03:00
|
|
|
activity_start_delta_ms: 0.0,
|
2026-04-25 16:48:20 -03:00
|
|
|
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![],
|
2026-04-30 22:23:29 -03:00
|
|
|
paired_events: vec![],
|
2026-04-25 16:48:20 -03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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,
|
2026-05-01 01:26:24 -03:00
|
|
|
activity_start_delta_ms: 0.0,
|
2026-04-25 16:48:20 -03:00
|
|
|
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![],
|
2026-04-30 22:23:29 -03:00
|
|
|
paired_events: vec![],
|
2026-04-25 16:48:20 -03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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,
|
2026-05-01 01:26:24 -03:00
|
|
|
activity_start_delta_ms: 0.0,
|
2026-04-25 16:48:20 -03:00
|
|
|
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![],
|
2026-04-30 22:23:29 -03:00
|
|
|
paired_events: vec![],
|
2026-04-25 16:48:20 -03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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"));
|
|
|
|
|
}
|
2026-04-30 22:23:29 -03:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn verdict_passes_preferred_skew_band() {
|
|
|
|
|
let report = SyncAnalysisReport {
|
|
|
|
|
video_event_count: 5,
|
|
|
|
|
audio_event_count: 5,
|
|
|
|
|
paired_event_count: 5,
|
2026-05-01 01:26:24 -03:00
|
|
|
activity_start_delta_ms: 0.0,
|
2026-04-30 22:23:29 -03:00
|
|
|
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,
|
2026-05-01 01:26:24 -03:00
|
|
|
activity_start_delta_ms: 0.0,
|
2026-04-30 22:23:29 -03:00
|
|
|
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");
|
|
|
|
|
}
|
2026-05-01 01:26:24 -03:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn verdict_flags_catastrophic_activity_start_delta() {
|
|
|
|
|
let report = SyncAnalysisReport {
|
|
|
|
|
video_event_count: 20,
|
|
|
|
|
audio_event_count: 20,
|
|
|
|
|
paired_event_count: 20,
|
|
|
|
|
activity_start_delta_ms: 20_000.0,
|
|
|
|
|
first_skew_ms: 0.0,
|
|
|
|
|
last_skew_ms: 0.0,
|
|
|
|
|
mean_skew_ms: 0.0,
|
|
|
|
|
median_skew_ms: 0.0,
|
|
|
|
|
max_abs_skew_ms: 0.0,
|
|
|
|
|
drift_ms: 0.0,
|
|
|
|
|
skews_ms: vec![0.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"));
|
|
|
|
|
}
|
2026-04-24 14:49:57 -03:00
|
|
|
}
|