sync: trust coded probe pairs for verdicts
This commit is contained in:
parent
e73e7f0a0f
commit
aeb85ca998
@ -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.
|
||||
|
||||
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.16.21"
|
||||
version = "0.16.22"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -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<f64> {
|
||||
unpaired_onsets(
|
||||
&report.video_onsets_s,
|
||||
&report
|
||||
.paired_events
|
||||
.iter()
|
||||
.map(|event| event.video_time_s)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
fn unpaired_audio_onsets(report: &SyncAnalysisReport) -> Vec<f64> {
|
||||
unpaired_onsets(
|
||||
&report.audio_onsets_s,
|
||||
&report
|
||||
.paired_events
|
||||
.iter()
|
||||
.map(|event| event.audio_time_s)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
fn unpaired_onsets(all_onsets: &[f64], paired_onsets: &[f64]) -> Vec<f64> {
|
||||
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::<Vec<_>>();
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.16.21"
|
||||
version = "0.16.22"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.16.21"
|
||||
version = "0.16.22"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user