diff --git a/AGENTS.md b/AGENTS.md index 6a990f7..487b5bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock index 3d48442..dc09e26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/client/Cargo.toml b/client/Cargo.toml index 499e652..a675004 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.16.18" +version = "0.16.19" edition = "2024" [dependencies] diff --git a/client/src/launcher/state/selection_models.rs b/client/src/launcher/state/selection_models.rs index cb6961f..ceab3af 100644 --- a/client/src/launcher/state/selection_models.rs +++ b/client/src/launcher/state/selection_models.rs @@ -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(), diff --git a/client/src/launcher/tests/state.rs b/client/src/launcher/tests/state.rs index 7daa290..7cfcb07 100644 --- a/client/src/launcher/tests/state.rs +++ b/client/src/launcher/tests/state.rs @@ -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); diff --git a/client/src/sync_probe/analyze/onset_detection.rs b/client/src/sync_probe/analyze/onset_detection.rs index b2a87dc..2daa81d 100644 --- a/client/src/sync_probe/analyze/onset_detection.rs +++ b/client/src/sync_probe/analyze/onset_detection.rs @@ -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) -> Vec { + let mut merged = Vec::::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, 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 { diff --git a/client/src/sync_probe/analyze/onset_detection/correlation.rs b/client/src/sync_probe/analyze/onset_detection/correlation.rs index 757ad1f..016c1df 100644 --- a/client/src/sync_probe/analyze/onset_detection/correlation.rs +++ b/client/src/sync_probe/analyze/onset_detection/correlation.rs @@ -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::>(); - 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, + audio_indexed: &BTreeMap, + offset_candidates: &[i64], + max_pair_gap_s: f64, + expected_start_skew_ms: f64, +) -> Vec { + let max_pair_gap_ms = max_pair_gap_s * 1000.0; + let mut best: Option<(f64, usize, f64, Vec)> = 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::>(); + 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::() / 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 { let threshold = pulse_width_s * MARKER_WIDTH_MULTIPLIER; segments diff --git a/client/src/sync_probe/analyze/onset_detection/tests.rs b/client/src/sync_probe/analyze/onset_detection/tests.rs index 1eb176b..5c1e0ae 100644 --- a/client/src/sync_probe/analyze/onset_detection/tests.rs +++ b/client/src/sync_probe/analyze/onset_detection/tests.rs @@ -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::>(); @@ -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::>(); + let audio = codes + .iter() + .enumerate() + .map(|(tick, code)| segment(6.7 + tick as f64, *code)) + .collect::>(); + + 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::>(); + let audio = codes + .iter() + .enumerate() + .map(|(tick, code)| segment(tick as f64 - 0.75, *code)) + .collect::>(); + + 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 { diff --git a/client/src/sync_probe/analyze/report.rs b/client/src/sync_probe/analyze/report.rs index f024635..d92f9c0 100644 --- a/client/src/sync_probe/analyze/report.rs +++ b/client/src/sync_probe/analyze/report.rs @@ -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 { diff --git a/common/Cargo.toml b/common/Cargo.toml index 24b0a13..c372560 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.16.18" +version = "0.16.19" edition = "2024" build = "build.rs" diff --git a/scripts/install/server.sh b/scripts/install/server.sh index a46863f..0cafab4 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -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}" diff --git a/server/Cargo.toml b/server/Cargo.toml index 84bb335..80b6349 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.16.18" +version = "0.16.19" edition = "2024" autobins = false diff --git a/server/src/calibration.rs b/server/src/calibration.rs index 01f2de0..e03742c 100644 --- a/server/src/calibration.rs +++ b/server/src/calibration.rs @@ -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 { diff --git a/server/src/upstream_media_runtime/tests/config.rs b/server/src/upstream_media_runtime/tests/config.rs index 9b6fddd..e772392 100644 --- a/server/src/upstream_media_runtime/tests/config.rs +++ b/server/src/upstream_media_runtime/tests/config.rs @@ -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), diff --git a/testing/tests/server_install_script_contract.rs b/testing/tests/server_install_script_contract.rs index c9bc7c0..b94caa9 100644 --- a/testing/tests/server_install_script_contract.rs +++ b/testing/tests/server_install_script_contract.rs @@ -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}")); diff --git a/testing/tests/server_main_rpc_contract.rs b/testing/tests/server_main_rpc_contract.rs index 782e8a0..29d534b 100644 --- a/testing/tests/server_main_rpc_contract.rs +++ b/testing/tests/server_main_rpc_contract.rs @@ -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 {