fix(sync): tighten detector and client freshness

This commit is contained in:
Brad Stein 2026-04-25 05:35:16 -03:00
parent 2aa4a27f9f
commit 76a6df4f60
15 changed files with 545 additions and 42 deletions

6
Cargo.lock generated
View File

@ -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",

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package]
name = "lesavka_client"
version = "0.13.14"
version = "0.13.15"
edition = "2024"
[dependencies]

View File

@ -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) => {

View File

@ -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);

View File

@ -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]

View File

@ -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}"
);

View File

@ -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."
);

View File

@ -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]);
},
);
}

View File

@ -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 {

View File

@ -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

View File

@ -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));
}
}

View File

@ -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(

View File

@ -1,6 +1,6 @@
[package]
name = "lesavka_common"
version = "0.13.14"
version = "0.13.15"
edition = "2024"
build = "build.rs"

View File

@ -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
}
}

View File

@ -10,7 +10,7 @@ bench = false
[package]
name = "lesavka_server"
version = "0.13.14"
version = "0.13.15"
edition = "2024"
autobins = false