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] 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] 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] 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 mirrored browser probe as the release/blocking upstream A/V gate.
|
||||||
- [ ] Keep the old raw-device probe as a lower-level diagnostic only.
|
- [ ] 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.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.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.
|
- 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.
|
- [ ] Re-run the mirrored browser probe after the pre-start false-positive fix.
|
||||||
- [ ] Run Google Meet manual validation.
|
- [ ] Run Google Meet manual validation.
|
||||||
|
|||||||
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.16.21"
|
version = "0.16.22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@ -1686,7 +1686,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.16.21"
|
version = "0.16.22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@ -1698,7 +1698,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.16.21"
|
version = "0.16.22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|||||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.16.21"
|
version = "0.16.22"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -157,11 +157,30 @@ fn format_human_report(
|
|||||||
calibration: &SyncCalibrationRecommendation,
|
calibration: &SyncCalibrationRecommendation,
|
||||||
verdict: &SyncAnalysisVerdict,
|
verdict: &SyncAnalysisVerdict,
|
||||||
) -> String {
|
) -> 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!(
|
format!(
|
||||||
"\
|
"\
|
||||||
A/V sync report for {capture}
|
A/V sync report for {capture}
|
||||||
- verdict: {status} ({passed})
|
- verdict: {status} ({passed})
|
||||||
- verdict reason: {reason}
|
- verdict reason: {reason}
|
||||||
|
- evidence mode: {evidence_mode}
|
||||||
- p95 abs skew: {p95:.1} ms
|
- p95 abs skew: {p95:.1} ms
|
||||||
- video onsets: {video_events}
|
- video onsets: {video_events}
|
||||||
- audio onsets: {audio_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)
|
- activity start delta: {activity_start_delta:+.1} ms (audio after video is positive)
|
||||||
- raw first video activity: {raw_video:.3} s
|
- raw first video activity: {raw_video:.3} s
|
||||||
- raw first audio activity: {raw_audio:.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
|
- 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)
|
- first skew: {first_skew:+.1} ms (audio after video is positive)
|
||||||
- last skew: {last_skew:+.1} ms
|
- last skew: {last_skew:+.1} ms
|
||||||
- mean skew: {mean_skew:+.1} ms
|
- mean skew: {mean_skew:+.1} ms
|
||||||
@ -185,6 +208,11 @@ A/V sync report for {capture}
|
|||||||
status = verdict.status,
|
status = verdict.status,
|
||||||
passed = if verdict.passed { "pass" } else { "fail" },
|
passed = if verdict.passed { "pass" } else { "fail" },
|
||||||
reason = verdict.reason,
|
reason = verdict.reason,
|
||||||
|
evidence_mode = if report.coded_events {
|
||||||
|
"coded pulses"
|
||||||
|
} else {
|
||||||
|
"cadence/brightness pulses"
|
||||||
|
},
|
||||||
p95 = verdict.p95_abs_skew_ms,
|
p95 = verdict.p95_abs_skew_ms,
|
||||||
video_events = report.video_event_count,
|
video_events = report.video_event_count,
|
||||||
audio_events = report.audio_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,
|
activity_start_delta = report.activity_start_delta_ms,
|
||||||
raw_video = report.raw_first_video_activity_s,
|
raw_video = report.raw_first_video_activity_s,
|
||||||
raw_audio = report.raw_first_audio_activity_s,
|
raw_audio = report.raw_first_audio_activity_s,
|
||||||
paired_video = report.video_onsets_s.first().copied().unwrap_or(0.0),
|
raw_pair_disagreement = report.activity_start_pair_disagreement_ms().abs(),
|
||||||
paired_audio = report.audio_onsets_s.first().copied().unwrap_or(0.0),
|
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,
|
first_skew = report.first_skew_ms,
|
||||||
last_skew = report.last_skew_ms,
|
last_skew = report.last_skew_ms,
|
||||||
mean_skew = report.mean_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))]
|
#[cfg(not(coverage))]
|
||||||
fn write_report_dir(
|
fn write_report_dir(
|
||||||
report_dir: &std::path::Path,
|
report_dir: &std::path::Path,
|
||||||
@ -244,6 +331,9 @@ fn main() {}
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::parse_args;
|
use super::parse_args;
|
||||||
|
use lesavka_client::sync_probe::analyze::{
|
||||||
|
SyncAnalysisReport, SyncAnalysisVerdict, SyncCalibrationRecommendation, SyncEventPair,
|
||||||
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_args_accepts_capture_path_and_json_flag() {
|
fn parse_args_accepts_capture_path_and_json_flag() {
|
||||||
@ -289,4 +379,63 @@ mod tests {
|
|||||||
fn coverage_main_stub_is_non_panicking() {
|
fn coverage_main_stub_is_non_panicking() {
|
||||||
let _ = super::main();
|
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(
|
Ok(sync_report_from_pairs(
|
||||||
common_window.filter_onsets(video_onsets_s),
|
common_window.filter_onsets(video_onsets_s),
|
||||||
common_window.filter_onsets(audio_onsets_s),
|
common_window.filter_onsets(audio_onsets_s),
|
||||||
|
false,
|
||||||
activity_start_delta_ms,
|
activity_start_delta_ms,
|
||||||
raw_first_video_activity_s,
|
raw_first_video_activity_s,
|
||||||
raw_first_audio_activity_s,
|
raw_first_audio_activity_s,
|
||||||
@ -186,6 +187,7 @@ pub(crate) fn correlate_segments(
|
|||||||
Ok(sync_report_from_pairs(
|
Ok(sync_report_from_pairs(
|
||||||
video_onsets_s,
|
video_onsets_s,
|
||||||
audio_onsets_s,
|
audio_onsets_s,
|
||||||
|
false,
|
||||||
activity_start_delta_ms,
|
activity_start_delta_ms,
|
||||||
raw_first_video_activity_s,
|
raw_first_video_activity_s,
|
||||||
raw_first_audio_activity_s,
|
raw_first_audio_activity_s,
|
||||||
@ -312,6 +314,7 @@ pub(crate) fn correlate_coded_segments(
|
|||||||
return Ok(sync_report_from_pairs(
|
return Ok(sync_report_from_pairs(
|
||||||
&full_video_onsets_s,
|
&full_video_onsets_s,
|
||||||
&full_audio_onsets_s,
|
&full_audio_onsets_s,
|
||||||
|
true,
|
||||||
activity_start_delta_ms,
|
activity_start_delta_ms,
|
||||||
raw_first_video_activity_s,
|
raw_first_video_activity_s,
|
||||||
raw_first_audio_activity_s,
|
raw_first_audio_activity_s,
|
||||||
@ -346,6 +349,7 @@ pub(crate) fn correlate_coded_segments(
|
|||||||
Ok(sync_report_from_pairs(
|
Ok(sync_report_from_pairs(
|
||||||
&video_onsets_s,
|
&video_onsets_s,
|
||||||
&audio_onsets_s,
|
&audio_onsets_s,
|
||||||
|
true,
|
||||||
activity_start_delta_ms,
|
activity_start_delta_ms,
|
||||||
raw_first_video_activity_s,
|
raw_first_video_activity_s,
|
||||||
raw_first_audio_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(
|
fn sync_report_from_pairs(
|
||||||
video_onsets_s: &[f64],
|
video_onsets_s: &[f64],
|
||||||
audio_onsets_s: &[f64],
|
audio_onsets_s: &[f64],
|
||||||
|
coded_events: bool,
|
||||||
activity_start_delta_ms: f64,
|
activity_start_delta_ms: f64,
|
||||||
raw_first_video_activity_s: f64,
|
raw_first_video_activity_s: f64,
|
||||||
raw_first_audio_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(),
|
video_event_count: video_onsets_s.len(),
|
||||||
audio_event_count: audio_onsets_s.len(),
|
audio_event_count: audio_onsets_s.len(),
|
||||||
paired_event_count: skews_ms.len(),
|
paired_event_count: skews_ms.len(),
|
||||||
|
coded_events,
|
||||||
activity_start_delta_ms,
|
activity_start_delta_ms,
|
||||||
raw_first_video_activity_s,
|
raw_first_video_activity_s,
|
||||||
raw_first_audio_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_DRIFT_MS: f64 = 40.0;
|
||||||
const CALIBRATION_MAX_START_MEDIAN_DISAGREEMENT_MS: f64 = 250.0;
|
const CALIBRATION_MAX_START_MEDIAN_DISAGREEMENT_MS: f64 = 250.0;
|
||||||
const CALIBRATION_SETTLED_SKEW_MS: f64 = 5.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_MIN_PAIRED_EVENTS: usize = 3;
|
||||||
const VERDICT_PREFERRED_P95_ABS_SKEW_MS: f64 = 35.0;
|
const VERDICT_PREFERRED_P95_ABS_SKEW_MS: f64 = 35.0;
|
||||||
const VERDICT_ACCEPTABLE_P95_ABS_SKEW_MS: f64 = 80.0;
|
const VERDICT_ACCEPTABLE_P95_ABS_SKEW_MS: f64 = 80.0;
|
||||||
@ -20,6 +21,7 @@ pub struct SyncAnalysisReport {
|
|||||||
pub video_event_count: usize,
|
pub video_event_count: usize,
|
||||||
pub audio_event_count: usize,
|
pub audio_event_count: usize,
|
||||||
pub paired_event_count: usize,
|
pub paired_event_count: usize,
|
||||||
|
pub coded_events: bool,
|
||||||
pub activity_start_delta_ms: f64,
|
pub activity_start_delta_ms: f64,
|
||||||
pub raw_first_video_activity_s: f64,
|
pub raw_first_video_activity_s: f64,
|
||||||
pub raw_first_audio_activity_s: f64,
|
pub raw_first_audio_activity_s: f64,
|
||||||
@ -81,17 +83,6 @@ impl SyncAnalysisReport {
|
|||||||
reason: String::new(),
|
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 {
|
if self.paired_event_count < VERDICT_MIN_PAIRED_EVENTS {
|
||||||
return SyncAnalysisVerdict {
|
return SyncAnalysisVerdict {
|
||||||
status: "insufficient_data".to_string(),
|
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 {
|
if p95_abs_skew_ms > VERDICT_GROSS_FAILURE_P95_ABS_SKEW_MS {
|
||||||
return SyncAnalysisVerdict {
|
return SyncAnalysisVerdict {
|
||||||
status: "gross_failure".to_string(),
|
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 {
|
if p95_abs_skew_ms <= VERDICT_PREFERRED_P95_ABS_SKEW_MS {
|
||||||
return SyncAnalysisVerdict {
|
return SyncAnalysisVerdict {
|
||||||
status: "preferred".to_string(),
|
status: "preferred".to_string(),
|
||||||
passed: true,
|
passed: true,
|
||||||
reason: format!(
|
reason: format!(
|
||||||
"p95 skew {:.1} ms is inside the preferred {:.1} ms band",
|
"p95 skew {:.1} ms is inside the preferred {:.1} ms band{}",
|
||||||
p95_abs_skew_ms, VERDICT_PREFERRED_P95_ABS_SKEW_MS
|
p95_abs_skew_ms, VERDICT_PREFERRED_P95_ABS_SKEW_MS, raw_activity_note
|
||||||
),
|
),
|
||||||
..base
|
..base
|
||||||
};
|
};
|
||||||
@ -142,8 +147,8 @@ impl SyncAnalysisReport {
|
|||||||
status: "acceptable".to_string(),
|
status: "acceptable".to_string(),
|
||||||
passed: true,
|
passed: true,
|
||||||
reason: format!(
|
reason: format!(
|
||||||
"p95 skew {:.1} ms is inside the acceptable {:.1} ms band",
|
"p95 skew {:.1} ms is inside the acceptable {:.1} ms band{}",
|
||||||
p95_abs_skew_ms, VERDICT_ACCEPTABLE_P95_ABS_SKEW_MS
|
p95_abs_skew_ms, VERDICT_ACCEPTABLE_P95_ABS_SKEW_MS, raw_activity_note
|
||||||
),
|
),
|
||||||
..base
|
..base
|
||||||
};
|
};
|
||||||
@ -185,8 +190,10 @@ impl SyncAnalysisReport {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let start_median_disagreement_ms = self.activity_start_delta_ms - self.median_skew_ms;
|
let start_median_disagreement_ms = self.activity_start_pair_disagreement_ms();
|
||||||
if start_median_disagreement_ms.abs() > CALIBRATION_MAX_START_MEDIAN_DISAGREEMENT_MS {
|
if !self.coded_events
|
||||||
|
&& start_median_disagreement_ms.abs() > CALIBRATION_MAX_START_MEDIAN_DISAGREEMENT_MS
|
||||||
|
{
|
||||||
return SyncCalibrationRecommendation {
|
return SyncCalibrationRecommendation {
|
||||||
ready: false,
|
ready: false,
|
||||||
recommended_audio_offset_adjust_us: 0,
|
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_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 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 {
|
let note = if self.median_skew_ms.abs() <= CALIBRATION_SETTLED_SKEW_MS {
|
||||||
format!(
|
format!(
|
||||||
"median skew {:.1} ms is already within the settled {:.1} ms band",
|
"median skew {:.1} ms is already within the settled {:.1} ms band{}",
|
||||||
self.median_skew_ms, CALIBRATION_SETTLED_SKEW_MS
|
self.median_skew_ms, CALIBRATION_SETTLED_SKEW_MS, raw_activity_note
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
format!(
|
format!(
|
||||||
"apply the audio offset adjustment to move median skew from {:+.1} ms toward 0.0 ms",
|
"apply the audio offset adjustment to move median skew from {:+.1} ms toward 0.0 ms{}",
|
||||||
self.median_skew_ms
|
self.median_skew_ms, raw_activity_note
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -222,6 +230,48 @@ impl SyncAnalysisReport {
|
|||||||
note,
|
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 {
|
fn percentile_abs(values: &[f64], percentile: f64) -> f64 {
|
||||||
@ -281,6 +331,7 @@ mod tests {
|
|||||||
video_event_count: 4,
|
video_event_count: 4,
|
||||||
audio_event_count: 4,
|
audio_event_count: 4,
|
||||||
paired_event_count: 4,
|
paired_event_count: 4,
|
||||||
|
coded_events: false,
|
||||||
activity_start_delta_ms: 0.0,
|
activity_start_delta_ms: 0.0,
|
||||||
raw_first_video_activity_s: 0.0,
|
raw_first_video_activity_s: 0.0,
|
||||||
raw_first_audio_activity_s: 0.0,
|
raw_first_audio_activity_s: 0.0,
|
||||||
@ -312,6 +363,7 @@ mod tests {
|
|||||||
video_event_count: 12,
|
video_event_count: 12,
|
||||||
audio_event_count: 12,
|
audio_event_count: 12,
|
||||||
paired_event_count: 12,
|
paired_event_count: 12,
|
||||||
|
coded_events: false,
|
||||||
activity_start_delta_ms: 0.0,
|
activity_start_delta_ms: 0.0,
|
||||||
raw_first_video_activity_s: 0.0,
|
raw_first_video_activity_s: 0.0,
|
||||||
raw_first_audio_activity_s: 0.0,
|
raw_first_audio_activity_s: 0.0,
|
||||||
@ -339,6 +391,7 @@ mod tests {
|
|||||||
video_event_count: 14,
|
video_event_count: 14,
|
||||||
audio_event_count: 14,
|
audio_event_count: 14,
|
||||||
paired_event_count: 12,
|
paired_event_count: 12,
|
||||||
|
coded_events: false,
|
||||||
activity_start_delta_ms: 0.0,
|
activity_start_delta_ms: 0.0,
|
||||||
raw_first_video_activity_s: 0.0,
|
raw_first_video_activity_s: 0.0,
|
||||||
raw_first_audio_activity_s: 0.0,
|
raw_first_audio_activity_s: 0.0,
|
||||||
@ -367,6 +420,7 @@ mod tests {
|
|||||||
video_event_count: 16,
|
video_event_count: 16,
|
||||||
audio_event_count: 16,
|
audio_event_count: 16,
|
||||||
paired_event_count: 16,
|
paired_event_count: 16,
|
||||||
|
coded_events: false,
|
||||||
activity_start_delta_ms: -766.4,
|
activity_start_delta_ms: -766.4,
|
||||||
raw_first_video_activity_s: 7.491,
|
raw_first_video_activity_s: 7.491,
|
||||||
raw_first_audio_activity_s: 6.725,
|
raw_first_audio_activity_s: 6.725,
|
||||||
@ -394,6 +448,7 @@ mod tests {
|
|||||||
video_event_count: 16,
|
video_event_count: 16,
|
||||||
audio_event_count: 16,
|
audio_event_count: 16,
|
||||||
paired_event_count: 16,
|
paired_event_count: 16,
|
||||||
|
coded_events: false,
|
||||||
activity_start_delta_ms: 6_735.0,
|
activity_start_delta_ms: 6_735.0,
|
||||||
raw_first_video_activity_s: 0.0,
|
raw_first_video_activity_s: 0.0,
|
||||||
raw_first_audio_activity_s: 6.735,
|
raw_first_audio_activity_s: 6.735,
|
||||||
@ -414,12 +469,41 @@ mod tests {
|
|||||||
assert!(recommendation.note.contains("disagrees with median skew"));
|
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]
|
#[test]
|
||||||
fn calibration_recommendation_reports_when_skew_is_already_settled() {
|
fn calibration_recommendation_reports_when_skew_is_already_settled() {
|
||||||
let report = SyncAnalysisReport {
|
let report = SyncAnalysisReport {
|
||||||
video_event_count: 14,
|
video_event_count: 14,
|
||||||
audio_event_count: 14,
|
audio_event_count: 14,
|
||||||
paired_event_count: 12,
|
paired_event_count: 12,
|
||||||
|
coded_events: false,
|
||||||
activity_start_delta_ms: 0.0,
|
activity_start_delta_ms: 0.0,
|
||||||
raw_first_video_activity_s: 0.0,
|
raw_first_video_activity_s: 0.0,
|
||||||
raw_first_audio_activity_s: 0.0,
|
raw_first_audio_activity_s: 0.0,
|
||||||
@ -447,6 +531,7 @@ mod tests {
|
|||||||
video_event_count: 5,
|
video_event_count: 5,
|
||||||
audio_event_count: 5,
|
audio_event_count: 5,
|
||||||
paired_event_count: 5,
|
paired_event_count: 5,
|
||||||
|
coded_events: false,
|
||||||
activity_start_delta_ms: 0.0,
|
activity_start_delta_ms: 0.0,
|
||||||
raw_first_video_activity_s: 0.0,
|
raw_first_video_activity_s: 0.0,
|
||||||
raw_first_audio_activity_s: 0.0,
|
raw_first_audio_activity_s: 0.0,
|
||||||
@ -473,6 +558,7 @@ mod tests {
|
|||||||
video_event_count: 5,
|
video_event_count: 5,
|
||||||
audio_event_count: 5,
|
audio_event_count: 5,
|
||||||
paired_event_count: 5,
|
paired_event_count: 5,
|
||||||
|
coded_events: false,
|
||||||
activity_start_delta_ms: 0.0,
|
activity_start_delta_ms: 0.0,
|
||||||
raw_first_video_activity_s: 0.0,
|
raw_first_video_activity_s: 0.0,
|
||||||
raw_first_audio_activity_s: 0.0,
|
raw_first_audio_activity_s: 0.0,
|
||||||
@ -499,6 +585,7 @@ mod tests {
|
|||||||
video_event_count: 20,
|
video_event_count: 20,
|
||||||
audio_event_count: 20,
|
audio_event_count: 20,
|
||||||
paired_event_count: 20,
|
paired_event_count: 20,
|
||||||
|
coded_events: false,
|
||||||
activity_start_delta_ms: 20_000.0,
|
activity_start_delta_ms: 20_000.0,
|
||||||
raw_first_video_activity_s: 0.0,
|
raw_first_video_activity_s: 0.0,
|
||||||
raw_first_audio_activity_s: 0.0,
|
raw_first_audio_activity_s: 0.0,
|
||||||
@ -519,4 +606,32 @@ mod tests {
|
|||||||
assert_eq!(verdict.status, "catastrophic_failure");
|
assert_eq!(verdict.status, "catastrophic_failure");
|
||||||
assert!(verdict.reason.contains("activity starts"));
|
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]
|
[package]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.16.21"
|
version = "0.16.22"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ bench = false
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.16.21"
|
version = "0.16.22"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
autobins = false
|
autobins = false
|
||||||
|
|
||||||
|
|||||||
@ -91,9 +91,7 @@ impl UpstreamMediaRuntime {
|
|||||||
fn positive_audio_delay_allowance_us(&self) -> u64 {
|
fn positive_audio_delay_allowance_us(&self) -> u64 {
|
||||||
let camera_offset_us = self.camera_playout_offset_us.load(Ordering::Relaxed);
|
let camera_offset_us = self.camera_playout_offset_us.load(Ordering::Relaxed);
|
||||||
let microphone_offset_us = self.microphone_playout_offset_us.load(Ordering::Relaxed);
|
let microphone_offset_us = self.microphone_playout_offset_us.load(Ordering::Relaxed);
|
||||||
microphone_offset_us
|
microphone_offset_us.saturating_sub(camera_offset_us).max(0) as u64
|
||||||
.saturating_sub(camera_offset_us)
|
|
||||||
.max(0) as u64
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user