247 lines
8.6 KiB
Rust
Raw Normal View History

use super::correlation::{
candidate_index_offsets, collapse_segments_by_phase, correlate_onsets, estimate_phase,
index_onsets_by_spacing, marker_index_offsets, marker_onsets, shortest_wrapped_difference,
};
use super::{
PulseSegment, correlate_segments, detect_audio_onsets, detect_audio_segments,
detect_video_onsets, detect_video_segments, median,
};
use crate::sync_probe::analyze::report::SyncAnalysisReport;
use std::collections::BTreeMap;
#[test]
fn detect_video_onsets_finds_bright_transitions() {
let timestamps = (0..60).map(|idx| idx as f64 / 10.0).collect::<Vec<_>>();
let brightness = timestamps
.iter()
.enumerate()
.map(|(idx, _)| {
if idx == 0 || idx == 10 || idx == 20 {
250
} else {
5
}
})
.collect::<Vec<_>>();
let onsets = detect_video_onsets(&timestamps, &brightness).expect("video onsets");
assert_eq!(onsets, vec![0.0, 0.95, 1.95]);
}
#[test]
fn detect_audio_onsets_finds_click_bursts() {
let mut samples = vec![0i16; 48_000];
for start in [0usize, 48_000 / 2] {
for sample in samples.iter_mut().skip(start).take(300) {
*sample = 18_000;
}
}
let onsets = detect_audio_onsets(&samples, 48_000, 5).expect("audio onsets");
assert_eq!(onsets.len(), 2);
assert!((onsets[0] - 0.0).abs() < 0.01);
assert!((onsets[1] - 0.5).abs() < 0.02);
}
#[test]
fn detect_video_segments_keeps_regular_and_marker_durations_distinct() {
let timestamps = (0..30).map(|idx| idx as f64 / 30.0).collect::<Vec<_>>();
let brightness = [
0, 255, 255, 255, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
];
let segments = detect_video_segments(&timestamps, &brightness).expect("video segments");
assert_eq!(segments.len(), 2);
assert!(segments[1].duration_s > segments[0].duration_s);
}
#[test]
fn detect_audio_segments_keeps_regular_and_marker_durations_distinct() {
let mut samples = vec![0i16; 48_000];
for sample in samples.iter_mut().take(3_000) {
*sample = 18_000;
}
for sample in samples.iter_mut().skip(24_000).take(6_000) {
*sample = 18_000;
}
let segments = detect_audio_segments(&samples, 48_000, 5).expect("audio segments");
assert_eq!(segments.len(), 2);
assert!(segments[1].duration_s > segments[0].duration_s);
}
#[test]
fn detect_video_segments_closes_a_pulse_that_stays_active_until_the_last_frame() {
let timestamps = [0.0, 0.1, 0.2, 0.3];
let brightness = [0, 0, 255, 255];
let segments = detect_video_segments(&timestamps, &brightness).expect("trailing video segment");
assert_eq!(segments.len(), 1);
assert!(segments[0].end_s > segments[0].start_s);
assert!(segments[0].end_s >= 0.3);
}
#[test]
fn detect_audio_segments_closes_a_click_that_stays_active_until_the_capture_ends() {
let mut samples = vec![0i16; 4_800];
let midpoint = samples.len() / 2;
for sample in samples.iter_mut().skip(midpoint) {
*sample = 18_000;
}
let segments = detect_audio_segments(&samples, 48_000, 5).expect("trailing audio segment");
assert_eq!(segments.len(), 1);
assert!(segments[0].end_s > segments[0].start_s);
}
#[test]
fn correlate_onsets_reports_skew_and_drift() {
let report = correlate_onsets(&[0.0, 1.0, 2.0, 3.0], &[0.05, 1.04, 2.03, 3.02], 1.0, 0.2)
.expect("correlated report");
assert_sync_report_shape(&report, 4);
assert!((report.first_skew_ms - 50.0).abs() < 0.001);
assert!((report.last_skew_ms - 20.0).abs() < 0.001);
assert!((report.drift_ms + 30.0).abs() < 0.001);
assert!(report.max_abs_skew_ms >= 50.0);
}
#[test]
fn correlate_onsets_single_pulse_uses_phase_fallback() {
let report = correlate_onsets(&[0.95], &[0.05], 1.0, 0.2).expect("single-pulse fallback");
assert_eq!(report.paired_event_count, 1);
assert!((report.first_skew_ms - 100.0).abs() < 0.001);
}
#[test]
fn correlate_onsets_ignores_leading_video_cadence_before_audio_becomes_active() {
let report = correlate_onsets(
&[0.15, 1.15, 2.15, 3.15, 10.15, 11.15, 12.15],
&[10.20, 11.20, 12.20],
1.0,
0.2,
)
.expect("correlated report");
assert_eq!(report.video_event_count, 3);
assert_eq!(report.audio_event_count, 3);
assert_eq!(report.paired_event_count, 3);
assert!((report.median_skew_ms - 50.0).abs() < 0.001);
assert!(report.max_abs_skew_ms < 60.0);
}
#[test]
fn correlate_onsets_prefers_the_phase_consistent_basin_over_a_larger_alias_cluster() {
let report = correlate_onsets(
&[
9.554, 10.5035, 11.487, 12.4365, 13.691, 14.505, 15.624, 16.438, 17.3535, 18.4385,
19.5575, 20.4395, 21.4905, 22.508, 23.4235, 24.5425, 25.5605, 26.4425, 27.4935,
28.5105,
],
&[
10.011041666666667,
11.011041666666667,
12.011041666666667,
13.011041666666667,
14.011041666666667,
15.011041666666667,
16.011041666666667,
17.011041666666667,
18.011041666666667,
19.011041666666667,
20.011041666666667,
21.011041666666667,
22.011041666666667,
23.011041666666667,
24.011041666666667,
25.011041666666667,
26.011041666666667,
27.011041666666667,
28.011041666666667,
29.011041666666667,
],
1.0,
0.5,
)
.expect("phase-consistent basin");
assert!(report.first_skew_ms > 400.0);
assert!(report.median_skew_ms > 350.0);
assert!(report.paired_event_count >= 6);
}
#[test]
fn detect_video_onsets_rejects_empty_low_contrast_and_missing_edges() {
assert!(detect_video_onsets(&[], &[]).is_err());
assert!(detect_video_onsets(&[0.0, 0.1], &[10, 12]).is_err());
assert!(detect_video_onsets(&[0.0, 0.1, 0.2], &[255, 255, 255]).is_err());
}
2026-04-27 13:35:18 -03:00
#[test]
fn detect_video_onsets_rejects_frame_to_frame_flicker() {
let timestamps = (0..120)
.map(|index| index as f64 / 30.0)
.collect::<Vec<_>>();
2026-04-27 13:35:18 -03:00
let brightness = (0..120)
.map(|index| if index % 2 == 0 { 0 } else { 6 })
.collect::<Vec<_>>();
let err =
detect_video_onsets(&timestamps, &brightness).expect_err("flicker should be rejected");
2026-04-27 13:35:18 -03:00
assert!(
err.to_string().contains("frame-to-frame flicker"),
2026-04-27 13:35:18 -03:00
"unexpected error: {err}"
);
}
#[test]
fn detect_audio_onsets_rejects_empty_invalid_and_too_quiet_inputs() {
assert!(detect_audio_onsets(&[], 48_000, 5).is_err());
assert!(detect_audio_onsets(&[1, 2, 3], 0, 5).is_err());
assert!(detect_audio_onsets(&[1, 2, 3], 48_000, 0).is_err());
assert!(detect_audio_onsets(&vec![1i16; 4_800], 48_000, 5).is_err());
}
#[test]
fn correlate_onsets_rejects_empty_inputs_invalid_gap_and_unpairable_events() {
assert!(correlate_onsets(&[], &[0.0], 1.0, 0.2).is_err());
assert!(correlate_onsets(&[0.0], &[], 1.0, 0.2).is_err());
assert!(correlate_onsets(&[0.0], &[0.0], 1.0, 0.0).is_err());
assert!(correlate_onsets(&[0.0, 1.0], &[2.0, 3.0], 1.0, 0.1).is_err());
assert!(correlate_onsets(&[0.0], &[0.0], 0.0, 0.1).is_err());
}
#[test]
fn correlate_segments_validate_inputs_and_support_single_pulse_fallback() {
let video = [PulseSegment {
start_s: 0.95,
end_s: 1.05,
duration_s: 0.1,
}];
let audio = [PulseSegment {
start_s: 0.05,
end_s: 0.15,
duration_s: 0.1,
}];
let report =
correlate_segments(&video, &audio, 1.0, 0.1, 3, 0.2).expect("single segment fallback");
assert_eq!(report.paired_event_count, 1);
assert!((report.first_skew_ms - 100.0).abs() < 0.001);
assert!(correlate_segments(&[], &audio, 1.0, 0.1, 3, 0.2).is_err());
assert!(correlate_segments(&video, &[], 1.0, 0.1, 3, 0.2).is_err());
assert!(correlate_segments(&video, &audio, 0.0, 0.1, 3, 0.2).is_err());
assert!(correlate_segments(&video, &audio, 1.0, 0.0, 3, 0.2).is_err());
assert!(correlate_segments(&video, &audio, 1.0, 0.1, 0, 0.2).is_err());
assert!(correlate_segments(&video, &audio, 1.0, 0.1, 3, 0.0).is_err());
assert!(correlate_segments(&video, &audio, 1.0, 0.1, 3, 0.05).is_err());
}
fn assert_sync_report_shape(report: &SyncAnalysisReport, paired_events: usize) {
assert_eq!(report.video_event_count, paired_events);
assert_eq!(report.audio_event_count, paired_events);
assert_eq!(report.paired_event_count, paired_events);
assert_eq!(report.skews_ms.len(), paired_events);
assert_eq!(report.video_onsets_s.len(), paired_events);
assert_eq!(report.audio_onsets_s.len(), paired_events);
}
mod correlation_helpers;