From e73e7f0a0f299d506edd04ef62ba193c68141f71 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Fri, 1 May 2026 14:24:38 -0300 Subject: [PATCH] sync: keep uvc flowing with delayed audio --- AGENTS.md | 3 ++ Cargo.lock | 6 ++-- client/Cargo.toml | 2 +- common/Cargo.toml | 2 +- server/Cargo.toml | 2 +- server/src/upstream_media_runtime.rs | 14 +++++++- .../tests/async_wait.rs | 34 +++++++++++++++++-- 7 files changed, 53 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4484ce1..961ae89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,6 +140,7 @@ Context: the mirrored browser probe finally reproduced the real failure class on - [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. +- [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. - [x] Add tests or contract coverage for bounded UAC settings where practical. @@ -165,5 +166,7 @@ Context: the mirrored browser probe finally reproduced the real failure class on - 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. - 0.16.19 mirrored browser probe did not move the measured skew: p95 `885.7 ms`, median `-788.4 ms`, activity start `-659.1 ms`, drift `-81.2 ms`. SSH inspection showed Theia was on commit `c348597`, but `/etc/lesavka/server.env` still contained `LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=-45000`; the new `+720ms` baseline was not actually installed. Patch the installer to migrate leaked legacy ambient `-45000` to `+720000` unless `LESAVKA_INSTALL_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US` explicitly asks for the legacy value. + - 0.16.20 installed the `+720ms` offset (`/etc/lesavka/server.env` had `LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=720000`), but the mirrored browser capture contained no recognizable color pulses. Theia server logs showed repeated `upstream video frame dropped because the audio master never caught up inside the pairing window`; UVC was effectively starved by the positive audio delay instead of flowing delayed-but-fresh frames. + - 0.16.21 makes that wait offset-aware and adds a regression test proving a configured positive audio delay does not freeze UVC video while UAC sleeps before playout. - [ ] 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 fbc7913..10ef9b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.16.20" +version = "0.16.21" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.16.20" +version = "0.16.21" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.16.20" +version = "0.16.21" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index 9918323..113d42d 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.16.20" +version = "0.16.21" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index ee2b110..9644d38 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.16.20" +version = "0.16.21" edition = "2024" build = "build.rs" diff --git a/server/Cargo.toml b/server/Cargo.toml index d41958f..27a4cf2 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.16.20" +version = "0.16.21" edition = "2024" autobins = false diff --git a/server/src/upstream_media_runtime.rs b/server/src/upstream_media_runtime.rs index 1c8e168..0cc77ee 100644 --- a/server/src/upstream_media_runtime.rs +++ b/server/src/upstream_media_runtime.rs @@ -87,6 +87,14 @@ impl UpstreamMediaRuntime { } } } + + fn positive_audio_delay_allowance_us(&self) -> u64 { + let camera_offset_us = self.camera_playout_offset_us.load(Ordering::Relaxed); + let microphone_offset_us = self.microphone_playout_offset_us.load(Ordering::Relaxed); + microphone_offset_us + .saturating_sub(camera_offset_us) + .max(0) as u64 + } } include!("upstream_media_runtime/lease_lifecycle.rs"); @@ -132,6 +140,7 @@ impl UpstreamMediaRuntime { let slack_us = upstream_pairing_master_slack() .as_micros() .min(u64::MAX as u128) as u64; + let audio_delay_allowance_us = self.positive_audio_delay_allowance_us(); loop { let notified = self.audio_progress_notify.notified(); { @@ -143,7 +152,10 @@ impl UpstreamMediaRuntime { return true; } if state.last_audio_local_pts_us.is_some_and(|audio_pts_us| { - audio_pts_us.saturating_add(slack_us) >= video_local_pts_us + audio_pts_us + .saturating_add(slack_us) + .saturating_add(audio_delay_allowance_us) + >= video_local_pts_us }) { return true; } diff --git a/server/src/upstream_media_runtime/tests/async_wait.rs b/server/src/upstream_media_runtime/tests/async_wait.rs index 9977974..00c96f4 100644 --- a/server/src/upstream_media_runtime/tests/async_wait.rs +++ b/server/src/upstream_media_runtime/tests/async_wait.rs @@ -1,4 +1,4 @@ -use super::{UpstreamMediaRuntime, play}; +use super::{UpstreamMediaRuntime, play, runtime_without_offsets}; use serial_test::serial; use std::sync::Arc; use std::time::Duration; @@ -6,7 +6,7 @@ use std::time::Duration; #[tokio::test(flavor = "current_thread")] #[serial(upstream_media_runtime)] async fn wait_for_audio_master_releases_video_once_audio_catches_up() { - let runtime = Arc::new(UpstreamMediaRuntime::new()); + let runtime = Arc::new(runtime_without_offsets()); let _camera = runtime.activate_camera(); let _microphone = runtime.activate_microphone(); @@ -34,8 +34,36 @@ async fn wait_for_audio_master_releases_video_once_audio_catches_up() { #[tokio::test(flavor = "current_thread")] #[serial(upstream_media_runtime)] -async fn wait_for_audio_master_times_out_when_audio_never_catches_up() { +async fn wait_for_audio_master_allows_configured_positive_audio_delay() { let runtime = Arc::new(UpstreamMediaRuntime::new()); + runtime.set_playout_offsets(0, 720_000); + let _camera = runtime.activate_camera(); + let _microphone = runtime.activate_microphone(); + + assert!(matches!( + runtime.plan_video_pts(1_000_000, 16_666), + super::UpstreamPlanDecision::AwaitingPair + )); + let _audio_first = play(runtime.plan_audio_pts(1_000_000)); + let _video_first = play(runtime.plan_video_pts(1_000_000, 16_666)); + let delayed_video = play(runtime.plan_video_pts(1_700_000, 16_666)); + + assert_eq!(delayed_video.local_pts_us, 700_000); + assert!( + runtime + .wait_for_audio_master( + delayed_video.local_pts_us, + tokio::time::Instant::now() + Duration::from_millis(5) + ) + .await, + "a positive audio playout offset should not freeze UVC video while the audio sink sleeps" + ); +} + +#[tokio::test(flavor = "current_thread")] +#[serial(upstream_media_runtime)] +async fn wait_for_audio_master_times_out_when_audio_never_catches_up() { + let runtime = Arc::new(runtime_without_offsets()); let _camera = runtime.activate_camera(); let _microphone = runtime.activate_microphone();