sync: calibrate mirrored upstream av baseline
This commit is contained in:
parent
add8d66c98
commit
c348597ea1
@ -118,6 +118,10 @@ Context: the mirrored browser probe finally reproduced the real failure class on
|
||||
- [x] Replace generic light/dark mirrored flashes with color-coded event IDs.
|
||||
- [x] Make mirrored audio pulses unique by the same event ID via pulse width plus tone frequency.
|
||||
- [x] Teach the analyzer to decode mirrored video event IDs from color, not grayscale brightness.
|
||||
- [x] Tighten real-camera color matching after 0.16.18 accepted washed-out brown/gray remnants as red/yellow events.
|
||||
- [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`.
|
||||
- [ ] 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.
|
||||
|
||||
@ -133,6 +137,9 @@ Context: the mirrored browser probe finally reproduced the real failure class on
|
||||
### Phase 2: Bound UAC Freshness
|
||||
- [x] Configure UAC `appsrc` as non-blocking and bounded.
|
||||
- [x] Log and drop UAC appsrc push failures instead of treating enqueue as guaranteed playback.
|
||||
- [x] Raise calibration offset limits to cover one-second healing without rejecting measured probe corrections.
|
||||
- [x] Update the MJPEG/UVC factory audio baseline from `-45ms` to `+720ms` based on the first trustworthy mirrored browser probe artifact.
|
||||
- [x] Migrate untouched legacy `-45ms` factory/env calibration files on load so old installs actually receive the new baseline.
|
||||
- [ ] Flush/stop UAC cleanly on session close, replacement, and recovery.
|
||||
- [x] Add tests or contract coverage for bounded UAC settings where practical.
|
||||
|
||||
@ -155,5 +162,7 @@ Context: the mirrored browser probe finally reproduced the real failure class on
|
||||
- [x] Bump version for the fix release.
|
||||
- [x] Run the mirrored browser probe on installed client/server.
|
||||
- 0.16.17 still failed: reported `activity_start_delta_ms=+6735.0`, but `raw_first_video_activity_s=0.000` exposed a probe false-positive from the pre-start screen. Paired pulses still showed real steady-state skew (`p95=411.8 ms`, `median=-99.0 ms`), so the product remains unfixed.
|
||||
- 0.16.18 captured real colored/audio-coded events but the analyzer still bailed with `need at least 3 matching coded pulse pairs; saw 1`. Replaying that artifact after analyzer hardening now reports `gross_failure`: 16/16 coded pairs, p95 `775.7 ms`, activity start `-766.4 ms`, and drift `-2.8 ms`; the failure is stable audio-ahead/video-late skew, not random detector noise.
|
||||
- 0.16.19 changes the shipped MJPEG/UVC audio playout baseline to `+720ms`; the next mirrored browser probe should move the measured median from about `-766ms` toward roughly `-46ms` before fine calibration.
|
||||
- [ ] 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.18"
|
||||
version = "0.16.19"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@ -1686,7 +1686,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lesavka_common"
|
||||
version = "0.16.18"
|
||||
version = "0.16.19"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@ -1698,7 +1698,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lesavka_server"
|
||||
version = "0.16.18"
|
||||
version = "0.16.19"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.16.18"
|
||||
version = "0.16.19"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -346,11 +346,11 @@ impl Default for CalibrationStatus {
|
||||
Self {
|
||||
available: false,
|
||||
profile: "mjpeg".to_string(),
|
||||
factory_audio_offset_us: -45_000,
|
||||
factory_audio_offset_us: 720_000,
|
||||
factory_video_offset_us: 0,
|
||||
default_audio_offset_us: -45_000,
|
||||
default_audio_offset_us: 720_000,
|
||||
default_video_offset_us: 0,
|
||||
active_audio_offset_us: -45_000,
|
||||
active_audio_offset_us: 720_000,
|
||||
active_video_offset_us: 0,
|
||||
source: "unknown".to_string(),
|
||||
confidence: "unknown".to_string(),
|
||||
|
||||
@ -405,7 +405,7 @@ fn capture_power_status_updates_snapshot_state() {
|
||||
fn calibration_status_tracks_proto_unavailable_and_status_line() {
|
||||
let mut state = LauncherState::new();
|
||||
assert!(!state.calibration.available);
|
||||
assert_eq!(state.calibration.active_audio_offset_us, -45_000);
|
||||
assert_eq!(state.calibration.active_audio_offset_us, 720_000);
|
||||
|
||||
let unavailable = CalibrationStatus::unavailable("server unreachable");
|
||||
assert!(!unavailable.available);
|
||||
@ -414,7 +414,7 @@ fn calibration_status_tracks_proto_unavailable_and_status_line() {
|
||||
state.set_calibration(CalibrationStatus::from_proto(
|
||||
lesavka_common::lesavka::CalibrationState {
|
||||
profile: "mjpeg".to_string(),
|
||||
factory_audio_offset_us: -45_000,
|
||||
factory_audio_offset_us: 720_000,
|
||||
factory_video_offset_us: 0,
|
||||
default_audio_offset_us: -40_000,
|
||||
default_video_offset_us: 1_000,
|
||||
@ -429,7 +429,7 @@ fn calibration_status_tracks_proto_unavailable_and_status_line() {
|
||||
|
||||
assert!(state.calibration.available);
|
||||
assert_eq!(state.calibration.profile, "mjpeg");
|
||||
assert_eq!(state.calibration.factory_audio_offset_us, -45_000);
|
||||
assert_eq!(state.calibration.factory_audio_offset_us, 720_000);
|
||||
assert_eq!(state.calibration.factory_video_offset_us, 0);
|
||||
assert_eq!(state.calibration.default_audio_offset_us, -40_000);
|
||||
assert_eq!(state.calibration.default_video_offset_us, 1_000);
|
||||
|
||||
@ -15,7 +15,8 @@ const MAX_VIDEO_ACTIVE_FRAME_FRACTION: f64 = 0.35;
|
||||
const MAX_VIDEO_FLICKER_SEGMENT_FRAME_MULTIPLIER: f64 = 1.5;
|
||||
const MIN_COLOR_PULSE_SATURATION: u8 = 36;
|
||||
const MIN_COLOR_PULSE_VALUE: u8 = 70;
|
||||
const MAX_COLOR_DISTANCE_SQUARED: u32 = 42_000;
|
||||
const MAX_COLOR_DISTANCE_SQUARED: u32 = 24_000;
|
||||
const MAX_AUDIO_PULSE_INTERNAL_GAP_S: f64 = 0.09;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) struct VideoColorFrame {
|
||||
@ -191,6 +192,20 @@ pub(crate) fn detect_color_coded_video_segments(
|
||||
Ok(segments)
|
||||
}
|
||||
|
||||
fn merge_nearby_audio_segments(segments: Vec<PulseSegment>) -> Vec<PulseSegment> {
|
||||
let mut merged = Vec::<PulseSegment>::new();
|
||||
for segment in segments {
|
||||
match merged.last_mut() {
|
||||
Some(prior) if segment.start_s - prior.end_s <= MAX_AUDIO_PULSE_INTERNAL_GAP_S => {
|
||||
prior.end_s = segment.end_s;
|
||||
prior.duration_s = prior.end_s - prior.start_s;
|
||||
}
|
||||
_ => merged.push(segment),
|
||||
}
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
fn push_color_segment(
|
||||
segments: &mut Vec<PulseSegment>,
|
||||
start_s: f64,
|
||||
@ -370,7 +385,7 @@ pub(crate) fn detect_audio_segments(
|
||||
));
|
||||
}
|
||||
|
||||
Ok(segments)
|
||||
Ok(merge_nearby_audio_segments(segments))
|
||||
}
|
||||
|
||||
pub(super) fn edge_midpoint(previous_s: f64, current_s: f64) -> f64 {
|
||||
|
||||
@ -13,6 +13,7 @@ const MARKER_WIDTH_MULTIPLIER: f64 = 1.5;
|
||||
const PHASE_TOLERANCE_WIDTH_MULTIPLIER: f64 = 2.5;
|
||||
const STARTUP_PHASE_ANCHOR_TOLERANCE_FRACTION: f64 = 1.0 / 3.0;
|
||||
const MIN_CODED_PAIRS: usize = 3;
|
||||
const DIAGNOSTIC_CODED_MAX_PAIR_GAP_S: f64 = 30.0;
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(super) fn correlate_onsets(
|
||||
@ -108,6 +109,17 @@ pub(crate) fn correlate_segments(
|
||||
bail!("max pair gap must stay positive");
|
||||
}
|
||||
|
||||
let raw_first_video_activity_s = video_segments
|
||||
.first()
|
||||
.expect("validated video segment list is not empty")
|
||||
.start_s;
|
||||
let raw_first_audio_activity_s = audio_segments
|
||||
.first()
|
||||
.expect("validated audio segment list is not empty")
|
||||
.start_s;
|
||||
let activity_start_delta_ms =
|
||||
(raw_first_audio_activity_s - raw_first_video_activity_s) * 1000.0;
|
||||
|
||||
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);
|
||||
@ -129,10 +141,6 @@ pub(crate) fn correlate_segments(
|
||||
bail!("audio onset list is empty");
|
||||
}
|
||||
|
||||
let raw_first_video_activity_s = video_onsets_s[0];
|
||||
let raw_first_audio_activity_s = audio_onsets_s[0];
|
||||
let activity_start_delta_ms =
|
||||
(raw_first_audio_activity_s - raw_first_video_activity_s) * 1000.0;
|
||||
let (video_onsets_s, audio_onsets_s, common_window) =
|
||||
trim_onsets_to_common_activity_window(&video_onsets_s, &audio_onsets_s, max_pair_gap_s);
|
||||
let expected_start_skew_ms = (audio_onsets_s[0] - video_onsets_s[0]) * 1000.0;
|
||||
@ -209,6 +217,17 @@ pub(crate) fn correlate_coded_segments(
|
||||
bail!("max pair gap must stay positive");
|
||||
}
|
||||
|
||||
let raw_first_video_activity_s = video_segments
|
||||
.first()
|
||||
.expect("validated video segment list is not empty")
|
||||
.start_s;
|
||||
let raw_first_audio_activity_s = audio_segments
|
||||
.first()
|
||||
.expect("validated audio segment list is not empty")
|
||||
.start_s;
|
||||
let activity_start_delta_ms =
|
||||
(raw_first_audio_activity_s - raw_first_video_activity_s) * 1000.0;
|
||||
|
||||
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);
|
||||
@ -229,10 +248,20 @@ pub(crate) fn correlate_coded_segments(
|
||||
.iter()
|
||||
.map(|segment| segment.start_s)
|
||||
.collect::<Vec<_>>();
|
||||
let raw_first_video_activity_s = video_onsets_s[0];
|
||||
let raw_first_audio_activity_s = audio_onsets_s[0];
|
||||
let activity_start_delta_ms =
|
||||
(raw_first_audio_activity_s - raw_first_video_activity_s) * 1000.0;
|
||||
let full_video_onsets_s = video_onsets_s.clone();
|
||||
let full_audio_onsets_s = audio_onsets_s.clone();
|
||||
let full_video_indexed = index_coded_segments_by_spacing(
|
||||
&video_segments,
|
||||
pulse_period_s,
|
||||
pulse_width_s,
|
||||
event_width_codes,
|
||||
);
|
||||
let full_audio_indexed = index_coded_segments_by_spacing(
|
||||
&audio_segments,
|
||||
pulse_period_s,
|
||||
pulse_width_s,
|
||||
event_width_codes,
|
||||
);
|
||||
let (_, _, common_window) =
|
||||
trim_onsets_to_common_activity_window(&video_onsets_s, &audio_onsets_s, max_pair_gap_s);
|
||||
let filtered_video_segments = filter_segments_to_window(&video_segments, common_window);
|
||||
@ -272,6 +301,24 @@ pub(crate) fn correlate_coded_segments(
|
||||
);
|
||||
|
||||
if pairs.len() < MIN_CODED_PAIRS {
|
||||
let diagnostic_pairs = diagnostic_coded_pairs_for_index_offsets(
|
||||
&full_video_indexed,
|
||||
&full_audio_indexed,
|
||||
&candidate_coded_index_offsets(&full_video_indexed, &full_audio_indexed),
|
||||
DIAGNOSTIC_CODED_MAX_PAIR_GAP_S,
|
||||
activity_start_delta_ms,
|
||||
);
|
||||
if diagnostic_pairs.len() >= MIN_CODED_PAIRS {
|
||||
return Ok(sync_report_from_pairs(
|
||||
&full_video_onsets_s,
|
||||
&full_audio_onsets_s,
|
||||
activity_start_delta_ms,
|
||||
raw_first_video_activity_s,
|
||||
raw_first_audio_activity_s,
|
||||
diagnostic_pairs,
|
||||
));
|
||||
}
|
||||
|
||||
if activity_start_delta_ms.abs() >= 1_000.0 {
|
||||
return correlate_segments(
|
||||
&video_segments,
|
||||
@ -283,7 +330,7 @@ pub(crate) fn correlate_coded_segments(
|
||||
);
|
||||
}
|
||||
bail!(
|
||||
"need at least {MIN_CODED_PAIRS} matching coded pulse pairs; saw {}",
|
||||
"need at least {MIN_CODED_PAIRS} matching coded pulse pairs; saw {}; raw activity delta was {activity_start_delta_ms:+.1} ms (video={raw_first_video_activity_s:.3}s audio={raw_first_audio_activity_s:.3}s)",
|
||||
pairs.len()
|
||||
);
|
||||
}
|
||||
@ -721,6 +768,51 @@ fn best_coded_pairs_for_index_offsets(
|
||||
best.map(|(_, _, _, _, pairs)| pairs).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn diagnostic_coded_pairs_for_index_offsets(
|
||||
video_indexed: &BTreeMap<i64, CodedPulseSegment>,
|
||||
audio_indexed: &BTreeMap<i64, CodedPulseSegment>,
|
||||
offset_candidates: &[i64],
|
||||
max_pair_gap_s: f64,
|
||||
expected_start_skew_ms: f64,
|
||||
) -> Vec<MatchedOnsetPair> {
|
||||
let max_pair_gap_ms = max_pair_gap_s * 1000.0;
|
||||
let mut best: Option<(f64, usize, f64, Vec<MatchedOnsetPair>)> = None;
|
||||
|
||||
for offset in offset_candidates.iter().copied() {
|
||||
let pairs = video_indexed
|
||||
.iter()
|
||||
.filter_map(|(pulse_index, video)| {
|
||||
audio_indexed
|
||||
.get(&(pulse_index + offset))
|
||||
.filter(|audio| audio.code == video.code)
|
||||
.map(|audio| {
|
||||
let skew_ms = (audio.start_s - video.start_s) * 1000.0;
|
||||
MatchedOnsetPair::new(video.start_s, audio.start_s, skew_ms, max_pair_gap_s)
|
||||
})
|
||||
})
|
||||
.filter(|pair| pair.skew_ms.abs() <= max_pair_gap_ms)
|
||||
.collect::<Vec<_>>();
|
||||
if pairs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let anchor_error_ms = (pairs[0].skew_ms - expected_start_skew_ms).abs();
|
||||
let mean_abs_skew_ms =
|
||||
pairs.iter().map(|pair| pair.skew_ms.abs()).sum::<f64>() / pairs.len() as f64;
|
||||
match &best {
|
||||
Some((best_anchor_error_ms, best_count, best_mean_abs_skew_ms, _))
|
||||
if anchor_error_ms > *best_anchor_error_ms
|
||||
|| (anchor_error_ms == *best_anchor_error_ms
|
||||
&& (pairs.len() < *best_count
|
||||
|| (pairs.len() == *best_count
|
||||
&& mean_abs_skew_ms >= *best_mean_abs_skew_ms))) => {}
|
||||
_ => best = Some((anchor_error_ms, pairs.len(), mean_abs_skew_ms, pairs)),
|
||||
}
|
||||
}
|
||||
|
||||
best.map(|(_, _, _, pairs)| pairs).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(super) fn marker_onsets(segments: &[PulseSegment], pulse_width_s: f64) -> Vec<f64> {
|
||||
let threshold = pulse_width_s * MARKER_WIDTH_MULTIPLIER;
|
||||
segments
|
||||
|
||||
@ -101,6 +101,11 @@ fn detect_color_coded_video_segments_ignores_generic_bright_changes() {
|
||||
g: 230,
|
||||
b: 118,
|
||||
},
|
||||
60..=63 => VideoColorFrame {
|
||||
r: 137,
|
||||
g: 133,
|
||||
b: 101,
|
||||
},
|
||||
_ => VideoColorFrame { r: 0, g: 0, b: 0 },
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@ -127,6 +132,21 @@ fn detect_audio_segments_keeps_regular_and_marker_durations_distinct() {
|
||||
assert!(segments[1].duration_s > segments[0].duration_s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_audio_segments_merges_short_internal_dropouts_inside_one_pulse() {
|
||||
let mut samples = vec![0i16; 48_000];
|
||||
for sample in samples.iter_mut().skip(4_800).take(5_760) {
|
||||
*sample = 18_000;
|
||||
}
|
||||
for sample in samples.iter_mut().skip(7_200).take(1_920) {
|
||||
*sample = 0;
|
||||
}
|
||||
|
||||
let segments = detect_audio_segments(&samples, 48_000, 5).expect("audio segments");
|
||||
assert_eq!(segments.len(), 1);
|
||||
assert!(segments[0].duration_s > 0.11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_video_segments_closes_a_pulse_that_stays_active_until_the_last_frame() {
|
||||
let timestamps = [0.0, 0.1, 0.2, 0.3];
|
||||
@ -330,6 +350,67 @@ fn correlate_segments_preserves_whole_period_delay_evidence() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn correlate_coded_segments_preserves_raw_activity_before_cadence_cleanup() {
|
||||
fn segment(start_s: f64, code: u32) -> PulseSegment {
|
||||
let duration_s = 0.12 * f64::from(code);
|
||||
PulseSegment {
|
||||
start_s,
|
||||
end_s: start_s + duration_s,
|
||||
duration_s,
|
||||
}
|
||||
}
|
||||
|
||||
let codes = [1, 2, 1, 3, 2, 4];
|
||||
let video = codes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(tick, code)| segment(8.3 + tick as f64, *code))
|
||||
.collect::<Vec<_>>();
|
||||
let audio = codes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(tick, code)| segment(6.7 + tick as f64, *code))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let report =
|
||||
correlate_coded_segments(&video, &audio, 1.0, 0.12, &codes, 0.5).expect("coded report");
|
||||
assert!(report.activity_start_delta_ms < -1_000.0);
|
||||
assert_eq!(report.verdict().status, "catastrophic_failure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn correlate_coded_segments_reports_large_but_decodable_skew() {
|
||||
fn segment(start_s: f64, code: u32) -> PulseSegment {
|
||||
let duration_s = 0.12 * f64::from(code);
|
||||
PulseSegment {
|
||||
start_s,
|
||||
end_s: start_s + duration_s,
|
||||
duration_s,
|
||||
}
|
||||
}
|
||||
|
||||
let codes = [1, 2, 1, 3, 2, 4, 1, 1, 3, 1, 4, 2];
|
||||
let video = codes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(tick, code)| segment(tick as f64, *code))
|
||||
.collect::<Vec<_>>();
|
||||
let audio = codes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(tick, code)| segment(tick as f64 - 0.75, *code))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let report =
|
||||
correlate_coded_segments(&video, &audio, 1.0, 0.12, &codes, 0.5).expect("coded report");
|
||||
|
||||
assert_eq!(report.paired_event_count, codes.len());
|
||||
assert!((report.activity_start_delta_ms + 750.0).abs() < 1.0);
|
||||
assert!(report.max_abs_skew_ms > 700.0);
|
||||
assert_eq!(report.verdict().status, "gross_failure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn correlate_coded_segments_matches_preserved_event_width_codes() {
|
||||
fn segment(start_s: f64, code: u32) -> PulseSegment {
|
||||
|
||||
@ -7,6 +7,7 @@ const DEFAULT_PULSE_WIDTH_S: f64 = 0.12;
|
||||
const DEFAULT_MARKER_TICK_PERIOD: u32 = 5;
|
||||
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 VERDICT_MIN_PAIRED_EVENTS: usize = 3;
|
||||
const VERDICT_PREFERRED_P95_ABS_SKEW_MS: f64 = 35.0;
|
||||
@ -160,18 +161,6 @@ impl SyncAnalysisReport {
|
||||
|
||||
#[must_use]
|
||||
pub fn calibration_recommendation(&self) -> SyncCalibrationRecommendation {
|
||||
if self.activity_start_delta_ms.abs() >= VERDICT_ACCEPTABLE_P95_ABS_SKEW_MS {
|
||||
return SyncCalibrationRecommendation {
|
||||
ready: false,
|
||||
recommended_audio_offset_adjust_us: 0,
|
||||
recommended_video_offset_adjust_us: 0,
|
||||
note: format!(
|
||||
"activity start delta {:+.1} ms exceeds the {:.1} ms calibration-safe band",
|
||||
self.activity_start_delta_ms, VERDICT_ACCEPTABLE_P95_ABS_SKEW_MS
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if self.paired_event_count < CALIBRATION_MIN_PAIRED_EVENTS {
|
||||
return SyncCalibrationRecommendation {
|
||||
ready: false,
|
||||
@ -196,6 +185,22 @@ 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 {
|
||||
return SyncCalibrationRecommendation {
|
||||
ready: false,
|
||||
recommended_audio_offset_adjust_us: 0,
|
||||
recommended_video_offset_adjust_us: 0,
|
||||
note: format!(
|
||||
"activity start delta {:+.1} ms disagrees with median skew {:+.1} ms by {:.1} ms, above the {:.1} ms calibration-safe band",
|
||||
self.activity_start_delta_ms,
|
||||
self.median_skew_ms,
|
||||
start_median_disagreement_ms.abs(),
|
||||
CALIBRATION_MAX_START_MEDIAN_DISAGREEMENT_MS
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
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 note = if self.median_skew_ms.abs() <= CALIBRATION_SETTLED_SKEW_MS {
|
||||
@ -356,6 +361,59 @@ mod tests {
|
||||
assert!(recommendation.note.contains("move median skew"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calibration_recommendation_accepts_large_stable_measured_offsets() {
|
||||
let report = SyncAnalysisReport {
|
||||
video_event_count: 16,
|
||||
audio_event_count: 16,
|
||||
paired_event_count: 16,
|
||||
activity_start_delta_ms: -766.4,
|
||||
raw_first_video_activity_s: 7.491,
|
||||
raw_first_audio_activity_s: 6.725,
|
||||
first_skew_ms: -766.4,
|
||||
last_skew_ms: -769.2,
|
||||
mean_skew_ms: -756.0,
|
||||
median_skew_ms: -766.4,
|
||||
max_abs_skew_ms: 775.7,
|
||||
drift_ms: -2.8,
|
||||
skews_ms: vec![-766.4; 16],
|
||||
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, 766_400);
|
||||
assert!(recommendation.note.contains("move median skew"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calibration_recommendation_rejects_start_delta_that_disagrees_with_pairs() {
|
||||
let report = SyncAnalysisReport {
|
||||
video_event_count: 16,
|
||||
audio_event_count: 16,
|
||||
paired_event_count: 16,
|
||||
activity_start_delta_ms: 6_735.0,
|
||||
raw_first_video_activity_s: 0.0,
|
||||
raw_first_audio_activity_s: 6.735,
|
||||
first_skew_ms: -90.0,
|
||||
last_skew_ms: -100.0,
|
||||
mean_skew_ms: -95.0,
|
||||
median_skew_ms: -99.0,
|
||||
max_abs_skew_ms: 120.0,
|
||||
drift_ms: -10.0,
|
||||
skews_ms: vec![-99.0; 16],
|
||||
video_onsets_s: vec![],
|
||||
audio_onsets_s: vec![],
|
||||
paired_events: vec![],
|
||||
};
|
||||
|
||||
let recommendation = report.calibration_recommendation();
|
||||
assert!(!recommendation.ready);
|
||||
assert!(recommendation.note.contains("disagrees with median skew"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calibration_recommendation_reports_when_skew_is_already_settled() {
|
||||
let report = SyncAnalysisReport {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.16.18"
|
||||
version = "0.16.19"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -951,7 +951,7 @@ fi
|
||||
printf 'LESAVKA_UAC_HDMI_COMPENSATION_US=%s\n' "${LESAVKA_UAC_HDMI_COMPENSATION_US:-205000}"
|
||||
printf 'LESAVKA_UAC_SESSION_CLOCK_ALIGN=%s\n' "${LESAVKA_UAC_SESSION_CLOCK_ALIGN:-0}"
|
||||
printf 'LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS=%s\n' "${LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS:-1000}"
|
||||
printf 'LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=%s\n' "${LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US:--45000}"
|
||||
printf 'LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=%s\n' "${LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US:-720000}"
|
||||
printf 'LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US=%s\n' "${LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US:-0}"
|
||||
printf 'LESAVKA_UPSTREAM_PAIR_SLACK_US=%s\n' "${LESAVKA_UPSTREAM_PAIR_SLACK_US:-80000}"
|
||||
printf 'LESAVKA_UPSTREAM_STALE_DROP_MS=%s\n' "${LESAVKA_UPSTREAM_STALE_DROP_MS:-80}"
|
||||
|
||||
@ -10,7 +10,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.16.18"
|
||||
version = "0.16.19"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -9,11 +9,12 @@ use lesavka_common::lesavka::{
|
||||
|
||||
use crate::upstream_media_runtime::UpstreamMediaRuntime;
|
||||
|
||||
pub const FACTORY_MJPEG_AUDIO_OFFSET_US: i64 = -45_000;
|
||||
pub const FACTORY_MJPEG_AUDIO_OFFSET_US: i64 = 720_000;
|
||||
pub const FACTORY_MJPEG_VIDEO_OFFSET_US: i64 = 0;
|
||||
const LEGACY_FACTORY_MJPEG_AUDIO_OFFSET_US: i64 = -45_000;
|
||||
const PROFILE: &str = "mjpeg";
|
||||
const FACTORY_CONFIDENCE: &str = "factory";
|
||||
const OFFSET_LIMIT_US: i64 = 500_000;
|
||||
const OFFSET_LIMIT_US: i64 = 1_000_000;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct CalibrationSnapshot {
|
||||
@ -42,7 +43,7 @@ impl CalibrationStore {
|
||||
let path = calibration_path();
|
||||
let state = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.map(|raw| parse_snapshot(&raw))
|
||||
.map(|raw| migrate_legacy_snapshot(parse_snapshot(&raw)))
|
||||
.unwrap_or_else(snapshot_from_env);
|
||||
runtime.set_playout_offsets(state.active_video_offset_us, state.active_audio_offset_us);
|
||||
Self {
|
||||
@ -232,6 +233,34 @@ fn parse_snapshot(raw: &str) -> CalibrationSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
fn migrate_legacy_snapshot(mut state: CalibrationSnapshot) -> CalibrationSnapshot {
|
||||
let source_allows_migration = matches!(state.source.as_str(), "factory" | "env");
|
||||
let confidence_allows_migration = matches!(state.confidence.as_str(), "factory" | "configured");
|
||||
let untouched_legacy_audio = state.default_audio_offset_us
|
||||
== LEGACY_FACTORY_MJPEG_AUDIO_OFFSET_US
|
||||
&& state.active_audio_offset_us == LEGACY_FACTORY_MJPEG_AUDIO_OFFSET_US;
|
||||
let untouched_legacy_video = state.default_video_offset_us == FACTORY_MJPEG_VIDEO_OFFSET_US
|
||||
&& state.active_video_offset_us == FACTORY_MJPEG_VIDEO_OFFSET_US;
|
||||
if state.profile == PROFILE
|
||||
&& source_allows_migration
|
||||
&& confidence_allows_migration
|
||||
&& untouched_legacy_audio
|
||||
&& untouched_legacy_video
|
||||
{
|
||||
state.default_audio_offset_us = FACTORY_MJPEG_AUDIO_OFFSET_US;
|
||||
state.active_audio_offset_us = FACTORY_MJPEG_AUDIO_OFFSET_US;
|
||||
state.source = "factory".to_string();
|
||||
state.confidence = FACTORY_CONFIDENCE.to_string();
|
||||
state.detail = format!(
|
||||
"migrated legacy MJPEG upstream A/V baseline from {:+.1}ms to {:+.1}ms",
|
||||
LEGACY_FACTORY_MJPEG_AUDIO_OFFSET_US as f64 / 1000.0,
|
||||
FACTORY_MJPEG_AUDIO_OFFSET_US as f64 / 1000.0
|
||||
);
|
||||
touch(&mut state);
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
fn persist_snapshot(path: &PathBuf, state: &CalibrationSnapshot) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
@ -289,7 +318,7 @@ mod tests {
|
||||
],
|
||||
|| {
|
||||
let state = snapshot_from_env();
|
||||
assert_eq!(state.default_audio_offset_us, -45_000);
|
||||
assert_eq!(state.default_audio_offset_us, 720_000);
|
||||
assert_eq!(state.active_video_offset_us, 0);
|
||||
assert_eq!(state.source, "factory");
|
||||
},
|
||||
@ -313,10 +342,10 @@ mod tests {
|
||||
note: String::new(),
|
||||
})
|
||||
.expect("manual adjust applies");
|
||||
assert_eq!(state.active_audio_offset_us, -50_000);
|
||||
assert_eq!(runtime.playout_offsets(), (0, -50_000));
|
||||
assert_eq!(state.active_audio_offset_us, 715_000);
|
||||
assert_eq!(runtime.playout_offsets(), (0, 715_000));
|
||||
let raw = std::fs::read_to_string(file.path()).expect("persisted calibration");
|
||||
assert!(raw.contains("active_audio_offset_us=-50000"));
|
||||
assert!(raw.contains("active_audio_offset_us=715000"));
|
||||
});
|
||||
}
|
||||
|
||||
@ -334,12 +363,12 @@ mod tests {
|
||||
fn snapshot_from_env_uses_configured_offsets_and_clamps_extremes() {
|
||||
temp_env::with_vars(
|
||||
[
|
||||
("LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US", Some("-999999")),
|
||||
("LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US", Some("-9999999")),
|
||||
("LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US", Some("12345")),
|
||||
],
|
||||
|| {
|
||||
let state = snapshot_from_env();
|
||||
assert_eq!(state.default_audio_offset_us, -500_000);
|
||||
assert_eq!(state.default_audio_offset_us, -1_000_000);
|
||||
assert_eq!(state.default_video_offset_us, 12_345);
|
||||
assert_eq!(state.source, "env");
|
||||
assert_eq!(state.confidence, "configured");
|
||||
@ -360,14 +389,14 @@ mod tests {
|
||||
profile="mjpeg"
|
||||
default_audio_offset_us=bad
|
||||
default_video_offset_us=2500
|
||||
active_audio_offset_us=-600000
|
||||
active_audio_offset_us=-1600000
|
||||
source="saved"
|
||||
detail="loaded \"quoted\" value"
|
||||
"#,
|
||||
);
|
||||
assert_eq!(state.default_audio_offset_us, FACTORY_MJPEG_AUDIO_OFFSET_US);
|
||||
assert_eq!(state.default_video_offset_us, 2_500);
|
||||
assert_eq!(state.active_audio_offset_us, -500_000);
|
||||
assert_eq!(state.active_audio_offset_us, -1_000_000);
|
||||
assert_eq!(state.active_video_offset_us, FACTORY_MJPEG_VIDEO_OFFSET_US);
|
||||
assert_eq!(state.source, "saved");
|
||||
assert_eq!(state.confidence, FACTORY_CONFIDENCE);
|
||||
@ -375,6 +404,64 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_migrates_untouched_legacy_factory_mjpeg_baseline() {
|
||||
let file = NamedTempFile::new().expect("temp calibration file");
|
||||
std::fs::write(
|
||||
file.path(),
|
||||
r#"
|
||||
profile="mjpeg"
|
||||
default_audio_offset_us=-45000
|
||||
default_video_offset_us=0
|
||||
active_audio_offset_us=-45000
|
||||
active_video_offset_us=0
|
||||
source="env"
|
||||
confidence="configured"
|
||||
detail="loaded upstream A/V calibration defaults"
|
||||
"#,
|
||||
)
|
||||
.expect("legacy calibration seed");
|
||||
let path = file.path().to_string_lossy().to_string();
|
||||
temp_env::with_var("LESAVKA_CALIBRATION_PATH", Some(path.as_str()), || {
|
||||
let runtime = Arc::new(UpstreamMediaRuntime::new());
|
||||
let store = CalibrationStore::load(runtime.clone());
|
||||
let state = store.current();
|
||||
assert_eq!(state.active_audio_offset_us, 720_000);
|
||||
assert_eq!(state.default_audio_offset_us, 720_000);
|
||||
assert_eq!(state.source, "factory");
|
||||
assert_eq!(runtime.playout_offsets(), (0, 720_000));
|
||||
assert!(state.detail.contains("migrated legacy MJPEG"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_keeps_manual_legacy_sized_calibration() {
|
||||
let file = NamedTempFile::new().expect("temp calibration file");
|
||||
std::fs::write(
|
||||
file.path(),
|
||||
r#"
|
||||
profile="mjpeg"
|
||||
default_audio_offset_us=-45000
|
||||
default_video_offset_us=0
|
||||
active_audio_offset_us=-45000
|
||||
active_video_offset_us=0
|
||||
source="manual"
|
||||
confidence="manual"
|
||||
detail="operator-set"
|
||||
"#,
|
||||
)
|
||||
.expect("manual calibration seed");
|
||||
let path = file.path().to_string_lossy().to_string();
|
||||
temp_env::with_var("LESAVKA_CALIBRATION_PATH", Some(path.as_str()), || {
|
||||
let runtime = Arc::new(UpstreamMediaRuntime::new());
|
||||
let store = CalibrationStore::load(runtime.clone());
|
||||
let state = store.current();
|
||||
assert_eq!(state.active_audio_offset_us, -45_000);
|
||||
assert_eq!(state.source, "manual");
|
||||
assert_eq!(runtime.playout_offsets(), (0, -45_000));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_applies_all_calibration_actions_and_persists_defaults() {
|
||||
let dir = tempfile::tempdir().expect("calibration dir");
|
||||
@ -399,7 +486,7 @@ mod tests {
|
||||
.expect("blind estimate");
|
||||
assert_eq!(blind.source, "blind");
|
||||
assert!(blind.detail.contains("delivery skew 44.0ms"));
|
||||
assert_eq!(runtime.playout_offsets(), (-2_000, -40_000));
|
||||
assert_eq!(runtime.playout_offsets(), (-2_000, 725_000));
|
||||
|
||||
let manual = store
|
||||
.apply(CalibrationRequest {
|
||||
@ -411,7 +498,7 @@ mod tests {
|
||||
note: String::new(),
|
||||
})
|
||||
.expect("manual clamp");
|
||||
assert_eq!(manual.active_audio_offset_us, 500_000);
|
||||
assert_eq!(manual.active_audio_offset_us, 1_000_000);
|
||||
|
||||
let saved = store
|
||||
.apply(CalibrationRequest {
|
||||
|
||||
@ -43,7 +43,7 @@ fn upstream_playout_offsets_default_to_mjpeg_calibration_and_accept_overrides()
|
||||
temp_env::with_var_unset("LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US", || {
|
||||
assert_eq!(
|
||||
super::upstream_playout_offset_us(UpstreamMediaKind::Microphone),
|
||||
-45_000
|
||||
720_000
|
||||
);
|
||||
assert_eq!(
|
||||
super::upstream_playout_offset_us(UpstreamMediaKind::Camera),
|
||||
|
||||
@ -50,7 +50,7 @@ fn server_install_pins_hdmi_camera_and_display_defaults() {
|
||||
assert!(SERVER_INSTALL.contains("${LESAVKA_HDMI_HEIGHT:-1080}"));
|
||||
assert!(SERVER_INSTALL.contains("${LESAVKA_HDMI_SINK:-fbdevsink}"));
|
||||
assert!(SERVER_INSTALL.contains("${LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS:-1000}"));
|
||||
assert!(SERVER_INSTALL.contains("${LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US:--45000}"));
|
||||
assert!(SERVER_INSTALL.contains("${LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US:-720000}"));
|
||||
assert!(SERVER_INSTALL.contains("${LESAVKA_UPSTREAM_PAIR_SLACK_US:-80000}"));
|
||||
assert!(SERVER_INSTALL.contains("${LESAVKA_UPSTREAM_STALE_DROP_MS:-80}"));
|
||||
assert!(SERVER_INSTALL.contains("${LESAVKA_INSTALL_SERVER_BIND_ADDR:-0.0.0.0:50051}"));
|
||||
|
||||
@ -467,7 +467,7 @@ mod server_main_rpc {
|
||||
.expect("initial calibration")
|
||||
.into_inner();
|
||||
assert_eq!(initial.profile, "mjpeg");
|
||||
assert_eq!(initial.active_audio_offset_us, -45_000);
|
||||
assert_eq!(initial.active_audio_offset_us, 720_000);
|
||||
|
||||
let adjusted = rt
|
||||
.block_on(async {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user