2026-04-24 14:49:57 -03:00
|
|
|
use super::correlation::{
|
2026-04-25 05:35:16 -03:00
|
|
|
candidate_index_offsets, collapse_segments_by_phase, correlate_onsets, estimate_phase,
|
|
|
|
|
index_onsets_by_spacing, marker_index_offsets, marker_onsets, shortest_wrapped_difference,
|
2026-04-24 14:49:57 -03:00
|
|
|
};
|
|
|
|
|
use super::{
|
2026-04-27 04:49:44 -03:00
|
|
|
correlate_segments, detect_audio_onsets, detect_audio_segments, detect_video_onsets,
|
|
|
|
|
detect_video_segments, median, PulseSegment,
|
2026-04-24 14:49:57 -03:00
|
|
|
};
|
|
|
|
|
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(×tamps, &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(×tamps, &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(×tamps, &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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 04:49:44 -03:00
|
|
|
#[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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 06:52:14 -03:00
|
|
|
#[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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 14:49:57 -03:00
|
|
|
#[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());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn phase_estimation_and_indexing_stay_stable_when_pulses_are_missing() {
|
|
|
|
|
let video_phase = estimate_phase(&[4.0, 5.0, 7.0, 8.0, 10.0], 1.0);
|
|
|
|
|
let audio_phase = estimate_phase(&[4.018, 5.017, 6.019, 8.018, 9.018], 1.0);
|
|
|
|
|
assert!((video_phase - 0.0).abs() < 0.02);
|
|
|
|
|
assert!((audio_phase - 0.018).abs() < 0.02);
|
|
|
|
|
|
|
|
|
|
let video_indexed = index_onsets_by_spacing(&[4.0, 5.0, 7.0, 8.0, 10.0], 1.0);
|
|
|
|
|
let audio_indexed = index_onsets_by_spacing(&[4.018, 5.017, 6.019, 8.018, 9.018], 1.0);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
video_indexed.keys().copied().collect::<Vec<_>>(),
|
|
|
|
|
vec![0, 1, 3, 4, 6]
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
audio_indexed.keys().copied().collect::<Vec<_>>(),
|
|
|
|
|
vec![0, 1, 2, 4, 5]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn correlation_helpers_cover_empty_index_sets_and_wrapped_phase_math() {
|
|
|
|
|
assert!(index_onsets_by_spacing(&[], 1.0).is_empty());
|
|
|
|
|
assert!(candidate_index_offsets(&BTreeMap::new(), &BTreeMap::new()).is_empty());
|
|
|
|
|
|
|
|
|
|
let mut video_only = BTreeMap::new();
|
|
|
|
|
video_only.insert(0, 1.0);
|
|
|
|
|
assert!(candidate_index_offsets(&video_only, &BTreeMap::new()).is_empty());
|
|
|
|
|
|
|
|
|
|
let mut audio_only = BTreeMap::new();
|
|
|
|
|
audio_only.insert(0, 1.0);
|
|
|
|
|
assert!(candidate_index_offsets(&BTreeMap::new(), &audio_only).is_empty());
|
|
|
|
|
|
|
|
|
|
let mut video_indexed = BTreeMap::new();
|
|
|
|
|
video_indexed.insert(2, 2.0);
|
|
|
|
|
let mut audio_indexed = BTreeMap::new();
|
|
|
|
|
audio_indexed.insert(5, 5.0);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
candidate_index_offsets(&video_indexed, &audio_indexed),
|
|
|
|
|
vec![3]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert!((shortest_wrapped_difference(0.6, 1.0) + 0.4).abs() < 0.000_001);
|
|
|
|
|
assert!((shortest_wrapped_difference(-0.6, 1.0) - 0.4).abs() < 0.000_001);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn marker_index_offsets_include_marker_alignment_and_general_fallback() {
|
|
|
|
|
let video_indexed = index_onsets_by_spacing(&[4.0, 5.0, 7.0, 8.0, 10.0], 1.0);
|
|
|
|
|
let audio_indexed = index_onsets_by_spacing(&[5.018, 6.017, 7.019, 9.018, 10.018], 1.0);
|
|
|
|
|
let offsets = marker_index_offsets(&video_indexed, &audio_indexed, &[10.0], &[10.018]);
|
|
|
|
|
assert!(offsets.contains(&1));
|
|
|
|
|
assert!(offsets.contains(&0));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn correlate_onsets_ignores_missing_pulses_and_preserves_stable_skew() {
|
|
|
|
|
let report = correlate_onsets(
|
|
|
|
|
&[4.0, 5.0, 7.0, 8.0, 10.0],
|
|
|
|
|
&[4.018, 5.017, 6.019, 8.018, 9.018],
|
|
|
|
|
1.0,
|
|
|
|
|
0.2,
|
|
|
|
|
)
|
|
|
|
|
.expect("correlated report");
|
|
|
|
|
|
|
|
|
|
assert_eq!(report.paired_event_count, 3);
|
|
|
|
|
assert!((report.mean_skew_ms - 17.666).abs() < 5.0);
|
|
|
|
|
assert!(report.max_abs_skew_ms < 30.0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn correlate_segments_uses_markers_to_break_period_aliasing() {
|
|
|
|
|
let video = vec![
|
|
|
|
|
PulseSegment {
|
|
|
|
|
start_s: 3.3,
|
|
|
|
|
end_s: 3.55,
|
|
|
|
|
duration_s: 0.25,
|
|
|
|
|
},
|
|
|
|
|
PulseSegment {
|
|
|
|
|
start_s: 4.266667,
|
|
|
|
|
end_s: 4.4,
|
|
|
|
|
duration_s: 0.133333,
|
|
|
|
|
},
|
|
|
|
|
PulseSegment {
|
|
|
|
|
start_s: 5.3,
|
|
|
|
|
end_s: 5.433333,
|
|
|
|
|
duration_s: 0.133333,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
let audio = vec![
|
|
|
|
|
PulseSegment {
|
|
|
|
|
start_s: 3.35,
|
|
|
|
|
end_s: 3.59,
|
|
|
|
|
duration_s: 0.24,
|
|
|
|
|
},
|
|
|
|
|
PulseSegment {
|
|
|
|
|
start_s: 4.316667,
|
|
|
|
|
end_s: 4.436667,
|
|
|
|
|
duration_s: 0.12,
|
|
|
|
|
},
|
|
|
|
|
PulseSegment {
|
|
|
|
|
start_s: 5.35,
|
|
|
|
|
end_s: 5.47,
|
|
|
|
|
duration_s: 0.12,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let report =
|
|
|
|
|
correlate_segments(&video, &audio, 1.0, 0.12, 5, 0.2).expect("marker-correlated report");
|
|
|
|
|
assert_eq!(report.paired_event_count, 3);
|
|
|
|
|
assert!((report.mean_skew_ms - 50.0).abs() < 10.0);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 05:35:16 -03:00
|
|
|
#[test]
|
|
|
|
|
fn collapse_segments_by_phase_keeps_one_best_segment_per_pulse_slot() {
|
|
|
|
|
let collapsed = collapse_segments_by_phase(
|
|
|
|
|
&[
|
|
|
|
|
PulseSegment {
|
|
|
|
|
start_s: 4.00,
|
|
|
|
|
end_s: 4.12,
|
|
|
|
|
duration_s: 0.12,
|
|
|
|
|
},
|
|
|
|
|
PulseSegment {
|
|
|
|
|
start_s: 4.08,
|
|
|
|
|
end_s: 4.10,
|
|
|
|
|
duration_s: 0.02,
|
|
|
|
|
},
|
|
|
|
|
PulseSegment {
|
|
|
|
|
start_s: 5.01,
|
|
|
|
|
end_s: 5.13,
|
|
|
|
|
duration_s: 0.12,
|
|
|
|
|
},
|
|
|
|
|
PulseSegment {
|
|
|
|
|
start_s: 5.09,
|
|
|
|
|
end_s: 5.11,
|
|
|
|
|
duration_s: 0.02,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
1.0,
|
|
|
|
|
0.32,
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(collapsed.len(), 2);
|
|
|
|
|
assert!((collapsed[0].start_s - 4.0).abs() < 0.001);
|
|
|
|
|
assert!((collapsed[1].start_s - 5.01).abs() < 0.001);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn collapse_segments_by_phase_prefers_the_longest_regular_cadence() {
|
|
|
|
|
let collapsed = collapse_segments_by_phase(
|
|
|
|
|
&[
|
|
|
|
|
PulseSegment {
|
|
|
|
|
start_s: 4.00,
|
|
|
|
|
end_s: 4.12,
|
|
|
|
|
duration_s: 0.12,
|
|
|
|
|
},
|
|
|
|
|
PulseSegment {
|
|
|
|
|
start_s: 5.02,
|
|
|
|
|
end_s: 5.14,
|
|
|
|
|
duration_s: 0.12,
|
|
|
|
|
},
|
|
|
|
|
PulseSegment {
|
|
|
|
|
start_s: 6.00,
|
|
|
|
|
end_s: 6.12,
|
|
|
|
|
duration_s: 0.12,
|
|
|
|
|
},
|
|
|
|
|
PulseSegment {
|
|
|
|
|
start_s: 7.01,
|
|
|
|
|
end_s: 7.13,
|
|
|
|
|
duration_s: 0.12,
|
|
|
|
|
},
|
|
|
|
|
PulseSegment {
|
|
|
|
|
start_s: 4.42,
|
|
|
|
|
end_s: 4.67,
|
|
|
|
|
duration_s: 0.25,
|
|
|
|
|
},
|
|
|
|
|
PulseSegment {
|
|
|
|
|
start_s: 6.42,
|
|
|
|
|
end_s: 6.67,
|
|
|
|
|
duration_s: 0.25,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
1.0,
|
|
|
|
|
0.32,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert_eq!(collapsed.len(), 4);
|
|
|
|
|
assert!((collapsed[0].start_s - 4.0).abs() < 0.001);
|
|
|
|
|
assert!((collapsed[3].start_s - 7.01).abs() < 0.001);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn correlate_segments_collapses_repeated_noise_within_each_pulse_slot() {
|
|
|
|
|
let video = vec![
|
|
|
|
|
PulseSegment {
|
|
|
|
|
start_s: 4.0,
|
|
|
|
|
end_s: 4.12,
|
|
|
|
|
duration_s: 0.12,
|
|
|
|
|
},
|
|
|
|
|
PulseSegment {
|
|
|
|
|
start_s: 4.03,
|
|
|
|
|
end_s: 4.05,
|
|
|
|
|
duration_s: 0.02,
|
|
|
|
|
},
|
|
|
|
|
PulseSegment {
|
|
|
|
|
start_s: 5.0,
|
|
|
|
|
end_s: 5.12,
|
|
|
|
|
duration_s: 0.12,
|
|
|
|
|
},
|
|
|
|
|
PulseSegment {
|
|
|
|
|
start_s: 5.03,
|
|
|
|
|
end_s: 5.05,
|
|
|
|
|
duration_s: 0.02,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
let audio = vec![
|
|
|
|
|
PulseSegment {
|
|
|
|
|
start_s: 4.02,
|
|
|
|
|
end_s: 4.14,
|
|
|
|
|
duration_s: 0.12,
|
|
|
|
|
},
|
|
|
|
|
PulseSegment {
|
|
|
|
|
start_s: 4.05,
|
|
|
|
|
end_s: 4.07,
|
|
|
|
|
duration_s: 0.02,
|
|
|
|
|
},
|
|
|
|
|
PulseSegment {
|
|
|
|
|
start_s: 5.02,
|
|
|
|
|
end_s: 5.14,
|
|
|
|
|
duration_s: 0.12,
|
|
|
|
|
},
|
|
|
|
|
PulseSegment {
|
|
|
|
|
start_s: 5.05,
|
|
|
|
|
end_s: 5.07,
|
|
|
|
|
duration_s: 0.02,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let report = correlate_segments(&video, &audio, 1.0, 0.12, 5, 0.2).expect("collapsed");
|
|
|
|
|
assert_eq!(report.video_event_count, 2);
|
|
|
|
|
assert_eq!(report.audio_event_count, 2);
|
|
|
|
|
assert_eq!(report.paired_event_count, 2);
|
|
|
|
|
assert!(report.max_abs_skew_ms < 30.0);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 14:49:57 -03:00
|
|
|
#[test]
|
|
|
|
|
fn marker_detection_finds_wider_segments_only() {
|
|
|
|
|
let markers = marker_onsets(
|
|
|
|
|
&[
|
|
|
|
|
PulseSegment {
|
|
|
|
|
start_s: 1.0,
|
|
|
|
|
end_s: 1.12,
|
|
|
|
|
duration_s: 0.12,
|
|
|
|
|
},
|
|
|
|
|
PulseSegment {
|
|
|
|
|
start_s: 5.0,
|
|
|
|
|
end_s: 5.24,
|
|
|
|
|
duration_s: 0.24,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
0.12,
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(markers, vec![5.0]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn median_handles_empty_even_and_odd_inputs() {
|
|
|
|
|
assert_eq!(median(Vec::new()), 0.0);
|
|
|
|
|
assert_eq!(median(vec![1.0, 3.0, 2.0]), 2.0);
|
|
|
|
|
assert_eq!(median(vec![4.0, 1.0, 3.0, 2.0]), 2.5);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|