From c960df7400883a856eca16beb095dc5fc6209b1f Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Fri, 1 May 2026 16:57:55 -0300 Subject: [PATCH] sync: recalibrate fresh uac to uvc timing --- AGENTS.md | 5 +- Cargo.lock | 6 +- client/Cargo.toml | 2 +- client/src/launcher/state/selection_models.rs | 6 +- client/src/launcher/tests/state.rs | 6 +- common/Cargo.toml | 2 +- scripts/install/server.sh | 9 +-- server/Cargo.toml | 2 +- server/src/calibration.rs | 67 ++++++++++++++----- .../upstream_media_runtime/tests/config.rs | 2 +- .../tests/server_install_script_contract.rs | 4 +- testing/tests/server_main_rpc_contract.rs | 2 +- 12 files changed, 74 insertions(+), 39 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a72ea5e..9c83a14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,8 +140,8 @@ 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] Raise calibration offset limits enough to cover the measured MJPEG/UVC path delta without rejecting probe corrections. +- [x] Update the MJPEG/UVC factory audio baseline from the old `-45ms`/`+720ms` values to `+1260ms` as the mirrored probe exposes the fresh UAC-vs-UVC path delta. - [x] Migrate untouched legacy `-45ms` factory/env calibration files on load so old installs actually receive the new baseline. - [x] Make the video/audio-master wait offset-aware so a positive audio playout delay does not freeze UVC video while UAC sleeps before emission. - [ ] Flush/stop UAC cleanly on session close, replacement, and recovery. @@ -176,5 +176,6 @@ Context: the mirrored browser probe finally reproduced the real failure class on - 0.16.23 local validation passed for fresh-queue behavior, uplink/probe freshness contracts, sync analyzer tests, client/server binary checks, and whitespace checks. - 0.16.23 live mirrored run improved to p95 `215.2 ms`, median `+142.2 ms`, 13 paired coded pulses, and raw activity alignment within `6.6 ms` of coded pairs. Patch 0.16.24 makes the probe print local client and remote server versions before capture so every run records what was actually tested. - 0.16.24 live mirrored run improved again to p95 `168.4 ms`, median `-19.1 ms`, 11 paired coded pulses, but still failed because individual paired pulses bounced between about `-168 ms` and `+45 ms`. Client logs showed the microphone uplink queue still accumulating depth `16`; patch 0.16.25 makes microphone uplink queues latest-only too so stale audio PTS cannot continue acting as the server timing master under backpressure. + - 0.16.25 removed the client mic backlog but exposed a stable hardware/browser path delta: p95 `557.3 ms`, median `-540.5 ms`, drift `+9.0 ms`, and fresh mic delivery ages around `2-10 ms`. Patch 0.16.26 raises the MJPEG/UVC factory audio delay to `+1260 ms` and expands the calibration clamp so this stable offset can actually be corrected instead of rejected. - [ ] 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 cf202ca..744b9b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.16.25" +version = "0.16.26" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.16.25" +version = "0.16.26" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.16.25" +version = "0.16.26" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index aa6297a..55e48ea 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.16.25" +version = "0.16.26" edition = "2024" [dependencies] diff --git a/client/src/launcher/state/selection_models.rs b/client/src/launcher/state/selection_models.rs index ceab3af..9ddc083 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: 720_000, + factory_audio_offset_us: 1_260_000, factory_video_offset_us: 0, - default_audio_offset_us: 720_000, + default_audio_offset_us: 1_260_000, default_video_offset_us: 0, - active_audio_offset_us: 720_000, + active_audio_offset_us: 1_260_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 7cfcb07..af82c6a 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, 720_000); + assert_eq!(state.calibration.active_audio_offset_us, 1_260_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: 720_000, + factory_audio_offset_us: 1_260_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, 720_000); + assert_eq!(state.calibration.factory_audio_offset_us, 1_260_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/common/Cargo.toml b/common/Cargo.toml index 40b43b5..d578499 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.16.25" +version = "0.16.26" edition = "2024" build = "build.rs" diff --git a/scripts/install/server.sh b/scripts/install/server.sh index 18c218c..c75cb03 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -14,8 +14,9 @@ INSTALL_UVC_CODEC=${LESAVKA_INSTALL_UVC_CODEC:-mjpeg} INSTALL_SERVER_BIND_ADDR=${LESAVKA_INSTALL_SERVER_BIND_ADDR:-0.0.0.0:50051} LESAVKA_TLS_DIR=${LESAVKA_TLS_DIR:-/etc/lesavka/pki} LESAVKA_CLIENT_BUNDLE=${LESAVKA_CLIENT_BUNDLE:-/etc/lesavka/lesavka-client-pki.tar.gz} -DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=720000 +DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=1260000 LEGACY_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=-45000 +PREVIOUS_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=720000 resolve_upstream_audio_playout_offset_us() { if [[ -n ${LESAVKA_INSTALL_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US:-} ]]; then @@ -23,9 +24,9 @@ resolve_upstream_audio_playout_offset_us() { return 0 fi - if [[ ${LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US:-} == "$LEGACY_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US" ]]; then - echo "⚠️ migrating legacy upstream audio playout offset -45ms to +720ms for MJPEG/UVC." >&2 - echo " Use LESAVKA_INSTALL_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=-45000 only if you intentionally need the old value." >&2 + if [[ ${LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US:-} == "$LEGACY_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US" || ${LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US:-} == "$PREVIOUS_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US" ]]; then + echo "⚠️ migrating stale upstream audio playout offset to +1260ms for MJPEG/UVC." >&2 + echo " Use LESAVKA_INSTALL_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US to intentionally keep an older value." >&2 printf '%s\n' "$DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US" return 0 fi diff --git a/server/Cargo.toml b/server/Cargo.toml index ab42874..7450d06 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.16.25" +version = "0.16.26" edition = "2024" autobins = false diff --git a/server/src/calibration.rs b/server/src/calibration.rs index e03742c..78a9588 100644 --- a/server/src/calibration.rs +++ b/server/src/calibration.rs @@ -9,12 +9,13 @@ use lesavka_common::lesavka::{ use crate::upstream_media_runtime::UpstreamMediaRuntime; -pub const FACTORY_MJPEG_AUDIO_OFFSET_US: i64 = 720_000; +pub const FACTORY_MJPEG_AUDIO_OFFSET_US: i64 = 1_260_000; pub const FACTORY_MJPEG_VIDEO_OFFSET_US: i64 = 0; const LEGACY_FACTORY_MJPEG_AUDIO_OFFSET_US: i64 = -45_000; +const PREVIOUS_FACTORY_MJPEG_AUDIO_OFFSET_US: i64 = 720_000; const PROFILE: &str = "mjpeg"; const FACTORY_CONFIDENCE: &str = "factory"; -const OFFSET_LIMIT_US: i64 = 1_000_000; +const OFFSET_LIMIT_US: i64 = 1_500_000; #[derive(Debug, Clone, PartialEq, Eq)] struct CalibrationSnapshot { @@ -236,9 +237,10 @@ 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_audio = matches!( + state.default_audio_offset_us, + LEGACY_FACTORY_MJPEG_AUDIO_OFFSET_US | PREVIOUS_FACTORY_MJPEG_AUDIO_OFFSET_US + ) && state.active_audio_offset_us == state.default_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 @@ -247,13 +249,14 @@ fn migrate_legacy_snapshot(mut state: CalibrationSnapshot) -> CalibrationSnapsho && untouched_legacy_audio && untouched_legacy_video { + let old_audio_offset_us = state.default_audio_offset_us; 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, + old_audio_offset_us as f64 / 1000.0, FACTORY_MJPEG_AUDIO_OFFSET_US as f64 / 1000.0 ); touch(&mut state); @@ -318,7 +321,7 @@ mod tests { ], || { let state = snapshot_from_env(); - assert_eq!(state.default_audio_offset_us, 720_000); + assert_eq!(state.default_audio_offset_us, 1_260_000); assert_eq!(state.active_video_offset_us, 0); assert_eq!(state.source, "factory"); }, @@ -342,10 +345,10 @@ mod tests { note: String::new(), }) .expect("manual adjust applies"); - assert_eq!(state.active_audio_offset_us, 715_000); - assert_eq!(runtime.playout_offsets(), (0, 715_000)); + assert_eq!(state.active_audio_offset_us, 1_255_000); + assert_eq!(runtime.playout_offsets(), (0, 1_255_000)); let raw = std::fs::read_to_string(file.path()).expect("persisted calibration"); - assert!(raw.contains("active_audio_offset_us=715000")); + assert!(raw.contains("active_audio_offset_us=1255000")); }); } @@ -368,7 +371,7 @@ mod tests { ], || { let state = snapshot_from_env(); - assert_eq!(state.default_audio_offset_us, -1_000_000); + assert_eq!(state.default_audio_offset_us, -1_500_000); assert_eq!(state.default_video_offset_us, 12_345); assert_eq!(state.source, "env"); assert_eq!(state.confidence, "configured"); @@ -396,7 +399,7 @@ mod tests { ); 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, -1_000_000); + assert_eq!(state.active_audio_offset_us, -1_500_000); assert_eq!(state.active_video_offset_us, FACTORY_MJPEG_VIDEO_OFFSET_US); assert_eq!(state.source, "saved"); assert_eq!(state.confidence, FACTORY_CONFIDENCE); @@ -426,14 +429,44 @@ mod tests { 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.active_audio_offset_us, 1_260_000); + assert_eq!(state.default_audio_offset_us, 1_260_000); assert_eq!(state.source, "factory"); - assert_eq!(runtime.playout_offsets(), (0, 720_000)); + assert_eq!(runtime.playout_offsets(), (0, 1_260_000)); assert!(state.detail.contains("migrated legacy MJPEG")); }); } + #[test] + fn load_migrates_untouched_previous_factory_mjpeg_baseline() { + let file = NamedTempFile::new().expect("temp calibration file"); + std::fs::write( + file.path(), + r#" + profile="mjpeg" + default_audio_offset_us=720000 + default_video_offset_us=0 + active_audio_offset_us=720000 + active_video_offset_us=0 + source="env" + confidence="configured" + detail="loaded upstream A/V calibration defaults" + "#, + ) + .expect("previous 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, 1_260_000); + assert_eq!(state.default_audio_offset_us, 1_260_000); + assert_eq!(state.source, "factory"); + assert_eq!(runtime.playout_offsets(), (0, 1_260_000)); + assert!(state.detail.contains("from +720.0ms to +1260.0ms")); + }); + } + #[test] fn load_keeps_manual_legacy_sized_calibration() { let file = NamedTempFile::new().expect("temp calibration file"); @@ -486,7 +519,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, 725_000)); + assert_eq!(runtime.playout_offsets(), (-2_000, 1_265_000)); let manual = store .apply(CalibrationRequest { @@ -498,7 +531,7 @@ mod tests { note: String::new(), }) .expect("manual clamp"); - assert_eq!(manual.active_audio_offset_us, 1_000_000); + assert_eq!(manual.active_audio_offset_us, 1_500_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 e772392..f660004 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), - 720_000 + 1_260_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 6ba8eaf..da634d7 100644 --- a/testing/tests/server_install_script_contract.rs +++ b/testing/tests/server_install_script_contract.rs @@ -50,14 +50,14 @@ 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("DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=720000")); + assert!(SERVER_INSTALL.contains("DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=1260000")); assert!(SERVER_INSTALL.contains("LEGACY_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=-45000")); assert!( SERVER_INSTALL.contains("LESAVKA_INSTALL_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US"), "install-specific offset override should bypass stale ambient runtime env" ); assert!( - SERVER_INSTALL.contains("migrating legacy upstream audio playout offset -45ms to +720ms"), + SERVER_INSTALL.contains("migrating stale upstream audio playout offset to +1260ms"), "installer should not preserve the old MJPEG/UVC sync baseline accidentally" ); assert!(SERVER_INSTALL.contains("${LESAVKA_UPSTREAM_PAIR_SLACK_US:-80000}")); diff --git a/testing/tests/server_main_rpc_contract.rs b/testing/tests/server_main_rpc_contract.rs index 29d534b..4f35771 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, 720_000); + assert_eq!(initial.active_audio_offset_us, 1_260_000); let adjusted = rt .block_on(async {