diff --git a/Cargo.lock b/Cargo.lock index fae6b35..7f96482 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1642,7 +1642,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.13.14" +version = "0.13.15" dependencies = [ "anyhow", "async-stream", @@ -1676,7 +1676,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.13.14" +version = "0.13.15" dependencies = [ "anyhow", "base64", @@ -1688,7 +1688,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.13.14" +version = "0.13.15" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index ec7b53f..9a7cfdf 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.13.14" +version = "0.13.15" edition = "2024" [dependencies] diff --git a/client/src/app/downlink_media.rs b/client/src/app/downlink_media.rs index f889525..3331ab0 100644 --- a/client/src/app/downlink_media.rs +++ b/client/src/app/downlink_media.rs @@ -10,6 +10,7 @@ impl LesavkaClientApp { let ep = ep.clone(); let tx = tx.clone(); tokio::spawn(async move { + let mut dropped_packets = 0_u64; loop { let mut cli = RelayClient::new(ep.clone()); let req = MonitorRequest { @@ -30,9 +31,23 @@ impl LesavkaClientApp { "🎥📥 cli video{monitor_id}: got {} bytes", pkt.data.len() ); - if tx.send(pkt).await.is_err() { - warn!("⚠️🎥 cli video{monitor_id}: GUI thread gone"); - break; + match tx.try_send(pkt) { + Ok(()) => {} + Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => { + warn!("⚠️🎥 cli video{monitor_id}: GUI thread gone"); + break; + } + Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { + dropped_packets = dropped_packets.saturating_add(1); + if dropped_packets <= 8 + || dropped_packets.is_multiple_of(300) + { + debug!( + dropped_packets, + "🎥🪂 cli video{monitor_id}: dropping stale packet because the renderer queue is full" + ); + } + } } } Err(e) => { diff --git a/client/src/app/session_lifecycle.rs b/client/src/app/session_lifecycle.rs index 26235a6..df00ea5 100644 --- a/client/src/app/session_lifecycle.rs +++ b/client/src/app/session_lifecycle.rs @@ -164,7 +164,7 @@ impl LesavkaClientApp { let _ = el.run(move |_: Event<()>, elwt| { elwt.set_control_flow(ControlFlow::WaitUntil( - std::time::Instant::now() + std::time::Duration::from_millis(16), + std::time::Instant::now() + std::time::Duration::from_millis(8), )); static CNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); diff --git a/client/src/app_support.rs b/client/src/app_support.rs index e9da9d5..7d27f9f 100644 --- a/client/src/app_support.rs +++ b/client/src/app_support.rs @@ -32,9 +32,9 @@ pub fn camera_config_from_caps(caps: &PeerCaps) -> Option { } #[must_use] -/// Clamp queue depth to a floor that keeps renderer bursts stable. +/// Clamp queue depth so stale video cannot backlog behind live input. pub fn sanitize_video_queue(queue: Option) -> usize { - queue.unwrap_or(256).max(16) + queue.unwrap_or(8).max(4) } #[must_use] @@ -118,9 +118,9 @@ mod tests { #[test] fn sanitize_video_queue_enforces_floor() { - assert_eq!(sanitize_video_queue(None), 256); - assert_eq!(sanitize_video_queue(Some(8)), 16); - assert_eq!(sanitize_video_queue(Some(512)), 512); + assert_eq!(sanitize_video_queue(None), 8); + assert_eq!(sanitize_video_queue(Some(1)), 4); + assert_eq!(sanitize_video_queue(Some(32)), 32); } #[test] diff --git a/client/src/output/video/monitor_window.rs b/client/src/output/video/monitor_window.rs index f36a24c..ed19755 100644 --- a/client/src/output/video/monitor_window.rs +++ b/client/src/output/video/monitor_window.rs @@ -186,7 +186,7 @@ impl MonitorWindow { let desc = format!( "appsrc name=src is-live=true format=time do-timestamp=true block=false ! \ - queue max-size-buffers=8 max-size-time=0 max-size-bytes=0 leaky=downstream ! \ + queue max-size-buffers=2 max-size-time=0 max-size-bytes=0 leaky=downstream ! \ capsfilter caps=video/x-h264,stream-format=byte-stream,alignment=au ! \ h264parse disable-passthrough=true ! {decoder_name} name=decoder ! videoconvert ! {sink}" ); diff --git a/client/src/output/video/unified_monitor.rs b/client/src/output/video/unified_monitor.rs index 06da6f9..293839c 100644 --- a/client/src/output/video/unified_monitor.rs +++ b/client/src/output/video/unified_monitor.rs @@ -76,11 +76,11 @@ impl UnifiedMonitorWindow { let desc = format!( "compositor name=mix background=black ! videoconvert ! {sink} \ appsrc name=src0 is-live=true format=time do-timestamp=true block=false ! \ - queue max-size-buffers=8 max-size-time=0 max-size-bytes=0 leaky=downstream ! \ + queue max-size-buffers=2 max-size-time=0 max-size-bytes=0 leaky=downstream ! \ capsfilter caps=video/x-h264,stream-format=byte-stream,alignment=au ! \ h264parse disable-passthrough=true ! {decoder_name} name=decoder0 ! videoconvert ! videoscale ! mix. \ appsrc name=src1 is-live=true format=time do-timestamp=true block=false ! \ - queue max-size-buffers=8 max-size-time=0 max-size-bytes=0 leaky=downstream ! \ + queue max-size-buffers=2 max-size-time=0 max-size-bytes=0 leaky=downstream ! \ capsfilter caps=video/x-h264,stream-format=byte-stream,alignment=au ! \ h264parse disable-passthrough=true ! {decoder_name} name=decoder1 ! videoconvert ! videoscale ! mix." ); diff --git a/client/src/sync_probe/analyze/media_extract.rs b/client/src/sync_probe/analyze/media_extract.rs index 6cf7f7e..b10d3bf 100644 --- a/client/src/sync_probe/analyze/media_extract.rs +++ b/client/src/sync_probe/analyze/media_extract.rs @@ -144,15 +144,25 @@ pub(super) fn run_command(command: &mut Command, description: &str) -> Result u8 { let crop_start = VIDEO_ANALYSIS_SIDE_PX / 4; let crop_end = VIDEO_ANALYSIS_SIDE_PX - crop_start; - let mut total = 0u64; - let mut pixels = 0u64; - for y in crop_start..crop_end { - for x in crop_start..crop_end { - total += u64::from(frame[y * VIDEO_ANALYSIS_SIDE_PX + x]); - pixels += 1; + let mut center_total = 0u64; + let mut center_pixels = 0u64; + let mut border_total = 0u64; + let mut border_pixels = 0u64; + for y in 0..VIDEO_ANALYSIS_SIDE_PX { + for x in 0..VIDEO_ANALYSIS_SIDE_PX { + let value = u64::from(frame[y * VIDEO_ANALYSIS_SIDE_PX + x]); + if (crop_start..crop_end).contains(&x) && (crop_start..crop_end).contains(&y) { + center_total += value; + center_pixels += 1; + } else { + border_total += value; + border_pixels += 1; + } } } - ((total / pixels.max(1)).min(u64::from(u8::MAX))) as u8 + let center_mean = center_total / center_pixels.max(1); + let border_mean = border_total / border_pixels.max(1); + center_mean.abs_diff(border_mean).min(u64::from(u8::MAX)) as u8 } #[cfg(test)] @@ -211,7 +221,7 @@ mod tests { &[1, 0], |capture_path| { let parsed = extract_video_brightness(capture_path, 1).expect("video brightness"); - assert_eq!(parsed, vec![brightness[0]]); + assert_eq!(parsed, vec![15]); }, ); } @@ -243,7 +253,7 @@ mod tests { &[1, 0], |capture_path| { let parsed = extract_video_brightness(capture_path, 3).expect("video brightness"); - assert_eq!(parsed, brightness); + assert_eq!(parsed, vec![0, 25, 0]); }, ); } diff --git a/client/src/sync_probe/analyze/onset_detection.rs b/client/src/sync_probe/analyze/onset_detection.rs index c073c9f..0ba0894 100644 --- a/client/src/sync_probe/analyze/onset_detection.rs +++ b/client/src/sync_probe/analyze/onset_detection.rs @@ -10,7 +10,7 @@ pub(super) const DEFAULT_AUDIO_SAMPLE_RATE_HZ: u32 = 48_000; // Real HDMI/capture paths can preserve pulse shape while compressing its absolute // luma swing into a narrower band, so keep this guard modest and let the // segment logic reject genuinely flat/noisy traces. -const MIN_VIDEO_CONTRAST: u8 = 8; +const MIN_VIDEO_CONTRAST: u8 = 4; #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct PulseSegment { diff --git a/client/src/sync_probe/analyze/onset_detection/correlation.rs b/client/src/sync_probe/analyze/onset_detection/correlation.rs index b602ac9..7223c45 100644 --- a/client/src/sync_probe/analyze/onset_detection/correlation.rs +++ b/client/src/sync_probe/analyze/onset_detection/correlation.rs @@ -5,7 +5,12 @@ use crate::sync_probe::analyze::report::SyncAnalysisReport; use super::{PulseSegment, median}; +#[path = "correlation_collapse.rs"] +mod collapse; +pub(super) use collapse::collapse_segments_by_phase; + const MARKER_WIDTH_MULTIPLIER: f64 = 1.5; +const PHASE_TOLERANCE_WIDTH_MULTIPLIER: f64 = 2.5; #[cfg_attr(not(test), allow(dead_code))] pub(super) fn correlate_onsets( @@ -66,18 +71,10 @@ pub(crate) fn correlate_segments( marker_tick_period: u32, max_pair_gap_s: f64, ) -> Result { - let video_onsets_s = video_segments - .iter() - .map(|segment| segment.start_s) - .collect::>(); - let audio_onsets_s = audio_segments - .iter() - .map(|segment| segment.start_s) - .collect::>(); - if video_onsets_s.is_empty() { + if video_segments.is_empty() { bail!("video onset list is empty"); } - if audio_onsets_s.is_empty() { + if audio_segments.is_empty() { bail!("audio onset list is empty"); } if pulse_period_s <= 0.0 { @@ -93,8 +90,29 @@ pub(crate) fn correlate_segments( bail!("max pair gap must stay positive"); } - let video_marker_onsets = marker_onsets(video_segments, pulse_width_s); - let audio_marker_onsets = marker_onsets(audio_segments, pulse_width_s); + let phase_tolerance_s = segment_phase_tolerance(pulse_period_s, pulse_width_s, max_pair_gap_s); + let video_segments = + collapse_segments_by_phase(video_segments, pulse_period_s, phase_tolerance_s); + let audio_segments = + collapse_segments_by_phase(audio_segments, pulse_period_s, phase_tolerance_s); + + let video_onsets_s = video_segments + .iter() + .map(|segment| segment.start_s) + .collect::>(); + let audio_onsets_s = audio_segments + .iter() + .map(|segment| segment.start_s) + .collect::>(); + if video_onsets_s.is_empty() { + bail!("video onset list is empty"); + } + if audio_onsets_s.is_empty() { + bail!("audio onset list is empty"); + } + + let video_marker_onsets = marker_onsets(&video_segments, pulse_width_s); + let audio_marker_onsets = marker_onsets(&audio_segments, pulse_width_s); let video_indexed = index_onsets_by_spacing(&video_onsets_s, pulse_period_s); let audio_indexed = index_onsets_by_spacing(&audio_onsets_s, pulse_period_s); let offset_candidates = marker_index_offsets( @@ -131,6 +149,12 @@ pub(crate) fn correlate_segments( )) } +fn segment_phase_tolerance(pulse_period_s: f64, pulse_width_s: f64, max_pair_gap_s: f64) -> f64 { + (pulse_width_s * PHASE_TOLERANCE_WIDTH_MULTIPLIER) + .max(max_pair_gap_s.min(pulse_period_s / 3.0)) + .min(pulse_period_s / 2.5) +} + pub(super) fn estimate_phase(onsets_s: &[f64], pulse_period_s: f64) -> f64 { let (sum_sin, sum_cos) = onsets_s diff --git a/client/src/sync_probe/analyze/onset_detection/correlation_collapse.rs b/client/src/sync_probe/analyze/onset_detection/correlation_collapse.rs new file mode 100644 index 0000000..39012f7 --- /dev/null +++ b/client/src/sync_probe/analyze/onset_detection/correlation_collapse.rs @@ -0,0 +1,311 @@ +use std::collections::BTreeMap; + +use crate::sync_probe::analyze::onset_detection::PulseSegment; + +use super::shortest_wrapped_difference; + +pub(crate) fn collapse_segments_by_phase( + segments: &[PulseSegment], + pulse_period_s: f64, + phase_tolerance_s: f64, +) -> Vec { + if segments.len() <= 1 { + return segments.to_vec(); + } + + let mut best: Option<(usize, f64, f64, Vec)> = None; + for segment in segments { + let candidate_phase = segment.start_s.rem_euclid(pulse_period_s); + let collapsed = collapse_segments_for_reference_phase( + segments, + pulse_period_s, + phase_tolerance_s, + candidate_phase, + ); + if collapsed.is_empty() { + continue; + } + + let cadence = + retain_regular_cadence_segments(&collapsed, pulse_period_s, phase_tolerance_s); + let total_duration_s = cadence + .iter() + .map(|segment| segment.duration_s) + .sum::(); + let total_error_s = cadence + .iter() + .map(|segment| phase_error_for_reference(segment, candidate_phase, pulse_period_s)) + .sum::(); + + match &best { + Some((best_unique_slots, best_duration_s, best_error_s, _)) + if cadence.len() < *best_unique_slots + || (cadence.len() == *best_unique_slots + && total_duration_s < *best_duration_s) + || (cadence.len() == *best_unique_slots + && total_duration_s == *best_duration_s + && total_error_s >= *best_error_s) => {} + _ => best = Some((cadence.len(), total_duration_s, total_error_s, cadence)), + } + } + + best.map(|(_, _, _, cadence)| cadence).unwrap_or_else(|| { + retain_regular_cadence_segments(segments, pulse_period_s, phase_tolerance_s) + }) +} + +fn collapse_segments_for_reference_phase( + segments: &[PulseSegment], + pulse_period_s: f64, + phase_tolerance_s: f64, + reference_phase_s: f64, +) -> Vec { + let mut by_index = BTreeMap::::new(); + for segment in segments { + let pulse_index = ((segment.start_s - reference_phase_s) / pulse_period_s).round() as i64; + let expected_start_s = reference_phase_s + pulse_index as f64 * pulse_period_s; + let phase_error_s = + shortest_wrapped_difference(segment.start_s - expected_start_s, pulse_period_s).abs(); + if phase_error_s > phase_tolerance_s { + continue; + } + + match by_index.get_mut(&pulse_index) { + Some(best) if !prefer_segment(segment, best, expected_start_s) => {} + Some(best) => *best = *segment, + None => { + by_index.insert(pulse_index, *segment); + } + } + } + + by_index.into_values().collect() +} + +fn retain_regular_cadence_segments( + segments: &[PulseSegment], + pulse_period_s: f64, + phase_tolerance_s: f64, +) -> Vec { + if segments.len() <= 2 { + return segments.to_vec(); + } + + let mut best_count = vec![1usize; segments.len()]; + let mut best_duration_s = segments + .iter() + .map(|segment| segment.duration_s) + .collect::>(); + let mut best_error_s = vec![0.0_f64; segments.len()]; + let mut previous_index = vec![None::; segments.len()]; + + for current in 0..segments.len() { + for previous in 0..current { + let Some(cadence_error_s) = cadence_error( + segments[previous].start_s, + segments[current].start_s, + pulse_period_s, + phase_tolerance_s, + ) else { + continue; + }; + + let candidate_count = best_count[previous] + 1; + let candidate_duration_s = best_duration_s[previous] + segments[current].duration_s; + let candidate_error_s = best_error_s[previous] + cadence_error_s; + if candidate_count > best_count[current] + || (candidate_count == best_count[current] + && candidate_duration_s > best_duration_s[current]) + || (candidate_count == best_count[current] + && candidate_duration_s == best_duration_s[current] + && candidate_error_s < best_error_s[current]) + { + best_count[current] = candidate_count; + best_duration_s[current] = candidate_duration_s; + best_error_s[current] = candidate_error_s; + previous_index[current] = Some(previous); + } + } + } + + let mut best_terminal = 0usize; + for index in 1..segments.len() { + if best_count[index] > best_count[best_terminal] + || (best_count[index] == best_count[best_terminal] + && best_duration_s[index] > best_duration_s[best_terminal]) + || (best_count[index] == best_count[best_terminal] + && best_duration_s[index] == best_duration_s[best_terminal] + && best_error_s[index] < best_error_s[best_terminal]) + { + best_terminal = index; + } + } + + let mut selected = Vec::new(); + let mut cursor = Some(best_terminal); + while let Some(index) = cursor { + selected.push(segments[index]); + cursor = previous_index[index]; + } + selected.reverse(); + selected +} + +fn cadence_error( + previous_start_s: f64, + current_start_s: f64, + pulse_period_s: f64, + phase_tolerance_s: f64, +) -> Option { + let delta_s = current_start_s - previous_start_s; + if delta_s <= 0.0 { + return None; + } + + let pulse_steps = (delta_s / pulse_period_s).round(); + if pulse_steps < 1.0 { + return None; + } + + let error_s = (delta_s - pulse_steps * pulse_period_s).abs(); + (error_s <= phase_tolerance_s).then_some(error_s) +} + +fn phase_error_for_reference( + segment: &PulseSegment, + reference_phase_s: f64, + pulse_period_s: f64, +) -> f64 { + let pulse_index = ((segment.start_s - reference_phase_s) / pulse_period_s).round() as i64; + let expected_start_s = reference_phase_s + pulse_index as f64 * pulse_period_s; + shortest_wrapped_difference(segment.start_s - expected_start_s, pulse_period_s).abs() +} + +fn prefer_segment( + candidate: &PulseSegment, + incumbent: &PulseSegment, + expected_start_s: f64, +) -> bool { + if candidate.duration_s > incumbent.duration_s { + return true; + } + if candidate.duration_s < incumbent.duration_s { + return false; + } + + let candidate_error_s = (candidate.start_s - expected_start_s).abs(); + let incumbent_error_s = (incumbent.start_s - expected_start_s).abs(); + candidate_error_s < incumbent_error_s +} + +#[cfg(test)] +mod tests { + use super::*; + + fn seg(start_s: f64, duration_s: f64) -> PulseSegment { + PulseSegment { + start_s, + end_s: start_s + duration_s, + duration_s, + } + } + + #[test] + fn collapse_segments_by_phase_falls_back_when_phase_tolerance_excludes_everything() { + let segments = [seg(4.0, 0.12), seg(5.0, 0.12)]; + let collapsed = collapse_segments_by_phase(&segments, 1.0, -0.01); + assert_eq!(collapsed, segments); + } + + #[test] + fn collapse_segments_by_phase_replaces_shorter_slot_winner_with_longer_one() { + let collapsed = collapse_segments_by_phase( + &[seg(4.02, 0.04), seg(4.05, 0.12), seg(5.02, 0.12)], + 1.0, + 0.32, + ); + assert_eq!(collapsed.len(), 2); + assert!((collapsed[0].start_s - 4.05).abs() < 0.001); + } + + #[test] + fn collapse_segments_by_phase_keeps_longer_incumbent_and_breaks_equal_duration_ties_by_error() { + let keep_longer = collapse_segments_by_phase( + &[seg(4.02, 0.12), seg(4.05, 0.04), seg(5.02, 0.12)], + 1.0, + 0.32, + ); + assert_eq!(keep_longer.len(), 2); + assert!((keep_longer[0].start_s - 4.02).abs() < 0.001); + + let prefer_closer = collapse_segments_by_phase( + &[seg(4.08, 0.12), seg(4.02, 0.12), seg(5.02, 0.12)], + 1.0, + 0.32, + ); + assert_eq!(prefer_closer.len(), 2); + assert!((prefer_closer[0].start_s - 4.02).abs() < 0.001); + } + + #[test] + fn retain_regular_cadence_segments_prefers_longer_predecessor_chain_when_counts_tie() { + let selected = retain_regular_cadence_segments( + &[ + seg(0.00, 0.10), + seg(0.49, 0.05), + seg(0.51, 0.20), + seg(1.50, 0.10), + ], + 1.0, + 0.1, + ); + assert_eq!(selected.len(), 2); + assert!((selected[0].start_s - 0.51).abs() < 0.001); + assert!((selected[1].start_s - 1.50).abs() < 0.001); + } + + #[test] + fn retain_regular_cadence_segments_prefers_best_terminal_by_duration_then_error() { + let longer_terminal = retain_regular_cadence_segments( + &[ + seg(0.00, 0.10), + seg(1.00, 0.10), + seg(0.49, 0.05), + seg(1.50, 0.20), + ], + 1.0, + 0.1, + ); + assert_eq!(longer_terminal, vec![seg(0.49, 0.05), seg(1.50, 0.20)]); + + let lower_error_terminal = retain_regular_cadence_segments( + &[ + seg(0.00, 0.10), + seg(1.05, 0.10), + seg(0.49, 0.10), + seg(1.50, 0.10), + ], + 1.0, + 0.1, + ); + assert_eq!(lower_error_terminal, vec![seg(0.49, 0.10), seg(1.50, 0.10)]); + } + + #[test] + fn cadence_error_rejects_non_forward_and_sub_pulse_gaps() { + assert_eq!(cadence_error(1.0, 1.0, 1.0, 0.1), None); + assert_eq!(cadence_error(1.0, 0.9, 1.0, 0.1), None); + assert_eq!(cadence_error(1.0, 1.49, 1.0, 0.1), None); + let multi_step = cadence_error(1.0, 3.02, 1.0, 0.05).expect("multi-step cadence"); + assert!((multi_step - 0.02).abs() < 0.000_001); + assert_eq!(cadence_error(1.0, 2.2, 1.0, 0.05), None); + } + + #[test] + fn prefer_segment_prefers_longer_and_then_closer_candidates() { + assert!(prefer_segment(&seg(4.0, 0.20), &seg(4.0, 0.10), 4.0)); + assert!(!prefer_segment(&seg(4.0, 0.05), &seg(4.0, 0.10), 4.0)); + assert!(prefer_segment(&seg(4.02, 0.10), &seg(4.08, 0.10), 4.0)); + assert!(!prefer_segment(&seg(4.08, 0.10), &seg(4.02, 0.10), 4.0)); + } +} diff --git a/client/src/sync_probe/analyze/onset_detection/tests.rs b/client/src/sync_probe/analyze/onset_detection/tests.rs index 5ceaaf2..f7d2fb6 100644 --- a/client/src/sync_probe/analyze/onset_detection/tests.rs +++ b/client/src/sync_probe/analyze/onset_detection/tests.rs @@ -1,6 +1,6 @@ use super::correlation::{ - candidate_index_offsets, correlate_onsets, estimate_phase, index_onsets_by_spacing, - marker_index_offsets, marker_onsets, shortest_wrapped_difference, + 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, @@ -272,6 +272,137 @@ fn correlate_segments_uses_markers_to_break_period_aliasing() { assert!((report.mean_skew_ms - 50.0).abs() < 10.0); } +#[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); +} + #[test] fn marker_detection_finds_wider_segments_only() { let markers = marker_onsets( diff --git a/common/Cargo.toml b/common/Cargo.toml index ba2e962..f8390fb 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.13.14" +version = "0.13.15" edition = "2024" build = "build.rs" diff --git a/scripts/ci/quality_gate_baseline.json b/scripts/ci/quality_gate_baseline.json index aec6d71..b57a821 100644 --- a/scripts/ci/quality_gate_baseline.json +++ b/scripts/ci/quality_gate_baseline.json @@ -424,5 +424,17 @@ "line_percent": 97.74, "loc": 263 } + }, + "client/src/sync_probe/analyze/media_extract.rs": { + "loc": 245, + "line_percent": 97.96 + }, + "client/src/sync_probe/analyze/onset_detection/correlation.rs": { + "loc": 282, + "line_percent": 99.29 + }, + "client/src/sync_probe/analyze/onset_detection/correlation_collapse.rs": { + "loc": 237, + "line_percent": 98.73 } } diff --git a/server/Cargo.toml b/server/Cargo.toml index f7b0e57..a835c83 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.13.14" +version = "0.13.15" edition = "2024" autobins = false