sync: calibrate mirrored upstream av baseline

This commit is contained in:
Brad Stein 2026-05-01 13:35:59 -03:00
parent add8d66c98
commit c348597ea1
16 changed files with 394 additions and 52 deletions

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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