diff --git a/AGENTS.md b/AGENTS.md index 961ae89..c651cfb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,6 +122,8 @@ Context: the mirrored browser probe finally reproduced the real failure class on - [x] Preserve raw activity-start timing before cadence cleanup in coded reports. - [x] Merge short audio envelope dropouts inside one coded pulse so a single tone burst cannot become two fake events. - [x] Add diagnostic coded-pair correlation so stable large skew reports as measured failure instead of `not enough pairs`. +- [x] Make coded mirrored verdicts/calibration use matched coded pulses as authority; raw activity-start deltas are reported separately unless they agree with the coded pairs. +- [x] Print unpaired video/audio onsets in the human report so missed coded pulses are visible during probe triage. - [ ] Keep the mirrored browser probe as the release/blocking upstream A/V gate. - [ ] Keep the old raw-device probe as a lower-level diagnostic only. @@ -168,5 +170,6 @@ Context: the mirrored browser probe finally reproduced the real failure class on - 0.16.19 mirrored browser probe did not move the measured skew: p95 `885.7 ms`, median `-788.4 ms`, activity start `-659.1 ms`, drift `-81.2 ms`. SSH inspection showed Theia was on commit `c348597`, but `/etc/lesavka/server.env` still contained `LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=-45000`; the new `+720ms` baseline was not actually installed. Patch the installer to migrate leaked legacy ambient `-45000` to `+720000` unless `LESAVKA_INSTALL_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US` explicitly asks for the legacy value. - 0.16.20 installed the `+720ms` offset (`/etc/lesavka/server.env` had `LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=720000`), but the mirrored browser capture contained no recognizable color pulses. Theia server logs showed repeated `upstream video frame dropped because the audio master never caught up inside the pairing window`; UVC was effectively starved by the positive audio delay instead of flowing delayed-but-fresh frames. - 0.16.21 makes that wait offset-aware and adds a regression test proving a configured positive audio delay does not freeze UVC video while UAC sleeps before playout. + - Replaying the 0.16.21 artifact after 0.16.22 analyzer hardening changes the verdict from false `catastrophic_failure` to `gross_failure`: p95 `273.8 ms`, median `-188.4 ms`, 7 paired coded pulses. The raw activity-start delta (`-3620.7 ms`) is still printed, but it is ignored for verdict/calibration because it disagrees with coded pairs by `3432.3 ms`; unpaired video/audio onsets are printed for triage. - [ ] Re-run the mirrored browser probe after the pre-start false-positive fix. - [ ] Run Google Meet manual validation. diff --git a/Cargo.lock b/Cargo.lock index 10ef9b7..aace271 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.16.21" +version = "0.16.22" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.16.21" +version = "0.16.22" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.16.21" +version = "0.16.22" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index 113d42d..21bd81d 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.16.21" +version = "0.16.22" edition = "2024" [dependencies] diff --git a/client/src/bin/lesavka-sync-analyze.rs b/client/src/bin/lesavka-sync-analyze.rs index 96c3c56..1a70b01 100644 --- a/client/src/bin/lesavka-sync-analyze.rs +++ b/client/src/bin/lesavka-sync-analyze.rs @@ -157,11 +157,30 @@ fn format_human_report( calibration: &SyncCalibrationRecommendation, verdict: &SyncAnalysisVerdict, ) -> String { + let first_paired_video = report + .paired_events + .first() + .map(|event| event.video_time_s) + .unwrap_or(0.0); + let first_paired_audio = report + .paired_events + .first() + .map(|event| event.audio_time_s) + .unwrap_or(0.0); + let unpaired_video = format_onset_list(&unpaired_video_onsets(report)); + let unpaired_audio = format_onset_list(&unpaired_audio_onsets(report)); + let raw_activity_handling = + if report.coded_events && !report.raw_activity_start_confirms_pairs() { + "reported only; ignored for verdict/calibration because it disagrees with coded pairs" + } else { + "used as verdict evidence" + }; format!( "\ A/V sync report for {capture} - verdict: {status} ({passed}) - verdict reason: {reason} +- evidence mode: {evidence_mode} - p95 abs skew: {p95:.1} ms - video onsets: {video_events} - audio onsets: {audio_events} @@ -169,7 +188,11 @@ A/V sync report for {capture} - activity start delta: {activity_start_delta:+.1} ms (audio after video is positive) - raw first video activity: {raw_video:.3} s - raw first audio activity: {raw_audio:.3} s +- raw-vs-paired disagreement: {raw_pair_disagreement:.1} ms +- raw activity handling: {raw_activity_handling} - paired window first video/audio: {paired_video:.3} s / {paired_audio:.3} s +- unpaired video onsets: {unpaired_video} +- unpaired audio onsets: {unpaired_audio} - first skew: {first_skew:+.1} ms (audio after video is positive) - last skew: {last_skew:+.1} ms - mean skew: {mean_skew:+.1} ms @@ -185,6 +208,11 @@ A/V sync report for {capture} status = verdict.status, passed = if verdict.passed { "pass" } else { "fail" }, reason = verdict.reason, + evidence_mode = if report.coded_events { + "coded pulses" + } else { + "cadence/brightness pulses" + }, p95 = verdict.p95_abs_skew_ms, video_events = report.video_event_count, audio_events = report.audio_event_count, @@ -192,8 +220,12 @@ A/V sync report for {capture} activity_start_delta = report.activity_start_delta_ms, raw_video = report.raw_first_video_activity_s, raw_audio = report.raw_first_audio_activity_s, - paired_video = report.video_onsets_s.first().copied().unwrap_or(0.0), - paired_audio = report.audio_onsets_s.first().copied().unwrap_or(0.0), + raw_pair_disagreement = report.activity_start_pair_disagreement_ms().abs(), + raw_activity_handling = raw_activity_handling, + paired_video = first_paired_video, + paired_audio = first_paired_audio, + unpaired_video = unpaired_video, + unpaired_audio = unpaired_audio, first_skew = report.first_skew_ms, last_skew = report.last_skew_ms, mean_skew = report.mean_skew_ms, @@ -207,6 +239,61 @@ A/V sync report for {capture} ) } +#[cfg(not(coverage))] +fn unpaired_video_onsets(report: &SyncAnalysisReport) -> Vec { + unpaired_onsets( + &report.video_onsets_s, + &report + .paired_events + .iter() + .map(|event| event.video_time_s) + .collect::>(), + ) +} + +#[cfg(not(coverage))] +fn unpaired_audio_onsets(report: &SyncAnalysisReport) -> Vec { + unpaired_onsets( + &report.audio_onsets_s, + &report + .paired_events + .iter() + .map(|event| event.audio_time_s) + .collect::>(), + ) +} + +#[cfg(not(coverage))] +fn unpaired_onsets(all_onsets: &[f64], paired_onsets: &[f64]) -> Vec { + const SAME_ONSET_EPSILON_S: f64 = 0.000_001; + all_onsets + .iter() + .copied() + .filter(|onset| { + !paired_onsets + .iter() + .any(|paired| (paired - onset).abs() <= SAME_ONSET_EPSILON_S) + }) + .collect() +} + +#[cfg(not(coverage))] +fn format_onset_list(onsets: &[f64]) -> String { + const MAX_PRINTED_ONSETS: usize = 12; + if onsets.is_empty() { + return "none".to_string(); + } + let mut formatted = onsets + .iter() + .take(MAX_PRINTED_ONSETS) + .map(|onset| format!("{onset:.3}s")) + .collect::>(); + if onsets.len() > MAX_PRINTED_ONSETS { + formatted.push(format!("...+{}", onsets.len() - MAX_PRINTED_ONSETS)); + } + formatted.join(", ") +} + #[cfg(not(coverage))] fn write_report_dir( report_dir: &std::path::Path, @@ -244,6 +331,9 @@ fn main() {} #[cfg(test)] mod tests { use super::parse_args; + use lesavka_client::sync_probe::analyze::{ + SyncAnalysisReport, SyncAnalysisVerdict, SyncCalibrationRecommendation, SyncEventPair, + }; #[test] fn parse_args_accepts_capture_path_and_json_flag() { @@ -289,4 +379,63 @@ mod tests { fn coverage_main_stub_is_non_panicking() { let _ = super::main(); } + + #[test] + fn human_report_explains_coded_raw_activity_and_unpaired_onsets() { + let report = SyncAnalysisReport { + video_event_count: 3, + audio_event_count: 3, + paired_event_count: 1, + coded_events: true, + activity_start_delta_ms: -3_620.7, + raw_first_video_activity_s: 9.361, + raw_first_audio_activity_s: 5.740, + first_skew_ms: -188.4, + last_skew_ms: -188.4, + mean_skew_ms: -188.4, + median_skew_ms: -188.4, + max_abs_skew_ms: 188.4, + drift_ms: 0.0, + skews_ms: vec![-188.4], + video_onsets_s: vec![9.461, 11.420, 13.367], + audio_onsets_s: vec![9.135, 11.146, 13.135], + paired_events: vec![SyncEventPair { + event_id: 0, + video_time_s: 11.420, + audio_time_s: 11.146, + skew_ms: -188.4, + confidence: 0.62, + }], + }; + let calibration = SyncCalibrationRecommendation { + ready: false, + recommended_audio_offset_adjust_us: 0, + recommended_video_offset_adjust_us: 0, + note: "need more pairs".to_string(), + }; + let verdict = SyncAnalysisVerdict { + status: "gross_failure".to_string(), + passed: false, + p95_abs_skew_ms: 188.4, + max_abs_skew_ms: 188.4, + preferred_p95_abs_skew_ms: 35.0, + acceptable_p95_abs_skew_ms: 80.0, + gross_failure_p95_abs_skew_ms: 250.0, + catastrophic_max_abs_skew_ms: 1_000.0, + reason: "coded pulse skew is too high".to_string(), + }; + + let text = super::format_human_report( + std::path::Path::new("/tmp/capture.webm"), + &report, + &calibration, + &verdict, + ); + + assert!(text.contains("- evidence mode: coded pulses")); + assert!(text.contains("raw activity handling: reported only")); + assert!(text.contains("- paired window first video/audio: 11.420 s / 11.146 s")); + assert!(text.contains("- unpaired video onsets: 9.461s, 13.367s")); + assert!(text.contains("- unpaired audio onsets: 9.135s, 13.135s")); + } } diff --git a/client/src/sync_probe/analyze/onset_detection/correlation.rs b/client/src/sync_probe/analyze/onset_detection/correlation.rs index 016c1df..e819554 100644 --- a/client/src/sync_probe/analyze/onset_detection/correlation.rs +++ b/client/src/sync_probe/analyze/onset_detection/correlation.rs @@ -75,6 +75,7 @@ pub(super) fn correlate_onsets( Ok(sync_report_from_pairs( common_window.filter_onsets(video_onsets_s), common_window.filter_onsets(audio_onsets_s), + false, activity_start_delta_ms, raw_first_video_activity_s, raw_first_audio_activity_s, @@ -186,6 +187,7 @@ pub(crate) fn correlate_segments( Ok(sync_report_from_pairs( video_onsets_s, audio_onsets_s, + false, activity_start_delta_ms, raw_first_video_activity_s, raw_first_audio_activity_s, @@ -312,6 +314,7 @@ pub(crate) fn correlate_coded_segments( return Ok(sync_report_from_pairs( &full_video_onsets_s, &full_audio_onsets_s, + true, activity_start_delta_ms, raw_first_video_activity_s, raw_first_audio_activity_s, @@ -346,6 +349,7 @@ pub(crate) fn correlate_coded_segments( Ok(sync_report_from_pairs( &video_onsets_s, &audio_onsets_s, + true, activity_start_delta_ms, raw_first_video_activity_s, raw_first_audio_activity_s, @@ -830,6 +834,7 @@ pub(super) fn shortest_wrapped_difference(delta_s: f64, pulse_period_s: f64) -> fn sync_report_from_pairs( video_onsets_s: &[f64], audio_onsets_s: &[f64], + coded_events: bool, activity_start_delta_ms: f64, raw_first_video_activity_s: f64, raw_first_audio_activity_s: f64, @@ -866,6 +871,7 @@ fn sync_report_from_pairs( video_event_count: video_onsets_s.len(), audio_event_count: audio_onsets_s.len(), paired_event_count: skews_ms.len(), + coded_events, activity_start_delta_ms, raw_first_video_activity_s, raw_first_audio_activity_s, diff --git a/client/src/sync_probe/analyze/report.rs b/client/src/sync_probe/analyze/report.rs index d92f9c0..9439980 100644 --- a/client/src/sync_probe/analyze/report.rs +++ b/client/src/sync_probe/analyze/report.rs @@ -9,6 +9,7 @@ const CALIBRATION_MIN_PAIRED_EVENTS: usize = 8; const CALIBRATION_MAX_DRIFT_MS: f64 = 40.0; const CALIBRATION_MAX_START_MEDIAN_DISAGREEMENT_MS: f64 = 250.0; const CALIBRATION_SETTLED_SKEW_MS: f64 = 5.0; +const RAW_ACTIVITY_CONFIRMS_PAIR_MAX_DISAGREEMENT_MS: f64 = 250.0; const VERDICT_MIN_PAIRED_EVENTS: usize = 3; const VERDICT_PREFERRED_P95_ABS_SKEW_MS: f64 = 35.0; const VERDICT_ACCEPTABLE_P95_ABS_SKEW_MS: f64 = 80.0; @@ -20,6 +21,7 @@ pub struct SyncAnalysisReport { pub video_event_count: usize, pub audio_event_count: usize, pub paired_event_count: usize, + pub coded_events: bool, pub activity_start_delta_ms: f64, pub raw_first_video_activity_s: f64, pub raw_first_audio_activity_s: f64, @@ -81,17 +83,6 @@ impl SyncAnalysisReport { reason: String::new(), }; - if self.activity_start_delta_ms.abs() >= VERDICT_CATASTROPHIC_MAX_ABS_SKEW_MS { - return SyncAnalysisVerdict { - status: "catastrophic_failure".to_string(), - reason: format!( - "audio/video activity starts are separated by {:+.1} ms, at or above the {:.1} ms catastrophic boundary", - self.activity_start_delta_ms, VERDICT_CATASTROPHIC_MAX_ABS_SKEW_MS - ), - ..base - }; - } - if self.paired_event_count < VERDICT_MIN_PAIRED_EVENTS { return SyncAnalysisVerdict { status: "insufficient_data".to_string(), @@ -114,6 +105,19 @@ impl SyncAnalysisReport { }; } + if self.activity_start_delta_ms.abs() >= VERDICT_CATASTROPHIC_MAX_ABS_SKEW_MS + && self.raw_activity_start_is_verdict_relevant() + { + return SyncAnalysisVerdict { + status: "catastrophic_failure".to_string(), + reason: format!( + "audio/video activity starts are separated by {:+.1} ms, at or above the {:.1} ms catastrophic boundary", + self.activity_start_delta_ms, VERDICT_CATASTROPHIC_MAX_ABS_SKEW_MS + ), + ..base + }; + } + if p95_abs_skew_ms > VERDICT_GROSS_FAILURE_P95_ABS_SKEW_MS { return SyncAnalysisVerdict { status: "gross_failure".to_string(), @@ -125,13 +129,14 @@ impl SyncAnalysisReport { }; } + let raw_activity_note = self.raw_activity_note(); if p95_abs_skew_ms <= VERDICT_PREFERRED_P95_ABS_SKEW_MS { return SyncAnalysisVerdict { status: "preferred".to_string(), passed: true, reason: format!( - "p95 skew {:.1} ms is inside the preferred {:.1} ms band", - p95_abs_skew_ms, VERDICT_PREFERRED_P95_ABS_SKEW_MS + "p95 skew {:.1} ms is inside the preferred {:.1} ms band{}", + p95_abs_skew_ms, VERDICT_PREFERRED_P95_ABS_SKEW_MS, raw_activity_note ), ..base }; @@ -142,8 +147,8 @@ impl SyncAnalysisReport { status: "acceptable".to_string(), passed: true, reason: format!( - "p95 skew {:.1} ms is inside the acceptable {:.1} ms band", - p95_abs_skew_ms, VERDICT_ACCEPTABLE_P95_ABS_SKEW_MS + "p95 skew {:.1} ms is inside the acceptable {:.1} ms band{}", + p95_abs_skew_ms, VERDICT_ACCEPTABLE_P95_ABS_SKEW_MS, raw_activity_note ), ..base }; @@ -185,8 +190,10 @@ impl SyncAnalysisReport { }; } - let start_median_disagreement_ms = self.activity_start_delta_ms - self.median_skew_ms; - if start_median_disagreement_ms.abs() > CALIBRATION_MAX_START_MEDIAN_DISAGREEMENT_MS { + let start_median_disagreement_ms = self.activity_start_pair_disagreement_ms(); + if !self.coded_events + && start_median_disagreement_ms.abs() > CALIBRATION_MAX_START_MEDIAN_DISAGREEMENT_MS + { return SyncCalibrationRecommendation { ready: false, recommended_audio_offset_adjust_us: 0, @@ -203,15 +210,16 @@ impl SyncAnalysisReport { let recommended_audio_offset_adjust_us = (-(self.median_skew_ms * 1_000.0)).round() as i64; let recommended_video_offset_adjust_us = -recommended_audio_offset_adjust_us; + let raw_activity_note = self.raw_activity_calibration_note(); let note = if self.median_skew_ms.abs() <= CALIBRATION_SETTLED_SKEW_MS { format!( - "median skew {:.1} ms is already within the settled {:.1} ms band", - self.median_skew_ms, CALIBRATION_SETTLED_SKEW_MS + "median skew {:.1} ms is already within the settled {:.1} ms band{}", + self.median_skew_ms, CALIBRATION_SETTLED_SKEW_MS, raw_activity_note ) } else { format!( - "apply the audio offset adjustment to move median skew from {:+.1} ms toward 0.0 ms", - self.median_skew_ms + "apply the audio offset adjustment to move median skew from {:+.1} ms toward 0.0 ms{}", + self.median_skew_ms, raw_activity_note ) }; @@ -222,6 +230,48 @@ impl SyncAnalysisReport { note, } } + + #[must_use] + pub fn activity_start_pair_disagreement_ms(&self) -> f64 { + self.activity_start_delta_ms - self.median_skew_ms + } + + #[must_use] + pub fn raw_activity_start_confirms_pairs(&self) -> bool { + self.activity_start_pair_disagreement_ms().abs() + <= RAW_ACTIVITY_CONFIRMS_PAIR_MAX_DISAGREEMENT_MS + } + + fn raw_activity_start_is_verdict_relevant(&self) -> bool { + !self.coded_events || self.raw_activity_start_confirms_pairs() + } + + fn raw_activity_note(&self) -> String { + if !self.coded_events + || self.activity_start_delta_ms.abs() < VERDICT_CATASTROPHIC_MAX_ABS_SKEW_MS + || self.raw_activity_start_confirms_pairs() + { + String::new() + } else { + format!( + "; raw activity start delta {:+.1} ms is reported separately because it disagrees with coded pairs by {:.1} ms", + self.activity_start_delta_ms, + self.activity_start_pair_disagreement_ms().abs() + ) + } + } + + fn raw_activity_calibration_note(&self) -> String { + if !self.coded_events || self.raw_activity_start_confirms_pairs() { + String::new() + } else { + format!( + "; raw activity start delta {:+.1} ms is not used for calibration because coded pairs disagree by {:.1} ms", + self.activity_start_delta_ms, + self.activity_start_pair_disagreement_ms().abs() + ) + } + } } fn percentile_abs(values: &[f64], percentile: f64) -> f64 { @@ -281,6 +331,7 @@ mod tests { video_event_count: 4, audio_event_count: 4, paired_event_count: 4, + coded_events: false, activity_start_delta_ms: 0.0, raw_first_video_activity_s: 0.0, raw_first_audio_activity_s: 0.0, @@ -312,6 +363,7 @@ mod tests { video_event_count: 12, audio_event_count: 12, paired_event_count: 12, + coded_events: false, activity_start_delta_ms: 0.0, raw_first_video_activity_s: 0.0, raw_first_audio_activity_s: 0.0, @@ -339,6 +391,7 @@ mod tests { video_event_count: 14, audio_event_count: 14, paired_event_count: 12, + coded_events: false, activity_start_delta_ms: 0.0, raw_first_video_activity_s: 0.0, raw_first_audio_activity_s: 0.0, @@ -367,6 +420,7 @@ mod tests { video_event_count: 16, audio_event_count: 16, paired_event_count: 16, + coded_events: false, activity_start_delta_ms: -766.4, raw_first_video_activity_s: 7.491, raw_first_audio_activity_s: 6.725, @@ -394,6 +448,7 @@ mod tests { video_event_count: 16, audio_event_count: 16, paired_event_count: 16, + coded_events: false, activity_start_delta_ms: 6_735.0, raw_first_video_activity_s: 0.0, raw_first_audio_activity_s: 6.735, @@ -414,12 +469,41 @@ mod tests { assert!(recommendation.note.contains("disagrees with median skew")); } + #[test] + fn calibration_recommendation_uses_coded_pairs_when_raw_activity_disagrees() { + let report = SyncAnalysisReport { + video_event_count: 14, + audio_event_count: 14, + paired_event_count: 10, + coded_events: true, + activity_start_delta_ms: -3_620.7, + raw_first_video_activity_s: 9.361, + raw_first_audio_activity_s: 5.740, + first_skew_ms: -270.0, + last_skew_ms: -230.0, + mean_skew_ms: -205.0, + median_skew_ms: -188.4, + max_abs_skew_ms: 273.8, + drift_ms: 40.0, + skews_ms: vec![-270.0, -240.0, -188.4, -175.0, -130.0], + video_onsets_s: vec![], + audio_onsets_s: vec![], + paired_events: vec![], + }; + + let recommendation = report.calibration_recommendation(); + assert!(recommendation.ready); + assert_eq!(recommendation.recommended_audio_offset_adjust_us, 188_400); + assert!(recommendation.note.contains("coded pairs disagree")); + } + #[test] fn calibration_recommendation_reports_when_skew_is_already_settled() { let report = SyncAnalysisReport { video_event_count: 14, audio_event_count: 14, paired_event_count: 12, + coded_events: false, activity_start_delta_ms: 0.0, raw_first_video_activity_s: 0.0, raw_first_audio_activity_s: 0.0, @@ -447,6 +531,7 @@ mod tests { video_event_count: 5, audio_event_count: 5, paired_event_count: 5, + coded_events: false, activity_start_delta_ms: 0.0, raw_first_video_activity_s: 0.0, raw_first_audio_activity_s: 0.0, @@ -473,6 +558,7 @@ mod tests { video_event_count: 5, audio_event_count: 5, paired_event_count: 5, + coded_events: false, activity_start_delta_ms: 0.0, raw_first_video_activity_s: 0.0, raw_first_audio_activity_s: 0.0, @@ -499,6 +585,7 @@ mod tests { video_event_count: 20, audio_event_count: 20, paired_event_count: 20, + coded_events: false, activity_start_delta_ms: 20_000.0, raw_first_video_activity_s: 0.0, raw_first_audio_activity_s: 0.0, @@ -519,4 +606,32 @@ mod tests { assert_eq!(verdict.status, "catastrophic_failure"); assert!(verdict.reason.contains("activity starts")); } + + #[test] + fn verdict_ignores_uncorroborated_raw_activity_for_coded_runs() { + let report = SyncAnalysisReport { + video_event_count: 20, + audio_event_count: 20, + paired_event_count: 20, + coded_events: true, + activity_start_delta_ms: -3_620.7, + raw_first_video_activity_s: 9.361, + raw_first_audio_activity_s: 5.740, + first_skew_ms: -20.0, + last_skew_ms: -18.0, + mean_skew_ms: -19.0, + median_skew_ms: -19.0, + max_abs_skew_ms: 20.0, + drift_ms: 2.0, + skews_ms: vec![-20.0; 20], + video_onsets_s: vec![], + audio_onsets_s: vec![], + paired_events: vec![], + }; + + let verdict = report.verdict(); + assert!(verdict.passed); + assert_eq!(verdict.status, "preferred"); + assert!(verdict.reason.contains("raw activity start delta")); + } } diff --git a/common/Cargo.toml b/common/Cargo.toml index 9644d38..c3f2ada 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.16.21" +version = "0.16.22" edition = "2024" build = "build.rs" diff --git a/server/Cargo.toml b/server/Cargo.toml index 27a4cf2..95ca642 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.16.21" +version = "0.16.22" edition = "2024" autobins = false diff --git a/server/src/upstream_media_runtime.rs b/server/src/upstream_media_runtime.rs index 0cc77ee..05396a6 100644 --- a/server/src/upstream_media_runtime.rs +++ b/server/src/upstream_media_runtime.rs @@ -91,9 +91,7 @@ impl UpstreamMediaRuntime { fn positive_audio_delay_allowance_us(&self) -> u64 { let camera_offset_us = self.camera_playout_offset_us.load(Ordering::Relaxed); let microphone_offset_us = self.microphone_playout_offset_us.load(Ordering::Relaxed); - microphone_offset_us - .saturating_sub(camera_offset_us) - .max(0) as u64 + microphone_offset_us.saturating_sub(camera_offset_us).max(0) as u64 } }