fix(sync): tighten detector and client freshness
This commit is contained in:
parent
2aa4a27f9f
commit
76a6df4f60
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.13.14"
|
||||
version = "0.13.15"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -32,9 +32,9 @@ pub fn camera_config_from_caps(caps: &PeerCaps) -> Option<CameraConfig> {
|
||||
}
|
||||
|
||||
#[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>) -> 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]
|
||||
|
||||
@ -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}"
|
||||
);
|
||||
|
||||
@ -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."
|
||||
);
|
||||
|
||||
@ -144,15 +144,25 @@ pub(super) fn run_command(command: &mut Command, description: &str) -> Result<Ve
|
||||
fn summarize_frame_brightness(frame: &[u8]) -> 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]);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<SyncAnalysisReport> {
|
||||
let video_onsets_s = video_segments
|
||||
.iter()
|
||||
.map(|segment| segment.start_s)
|
||||
.collect::<Vec<_>>();
|
||||
let audio_onsets_s = audio_segments
|
||||
.iter()
|
||||
.map(|segment| segment.start_s)
|
||||
.collect::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
let audio_onsets_s = audio_segments
|
||||
.iter()
|
||||
.map(|segment| segment.start_s)
|
||||
.collect::<Vec<_>>();
|
||||
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
|
||||
|
||||
@ -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<PulseSegment> {
|
||||
if segments.len() <= 1 {
|
||||
return segments.to_vec();
|
||||
}
|
||||
|
||||
let mut best: Option<(usize, f64, f64, Vec<PulseSegment>)> = 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::<f64>();
|
||||
let total_error_s = cadence
|
||||
.iter()
|
||||
.map(|segment| phase_error_for_reference(segment, candidate_phase, pulse_period_s))
|
||||
.sum::<f64>();
|
||||
|
||||
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<PulseSegment> {
|
||||
let mut by_index = BTreeMap::<i64, PulseSegment>::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<PulseSegment> {
|
||||
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::<Vec<_>>();
|
||||
let mut best_error_s = vec![0.0_f64; segments.len()];
|
||||
let mut previous_index = vec![None::<usize>; 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<f64> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.13.14"
|
||||
version = "0.13.15"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.13.14"
|
||||
version = "0.13.15"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user