feat: rebuild upstream media v2

This commit is contained in:
Brad Stein 2026-05-03 12:22:33 -03:00
parent 248f1b7a47
commit 3011dabc92
22 changed files with 2114 additions and 1235 deletions

View File

@ -1,42 +1,41 @@
# Lesavka Agent Notes
## 0.18.5 Bundled Webcam A/V Migration Checklist
## 0.19.0 Upstream Media v2 Rebuild Checklist
Context: manual Google Meet and mirrored-probe testing showed the split webcam
and microphone uplink design is too fragile under real browser/device pressure.
The new product contract is: when webcam video is present, microphone audio
travels with it on one client-owned upstream media stream. The server manages
freshness and smoothness after arrival; it no longer tries to make two racing
upstream channels look synchronized. Microphone-only remains supported as the
explicit no-camera path.
Context: manual Google Meet testing showed the 0.18.x upstream media stack was
still capable of seconds-scale lag and A/V skew. Treat that implementation as
quarantined v1, not as something to tune. The v2 contract is deliberately small:
when webcam video is active, microphone audio and camera frames travel through
one client-owned bundle path; the server maps that client capture clock onto one
local epoch, applies only explicit UVC/UAC output-path offsets, and drops stale
bundles as a unit. Microphone-only remains supported as the explicit no-camera
path.
### Product Invariants
- [x] Webcam-enabled sessions use one bundled upstream media RPC by default.
- [x] Webcam-enabled sessions imply microphone capture when the server supports UAC.
- [x] The previous upstream media runtime/planner is quarantined under
`quarantine/upstream-media-v1/` with retained-idea notes.
- [x] The UI-selected camera, camera quality, microphone, speaker, gain, and
enable switches remain authoritative; defaults may not override visible UI state.
- [x] Client capture timestamps are the source of A/V sync truth for webcam sessions.
- [x] Server bundled playout rebases that client timeline onto a fresh local epoch.
- [x] Server bundled playout may drop stale packets, but must not rebuild sync by
independently pairing separate camera and microphone streams.
- [x] Mic-only sessions keep the existing microphone stream path.
- [x] Server v2 playout rebases that client timeline onto a fresh local epoch.
- [x] Server v2 may drop stale bundles, but must not rebuild sync by independently
pairing separate camera and microphone streams.
- [x] Mic-only sessions keep an explicit no-camera audio path.
- [x] Legacy split webcam/mic uplink is only an explicit compatibility escape hatch.
- [x] Manual probes and diagnostics clearly label `bundled-webcam-media` versus
`mic-only` so we never confuse the architectures during debugging.
- [x] Sync protection takes precedence over freshness and smoothness: bad mixed
bundle timing is dropped coherently instead of letting one side play alone.
- [x] Startup video is allowed to prime UVC if a first mixed bundle has bad
audio/video timing; the mismatched audio is dropped, preserving sync while
avoiding browser `Camera is starting` starvation.
- [x] An already-attached UVC gadget descriptor is the physical browser contract:
if it still advertises an older profile, server handshake/capture sizing
follows that live descriptor until a controlled gadget rebuild is allowed.
- [x] Bundled webcam sessions enforce freshness before output-path compensation;
sync-critical measured UVC/UAC offsets are allowed to add presentation
delay because audio/video sync is higher priority than freshness.
- [x] Bundled webcam sessions use the shared client capture timeline for
transit sync, then apply runtime output-path calibration when splitting
into UVC/UAC so Meet sees synchronized presentation.
- [x] Bundled webcam sessions use the shared client capture timeline for transit
sync, then apply runtime output-path calibration as explicit per-device
handoff delays.
- [x] Optional common playout delay is only smoothness slack; it cannot clip or
replace sync-critical UVC/UAC offsets.
### Wire Protocol
- [x] Add `UpstreamMediaBundle` containing one optional video frame plus zero or
@ -52,6 +51,12 @@ explicit no-camera path.
- [x] Spawn one bundled capture/uplink task instead of separate camera and mic
tasks for webcam sessions.
- [x] Bundle camera frames and microphone packets into one freshness-bounded queue.
- [x] With a camera active, do not flush microphone packets as standalone upstream
bundles; trim old pending audio and emit with a video frame instead.
- [x] Add a short video/audio grace window so audio captured beside a frame has
a chance to join that frame's bundle before uplink.
- [x] Keep the microphone-only RPC running as the no-camera path even when the
server supports bundled webcam media; it yields while camera is active.
- [x] Bound the pre-bundle capture handoff channel so camera/mic workers drop
old events under pressure instead of building unbounded latency.
- [x] Drop lag-clamped camera and microphone source buffers before bundling;
@ -70,15 +75,15 @@ explicit no-camera path.
for one upstream session.
- [x] Schedule bundled packets by shared client capture timestamp instead of
startup-pairing independent streams.
- [x] Replace the old bundled event sorter/reanchor planner with one v2 bundle
clock and explicit per-device handoff scheduling.
- [x] Sanitize packet timestamps before bundling so stale/future source PTS values
cannot become the server's A/V sync truth.
- [x] Make server bundled scheduling use the client capture sidecar rather than
raw packet `pts`, and reset the bundled epoch on client-session changes.
- [x] Keep server freshness drops/reanchors active for bundled media.
- [x] Drop mixed A/V bundles coherently when one side fails freshness/sync planning.
- [x] Reanchor bundled playout when due times drift too far before output-path
compensation, and drop packets whose predicted pre-compensation playout
age would exceed the one-second freshness budget.
- [x] Drop bundles coherently when they are already outside the live age budget.
- [x] Drop mixed A/V bundles coherently when capture timestamps are too far apart
to represent one real capture moment.
- [x] Keep bundled UVC/UAC output-path compensation authoritative; do not clip
measured offsets just to improve freshness when that would break sync.
- [x] Activate the camera relay before opening the microphone sink so UVC can

6
Cargo.lock generated
View File

@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lesavka_client"
version = "0.18.5"
version = "0.19.0"
dependencies = [
"anyhow",
"async-stream",
@ -1686,7 +1686,7 @@ dependencies = [
[[package]]
name = "lesavka_common"
version = "0.18.5"
version = "0.19.0"
dependencies = [
"anyhow",
"base64",
@ -1698,7 +1698,7 @@ dependencies = [
[[package]]
name = "lesavka_server"
version = "0.18.5"
version = "0.19.0"
dependencies = [
"anyhow",
"base64",

View File

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

View File

@ -297,7 +297,7 @@ impl LesavkaClientApp {
));
}
}
if microphone_available && !bundled_webcam_media {
if microphone_available {
let ep = vid_ep.clone();
let mic_telemetry =
uplink_telemetry.handle(crate::uplink_telemetry::UpstreamStreamKind::Microphone);
@ -307,6 +307,7 @@ impl LesavkaClientApp {
initial_mic_source.clone(),
mic_telemetry,
media_controls,
bundled_webcam_media,
));
}

View File

@ -17,10 +17,8 @@ impl LesavkaClientApp {
loop {
let state = media_controls.refresh();
let camera_requested = state.camera;
let microphone_requested = state.microphone || state.camera;
if !camera_requested && !microphone_requested {
if !camera_requested {
camera_telemetry.record_enabled(false);
microphone_telemetry.record_enabled(false);
tokio::time::sleep(Duration::from_millis(100)).await;
continue;
}
@ -244,6 +242,7 @@ impl LesavkaClientApp {
event_rx,
stop,
queue,
camera.is_some(),
camera_telemetry,
microphone_telemetry,
drop_log,
@ -298,12 +297,17 @@ impl LesavkaClientApp {
initial_source: Option<String>,
telemetry: crate::uplink_telemetry::UplinkTelemetryHandle,
media_controls: crate::live_media_control::LiveMediaControls,
pause_when_camera_active: bool,
) {
let mut delay = Duration::from_secs(1);
static FAIL_CNT: AtomicUsize = AtomicUsize::new(0);
loop {
let state = media_controls.refresh();
if pause_when_camera_active && state.camera {
tokio::time::sleep(Duration::from_millis(100)).await;
continue;
}
if !state.microphone {
telemetry.record_enabled(false);
tokio::time::sleep(Duration::from_millis(100)).await;
@ -410,6 +414,12 @@ impl LesavkaClientApp {
let desired_source = state
.microphone_source
.resolve(initial_source_thread.as_deref());
if pause_when_camera_active && state.camera {
tracing::info!(
"🎤 microphone-only uplink yielding to bundled webcam A/V"
);
break;
}
if desired_source != active_source_thread {
tracing::info!(
from = active_source_thread.as_deref().unwrap_or("auto"),
@ -780,8 +790,8 @@ const AUDIO_UPLINK_QUEUE: crate::uplink_fresh_queue::FreshQueueConfig =
#[cfg(not(coverage))]
const BUNDLED_MEDIA_UPLINK_QUEUE: crate::uplink_fresh_queue::FreshQueueConfig =
crate::uplink_fresh_queue::FreshQueueConfig {
capacity: 16,
max_age: Duration::from_millis(350),
capacity: 4,
max_age: Duration::from_secs(1),
policy: crate::uplink_fresh_queue::FreshQueuePolicy::LatestOnly,
};
@ -791,6 +801,9 @@ const BUNDLED_AUDIO_FLUSH_INTERVAL: Duration = Duration::from_millis(20);
#[cfg(not(coverage))]
const BUNDLED_AUDIO_MAX_PENDING: usize = 8;
#[cfg(not(coverage))]
const BUNDLED_VIDEO_AUDIO_GRACE: Duration = Duration::from_millis(30);
#[cfg(not(coverage))]
const BUNDLED_CAPTURE_EVENT_CHANNEL_CAPACITY: usize = 64;
@ -807,6 +820,7 @@ fn bundle_captured_media(
event_rx: std::sync::mpsc::Receiver<BundledCaptureEvent>,
stop: Arc<AtomicBool>,
queue: crate::uplink_fresh_queue::FreshPacketQueue<UpstreamMediaBundle>,
video_required: bool,
camera_telemetry: crate::uplink_telemetry::UplinkTelemetryHandle,
microphone_telemetry: crate::uplink_telemetry::UplinkTelemetryHandle,
drop_log: Arc<std::sync::Mutex<UplinkDropLogLimiter>>,
@ -816,18 +830,39 @@ fn bundle_captured_media(
.fetch_add(1, Ordering::Relaxed)
.saturating_add(1);
let mut bundle_seq = 0_u64;
let mut pending_audio = Vec::new();
let mut pending_audio = Vec::<AudioPacket>::new();
let mut pending_video = None::<VideoPacket>;
let mut pending_video_deadline = None::<Instant>;
let mut next_audio_flush = Instant::now() + BUNDLED_AUDIO_FLUSH_INTERVAL;
loop {
if stop.load(Ordering::Relaxed) {
break;
}
let timeout = next_audio_flush.saturating_duration_since(Instant::now());
let now = Instant::now();
let timeout = if video_required {
pending_video_deadline
.unwrap_or(now + BUNDLED_AUDIO_FLUSH_INTERVAL)
.saturating_duration_since(now)
} else {
next_audio_flush.saturating_duration_since(now)
};
match event_rx.recv_timeout(timeout) {
Ok(BundledCaptureEvent::Audio(packet)) => {
pending_audio.push(packet);
if pending_audio.len() >= BUNDLED_AUDIO_MAX_PENDING {
if video_required {
let dropped = retain_newest_pending_audio(&mut pending_audio);
if dropped > 0 {
microphone_telemetry.record_stale_drop(dropped as u64);
log_uplink_drop(
&drop_log,
UplinkDropReason::Stale,
dropped as u64,
pending_audio.len(),
0.0,
);
}
} else if pending_audio.len() >= BUNDLED_AUDIO_MAX_PENDING {
emit_bundled_media(
session_id,
&mut bundle_seq,
@ -842,24 +877,72 @@ fn bundle_captured_media(
}
}
Ok(BundledCaptureEvent::Video(packet)) => {
emit_bundled_media(
session_id,
&mut bundle_seq,
Some(packet),
std::mem::take(&mut pending_audio),
&queue,
&camera_telemetry,
&microphone_telemetry,
&drop_log,
);
next_audio_flush = Instant::now() + BUNDLED_AUDIO_FLUSH_INTERVAL;
if let Some(video) = pending_video.take() {
emit_bundled_media(
session_id,
&mut bundle_seq,
Some(video),
std::mem::take(&mut pending_audio),
&queue,
&camera_telemetry,
&microphone_telemetry,
&drop_log,
);
}
pending_video = Some(packet);
pending_video_deadline = Some(Instant::now() + BUNDLED_VIDEO_AUDIO_GRACE);
if !video_required {
emit_bundled_media(
session_id,
&mut bundle_seq,
pending_video.take(),
std::mem::take(&mut pending_audio),
&queue,
&camera_telemetry,
&microphone_telemetry,
&drop_log,
);
pending_video_deadline = None;
next_audio_flush = Instant::now() + BUNDLED_AUDIO_FLUSH_INTERVAL;
}
}
Ok(BundledCaptureEvent::Restart) => {
if pending_video.is_some() || (!video_required && !pending_audio.is_empty()) {
emit_bundled_media(
session_id,
&mut bundle_seq,
pending_video.take(),
std::mem::take(&mut pending_audio),
&queue,
&camera_telemetry,
&microphone_telemetry,
&drop_log,
);
}
stop.store(true, Ordering::Relaxed);
break;
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if !pending_audio.is_empty() {
if video_required {
if pending_video_deadline.is_some_and(|deadline| Instant::now() >= deadline) {
emit_bundled_media(
session_id,
&mut bundle_seq,
pending_video.take(),
std::mem::take(&mut pending_audio),
&queue,
&camera_telemetry,
&microphone_telemetry,
&drop_log,
);
pending_video_deadline = None;
} else {
let dropped = retain_newest_pending_audio(&mut pending_audio);
if dropped > 0 {
microphone_telemetry.record_stale_drop(dropped as u64);
}
}
} else if !pending_audio.is_empty() {
emit_bundled_media(
session_id,
&mut bundle_seq,
@ -876,9 +959,31 @@ fn bundle_captured_media(
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
}
if pending_video.is_some() || (!video_required && !pending_audio.is_empty()) {
emit_bundled_media(
session_id,
&mut bundle_seq,
pending_video.take(),
std::mem::take(&mut pending_audio),
&queue,
&camera_telemetry,
&microphone_telemetry,
&drop_log,
);
}
queue.close();
}
#[cfg(not(coverage))]
fn retain_newest_pending_audio(pending_audio: &mut Vec<AudioPacket>) -> usize {
if pending_audio.len() <= BUNDLED_AUDIO_MAX_PENDING {
return 0;
}
let dropped = pending_audio.len() - BUNDLED_AUDIO_MAX_PENDING;
pending_audio.drain(..dropped);
dropped
}
#[cfg(not(coverage))]
fn emit_bundled_media(
session_id: u64,

View File

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

View File

@ -247,18 +247,18 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta
| `LESAVKA_TOUCHPAD_SCALE` | input routing/clipboard override |
| `LESAVKA_UAC_DEV` | server hardware/device override |
| `LESAVKA_UAC_SESSION_CLOCK_ALIGN` | server audio sink clock-alignment override; `0` is the host-validated default |
| `LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US` | legacy/split server upstream playout override; shifts gadget-audio presentation relative to the shared playout epoch |
| `LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US` | server upstream output-path override; v2 uses it as the explicit UAC handoff delay relative to the shared client capture clock |
| `LESAVKA_UPSTREAM_AUDIO_MASTER_WAIT_GRACE_MS` | server upstream sync override; how long video may wait past its nominal due time for UAC audio to reach the matching timestamp, defaults to `350` |
| `LESAVKA_UPSTREAM_BUNDLED_AUDIO_PLAYOUT_OFFSET_US` | bundled webcam server playout override; defaults to the active runtime audio output-path calibration when unset; sync-critical measured offsets are not clipped for freshness |
| `LESAVKA_UPSTREAM_BUNDLED_PLAYOUT_DELAY_MS` | bundled webcam jitter buffer before output-path compensation; defaults to `350` to protect smooth synced playout |
| `LESAVKA_UPSTREAM_BUNDLED_VIDEO_PLAYOUT_OFFSET_US` | bundled webcam server playout override; defaults to the active runtime video output-path calibration when unset; sync-critical measured offsets are not clipped for freshness |
| `LESAVKA_UPSTREAM_MAX_LIVE_LAG_MS` | server upstream planner freshness ceiling; planner-approved audio/video should not exceed this live lag budget, defaults to `1000` and is capped at `1000` |
| `LESAVKA_UPSTREAM_BUNDLED_PLAYOUT_DELAY_MS` | compatibility alias for `LESAVKA_UPSTREAM_V2_PLAYOUT_DELAY_MS` |
| `LESAVKA_UPSTREAM_MAX_LIVE_LAG_MS` | compatibility alias for `LESAVKA_UPSTREAM_V2_MAX_LIVE_AGE_MS` |
| `LESAVKA_UPSTREAM_PAIR_SLACK_US` | server upstream pairing override; how far video may diverge from the planned audio-master capture moment before the frame is held or dropped, defaults to `80000` |
| `LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS` | server upstream pairing/synchronization target buffer; the server uses this shared buffer to pair webcam frames with matching gadget-mic audio before remote presentation, defaults to `350` |
| `LESAVKA_UPSTREAM_STARTUP_TIMEOUT_MS` | server upstream startup guard; paired startup must converge before this timeout or fail visibly, defaults to `60000` |
| `LESAVKA_UPSTREAM_STALE_DROP_MS` | server upstream freshness override; late audio/video that miss this budget are dropped instead of silently extending lag, defaults to `80` |
| `LESAVKA_UPSTREAM_TIMING_TRACE` | upstream capture/rebase trace override for sync debugging |
| `LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US` | legacy/split server upstream playout override; shifts webcam-video presentation relative to the shared playout epoch, defaults to `1090000` for measured MJPEG/UVC browser-visible sync compensation |
| `LESAVKA_UPSTREAM_V2_MAX_LIVE_AGE_MS` | v2 bundled webcam freshness ceiling; bundles already older than this are dropped as one unit, defaults to `1000` |
| `LESAVKA_UPSTREAM_V2_PLAYOUT_DELAY_MS` | v2 optional common playout slack after sync offsets; defaults to `20` and is reduced when needed to protect the live-age budget |
| `LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US` | server upstream output-path override; v2 uses it as the explicit UVC handoff delay relative to the shared client capture clock, defaults to the calibrated MJPEG/UVC offset |
| `LESAVKA_UPLINK_CAMERA_PREVIEW` | client media capture/playback override |
| `LESAVKA_UPLINK_MIC_LEVEL` | client media capture/playback override |
| `LESAVKA_INSTALL_UVC_CODEC` | installer override; sets the persisted default UVC webcam codec in `/etc/lesavka/server.env` and `/etc/lesavka/uvc.env` |

View File

@ -0,0 +1,16 @@
# Upstream Media v1 Quarantine
This folder preserves the old upstream media planner for reference only. It is intentionally outside the active Cargo module tree.
Good ideas worth reusing later, after v2 is stable:
- Explicit client timing sidecars for capture/send/queue age.
- Diagnostics windows for capture skew, send skew, receive skew, and sink handoff skew.
- Single-owner leases for camera and microphone streams.
- A single UAC sink permit so reconnects do not double-open ALSA.
Reasons this was quarantined:
- Bundled A/V was unpacked into independent events and scheduled one by one.
- Freshness recovery, output compensation, startup pairing, and per-kind drops interacted in ways that could create delay or asymmetry.
- The active bundled path was too complex to reason about while debugging real Google Meet lip sync.
The v2 active path must stay small: when video is present, audio and video travel in one bundle and the server releases that bundle coherently. Sync first, freshness second, smoothness third.

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,133 +1,209 @@
#[cfg(not(coverage))]
#[derive(Debug)]
enum BundledUpstreamEvent {
Audio(AudioPacket),
Video(VideoPacket),
}
const MEDIA_V2_DEFAULT_PLAYOUT_DELAY_MS: u64 = 20;
#[cfg(not(coverage))]
impl BundledUpstreamEvent {
fn remote_pts_us(&self) -> u64 {
self.client_timing().capture_pts_us
}
fn client_timing(&self) -> UpstreamClientTiming {
match self {
Self::Audio(packet) => audio_client_timing(packet),
Self::Video(packet) => video_client_timing(packet),
}
}
fn kind(&self) -> UpstreamMediaKind {
match self {
Self::Audio(_) => UpstreamMediaKind::Microphone,
Self::Video(_) => UpstreamMediaKind::Camera,
}
}
fn playout_order(&self) -> u8 {
match self {
Self::Audio(_) => 0,
Self::Video(_) => 1,
}
}
}
const MEDIA_V2_DEFAULT_MAX_LIVE_AGE_MS: u64 = 1_000;
#[cfg(not(coverage))]
const MEDIA_V2_MAX_MIXED_CAPTURE_SPAN_US: u64 = 250_000;
#[cfg(not(coverage))]
#[derive(Clone, Copy, Debug, Default)]
struct BundledPlayoutClock {
base_remote_pts_us: Option<u64>,
epoch: Option<tokio::time::Instant>,
struct MediaV2Clock {
base_capture_pts_us: Option<u64>,
}
#[cfg(not(coverage))]
impl BundledPlayoutClock {
fn ensure(&mut self, events: &[BundledUpstreamEvent]) -> Option<(u64, tokio::time::Instant)> {
if self.base_remote_pts_us.is_none() || self.epoch.is_none() {
let base = events.iter().map(BundledUpstreamEvent::remote_pts_us).min()?;
self.base_remote_pts_us = Some(base);
self.epoch = Some(tokio::time::Instant::now() + bundled_upstream_playout_delay());
}
let base_remote_pts_us = self.base_remote_pts_us?;
let epoch = self.epoch?;
Some((base_remote_pts_us, epoch))
impl MediaV2Clock {
fn local_pts_us(&mut self, capture_pts_us: u64) -> u64 {
let base = *self.base_capture_pts_us.get_or_insert(capture_pts_us);
capture_pts_us.saturating_sub(base)
}
}
#[cfg(not(coverage))]
const BUNDLED_CAPTURE_BOUND_TOLERANCE_US: u64 = 50_000;
#[cfg(not(coverage))]
const BUNDLED_MIXED_CAPTURE_SPAN_DROP_US: u64 = 250_000;
#[cfg(not(coverage))]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct BundledTimingSummary {
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
struct MediaV2BundleFacts {
has_audio: bool,
has_video: bool,
min_event_pts_us: u64,
max_event_pts_us: u64,
capture_span_us: u64,
capture_bounds_match: bool,
mixed_span_too_wide: bool,
max_queue_age_ms: u32,
}
#[cfg(not(coverage))]
fn bundled_events_are_mixed(events: &[BundledUpstreamEvent]) -> bool {
let has_audio = events.iter().any(|event| matches!(event, BundledUpstreamEvent::Audio(_)));
let has_video = events.iter().any(|event| matches!(event, BundledUpstreamEvent::Video(_)));
has_audio && has_video
#[derive(Clone, Copy, Debug)]
struct MediaV2HandoffSchedule {
audio_due_at: Option<tokio::time::Instant>,
video_due_at: Option<tokio::time::Instant>,
common_delay: Duration,
relative_audio_delay: Duration,
relative_video_delay: Duration,
}
#[cfg(not(coverage))]
fn retain_startup_video_only(events: &mut Vec<BundledUpstreamEvent>) -> bool {
if !bundled_events_are_mixed(events) {
return false;
fn summarize_media_v2_bundle(bundle: &UpstreamMediaBundle) -> Option<MediaV2BundleFacts> {
let mut capture_start_us = u64::MAX;
let mut capture_end_us = 0_u64;
let mut max_queue_age_ms = 0_u32;
let has_video = bundle.video.is_some();
let has_audio = !bundle.audio.is_empty();
if let Some(video) = bundle.video.as_ref() {
let pts = packet_video_capture_pts_us(video);
capture_start_us = capture_start_us.min(pts);
capture_end_us = capture_end_us.max(pts);
max_queue_age_ms = max_queue_age_ms.max(video.client_queue_age_ms);
}
events.retain(|event| matches!(event, BundledUpstreamEvent::Video(_)));
!events.is_empty()
}
#[cfg(not(coverage))]
fn summarize_bundled_timing(
bundle: &UpstreamMediaBundle,
events: &[BundledUpstreamEvent],
) -> Option<BundledTimingSummary> {
let min_event_pts_us = events.iter().map(BundledUpstreamEvent::remote_pts_us).min()?;
let max_event_pts_us = events.iter().map(BundledUpstreamEvent::remote_pts_us).max()?;
let has_audio = events.iter().any(|event| matches!(event, BundledUpstreamEvent::Audio(_)));
let has_video = events.iter().any(|event| matches!(event, BundledUpstreamEvent::Video(_)));
let start_matches = bundle.capture_start_us == 0
|| abs_delta_us(bundle.capture_start_us, min_event_pts_us)
<= BUNDLED_CAPTURE_BOUND_TOLERANCE_US;
let end_matches = bundle.capture_end_us == 0
|| abs_delta_us(bundle.capture_end_us, max_event_pts_us) <= BUNDLED_CAPTURE_BOUND_TOLERANCE_US;
let capture_span_us = max_event_pts_us.saturating_sub(min_event_pts_us);
Some(BundledTimingSummary {
for audio in &bundle.audio {
let pts = packet_audio_capture_pts_us(audio);
capture_start_us = capture_start_us.min(pts);
capture_end_us = capture_end_us.max(pts);
max_queue_age_ms = max_queue_age_ms.max(audio.client_queue_age_ms);
}
if !has_audio && !has_video {
return None;
}
if capture_start_us == u64::MAX {
capture_start_us = 0;
}
Some(MediaV2BundleFacts {
has_audio,
has_video,
min_event_pts_us,
max_event_pts_us,
capture_span_us,
capture_bounds_match: start_matches && end_matches,
mixed_span_too_wide: has_audio
&& has_video
&& capture_span_us > BUNDLED_MIXED_CAPTURE_SPAN_DROP_US,
capture_span_us: capture_end_us.saturating_sub(capture_start_us),
max_queue_age_ms,
})
}
#[cfg(not(coverage))]
fn abs_delta_us(left: u64, right: u64) -> u64 {
left.max(right) - left.min(right)
fn packet_audio_capture_pts_us(packet: &AudioPacket) -> u64 {
if packet.client_capture_pts_us == 0 {
packet.pts
} else {
packet.client_capture_pts_us
}
}
#[cfg(not(coverage))]
fn bundled_upstream_playout_delay() -> Duration {
std::env::var("LESAVKA_UPSTREAM_BUNDLED_PLAYOUT_DELAY_MS")
.or_else(|_| std::env::var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS"))
fn packet_video_capture_pts_us(packet: &VideoPacket) -> u64 {
if packet.client_capture_pts_us == 0 {
packet.pts
} else {
packet.client_capture_pts_us
}
}
#[cfg(not(coverage))]
fn media_v2_playout_delay() -> Duration {
std::env::var("LESAVKA_UPSTREAM_V2_PLAYOUT_DELAY_MS")
.or_else(|_| std::env::var("LESAVKA_UPSTREAM_BUNDLED_PLAYOUT_DELAY_MS"))
.ok()
.and_then(|value| value.trim().parse::<u64>().ok())
.map(Duration::from_millis)
.unwrap_or_else(|| Duration::from_millis(350))
.unwrap_or_else(|| Duration::from_millis(MEDIA_V2_DEFAULT_PLAYOUT_DELAY_MS))
}
#[cfg(not(coverage))]
fn media_v2_max_live_age() -> Duration {
std::env::var("LESAVKA_UPSTREAM_V2_MAX_LIVE_AGE_MS")
.or_else(|_| std::env::var("LESAVKA_UPSTREAM_MAX_LIVE_LAG_MS"))
.ok()
.and_then(|value| value.trim().parse::<u64>().ok())
.map(Duration::from_millis)
.unwrap_or_else(|| Duration::from_millis(MEDIA_V2_DEFAULT_MAX_LIVE_AGE_MS))
}
#[cfg(not(coverage))]
fn media_v2_handoff_schedule(
facts: MediaV2BundleFacts,
audio_offset_us: i64,
video_offset_us: i64,
) -> Option<MediaV2HandoffSchedule> {
let max_live_age = media_v2_max_live_age();
let queue_age = Duration::from_millis(u64::from(facts.max_queue_age_ms));
if queue_age >= max_live_age {
return None;
}
let mut relative_audio_delay = Duration::ZERO;
let mut relative_video_delay = Duration::ZERO;
if facts.has_audio && facts.has_video {
let base_offset_us = audio_offset_us.min(video_offset_us);
relative_audio_delay =
Duration::from_micros(audio_offset_us.saturating_sub(base_offset_us) as u64);
relative_video_delay =
Duration::from_micros(video_offset_us.saturating_sub(base_offset_us) as u64);
}
let relative_span = relative_audio_delay.max(relative_video_delay);
let remaining_after_offset = max_live_age
.saturating_sub(queue_age)
.saturating_sub(relative_span);
let common_delay = media_v2_playout_delay().min(remaining_after_offset);
let now = tokio::time::Instant::now();
Some(MediaV2HandoffSchedule {
audio_due_at: facts
.has_audio
.then_some(now + common_delay + relative_audio_delay),
video_due_at: facts
.has_video
.then_some(now + common_delay + relative_video_delay),
common_delay,
relative_audio_delay,
relative_video_delay,
})
}
#[cfg(not(coverage))]
async fn sleep_until_media_v2(due_at: tokio::time::Instant) {
if due_at > tokio::time::Instant::now() {
tokio::time::sleep_until(due_at).await;
}
}
#[cfg(not(coverage))]
async fn push_media_v2_audio(
audio_packets: &mut Vec<AudioPacket>,
clock: &mut MediaV2Clock,
sink: &mut lesavka_server::audio::Voice,
upstream_media_rt: &UpstreamMediaRuntime,
due_at: tokio::time::Instant,
) {
sleep_until_media_v2(due_at).await;
for mut audio in audio_packets.drain(..) {
let capture_pts_us = packet_audio_capture_pts_us(&audio);
audio.pts = clock.local_pts_us(capture_pts_us);
sink.push(&audio);
upstream_media_rt.mark_audio_presented(audio.pts, due_at);
}
}
#[cfg(not(coverage))]
async fn feed_media_v2_video(
video: Option<VideoPacket>,
clock: &mut MediaV2Clock,
relay: &Arc<lesavka_server::video::CameraRelay>,
upstream_media_rt: &UpstreamMediaRuntime,
due_at: tokio::time::Instant,
video_presented_once: &mut bool,
rpc_id: u64,
session_id: u64,
camera_session_id: u64,
) {
let Some(mut video) = video else {
return;
};
sleep_until_media_v2(due_at).await;
let capture_pts_us = packet_video_capture_pts_us(&video);
video.pts = clock.local_pts_us(capture_pts_us);
let presented_pts = video.pts;
relay.feed(video);
if !*video_presented_once {
info!(
rpc_id,
session_id,
camera_session_id,
pts = presented_pts,
"📦 first v2 bundled video frame fed to camera sink"
);
*video_presented_once = true;
}
upstream_media_rt.mark_video_presented(presented_pts, due_at);
}
#[cfg(not(coverage))]
@ -235,7 +311,7 @@ impl Relay for Handler {
Ok(Response::new(ReceiverStream::new(rx)))
}
/// Accept client-bundled webcam and microphone packets on one upstream clock.
/// Accept client-bundled webcam and microphone packets on the v2 upstream path.
async fn stream_webcam_media(
&self,
req: Request<tonic::Streaming<UpstreamMediaBundle>>,
@ -254,16 +330,14 @@ impl Relay for Handler {
width = camera_cfg.width,
height = camera_cfg.height,
fps = camera_cfg.fps,
"📦 stream_webcam_media opened"
"📦 stream_webcam_media v2 opened"
);
let (camera_session_id, relay, _relay_reused) =
match self.camera_rt.activate(&camera_cfg).await {
Ok(active) => active,
Err(err) => {
self.upstream_media_rt
.close_camera(camera_lease.generation);
self.upstream_media_rt
.close_microphone(microphone_lease.generation);
self.upstream_media_rt.close_camera(camera_lease.generation);
self.upstream_media_rt.close_microphone(microphone_lease.generation);
return Err(err);
}
};
@ -272,53 +346,44 @@ impl Relay for Handler {
.reserve_microphone_sink(microphone_lease.generation)
.await
else {
self.upstream_media_rt
.close_camera(camera_lease.generation);
self.upstream_media_rt
.close_microphone(microphone_lease.generation);
self.upstream_media_rt.close_camera(camera_lease.generation);
self.upstream_media_rt.close_microphone(microphone_lease.generation);
return Err(Status::aborted(
"bundled webcam media stream superseded before microphone sink became available",
"v2 bundled media stream superseded before microphone sink became available",
));
};
let uac_dev = std::env::var("LESAVKA_UAC_DEV").unwrap_or_else(|_| "hw:UAC2Gadget,0".into());
let mut sink = runtime_support::open_voice_with_retry(&uac_dev)
.await
.map_err(|e| {
self.upstream_media_rt
.close_camera(camera_lease.generation);
self.upstream_media_rt
.close_microphone(microphone_lease.generation);
self.upstream_media_rt.close_camera(camera_lease.generation);
self.upstream_media_rt.close_microphone(microphone_lease.generation);
Status::internal(format!("{e:#}"))
})?;
let camera_rt = self.camera_rt.clone();
let upstream_media_rt = self.upstream_media_rt.clone();
let frame_step_us = (1_000_000u64 / u64::from(camera_cfg.fps.max(1))).max(1);
let stale_drop_budget = upstream_stale_drop_budget();
let (tx, rx) = tokio::sync::mpsc::channel(1);
tokio::spawn(async move {
let _microphone_sink_permit = microphone_sink_permit;
let mut inbound = req.into_inner();
let mut clock = BundledPlayoutClock::default();
let mut clock = MediaV2Clock::default();
let mut last_bundle_session_id = None;
let mut last_bundle_seq = None;
let mut video_presented_once = false;
let mut outcome = "aborted";
'bundled_loop: loop {
let bundle = match inbound.next().await {
Some(Ok(bundle)) => bundle,
Some(Err(err)) => {
while let Some(bundle_result) = inbound.next().await {
let mut bundle = match bundle_result {
Ok(bundle) => bundle,
Err(err) => {
warn!(
rpc_id,
session_id = camera_lease.session_id,
"📦 stream_webcam_media inbound error before clean EOF: {err}"
"📦 stream_webcam_media v2 inbound error before clean EOF: {err}"
);
break;
}
None => {
outcome = "closed";
break;
}
};
if !camera_rt.is_active(camera_session_id)
|| !upstream_media_rt.is_camera_active(camera_lease.generation)
@ -327,31 +392,14 @@ impl Relay for Handler {
outcome = "superseded";
break;
}
let mut events = Vec::with_capacity(bundle.audio.len() + 1);
if let Some(video) = bundle.video.clone() {
upstream_media_rt
.record_client_timing(UpstreamMediaKind::Camera, video_client_timing(&video));
events.push(BundledUpstreamEvent::Video(video));
}
for audio in &bundle.audio {
upstream_media_rt.record_client_timing(
UpstreamMediaKind::Microphone,
audio_client_timing(audio),
);
events.push(BundledUpstreamEvent::Audio(audio.clone()));
}
if events.is_empty() {
continue;
}
events.sort_by_key(|event| (event.remote_pts_us(), event.playout_order()));
if last_bundle_session_id.is_some_and(|session_id| session_id != bundle.session_id) {
warn!(
rpc_id,
previous_session_id = last_bundle_session_id.unwrap_or_default(),
next_session_id = bundle.session_id,
"📦 bundled upstream client session changed inside one gRPC stream; resetting playout epoch"
"📦 v2 bundled client session changed; resetting local media clock"
);
clock = BundledPlayoutClock::default();
clock = MediaV2Clock::default();
last_bundle_seq = None;
}
last_bundle_session_id = Some(bundle.session_id);
@ -362,204 +410,143 @@ impl Relay for Handler {
client_bundle_session_id = bundle.session_id,
bundle_seq = bundle.seq,
previous_bundle_seq = last_bundle_seq.unwrap_or_default(),
"📦 bundled upstream packet sequence moved backwards; dropping duplicate/stale bundle"
"📦 v2 dropping duplicate/stale bundled packet"
);
continue;
}
last_bundle_seq = Some(bundle.seq);
let Some(timing_summary) = summarize_bundled_timing(&bundle, &events) else {
let Some(facts) = summarize_media_v2_bundle(&bundle) else {
continue;
};
if !timing_summary.capture_bounds_match {
if facts.has_audio && facts.has_video && facts.capture_span_us > MEDIA_V2_MAX_MIXED_CAPTURE_SPAN_US {
warn!(
rpc_id,
session_id = camera_lease.session_id,
client_bundle_session_id = bundle.session_id,
bundle_seq = bundle.seq,
capture_start_us = bundle.capture_start_us,
capture_end_us = bundle.capture_end_us,
event_min_us = timing_summary.min_event_pts_us,
event_max_us = timing_summary.max_event_pts_us,
"📦 bundled upstream capture bounds disagreed with packet timing; using packet sidecar timing"
span_ms = facts.capture_span_us / 1000,
"📦 v2 dropping mixed bundle with impossible A/V capture span"
);
continue;
}
for audio in &bundle.audio {
upstream_media_rt.record_client_timing(
UpstreamMediaKind::Microphone,
audio_client_timing(audio),
);
}
if timing_summary.mixed_span_too_wide {
if !video_presented_once && retain_startup_video_only(&mut events) {
warn!(
rpc_id,
session_id = camera_lease.session_id,
client_bundle_session_id = bundle.session_id,
bundle_seq = bundle.seq,
span_ms = timing_summary.capture_span_us / 1000,
"📦 bundled startup A/V span is too wide; dropping audio but feeding video to prime the camera device"
);
} else {
warn!(
rpc_id,
session_id = camera_lease.session_id,
client_bundle_session_id = bundle.session_id,
bundle_seq = bundle.seq,
span_ms = timing_summary.capture_span_us / 1000,
"📦 bundled mixed A/V capture span is too wide; dropping the bundle to protect sync"
);
continue;
}
if let Some(video) = bundle.video.as_ref() {
upstream_media_rt.record_client_timing(
UpstreamMediaKind::Camera,
video_client_timing(video),
);
}
let Some((base_remote_pts_us, epoch)) = clock.ensure(&events) else {
let (video_offset_us, audio_offset_us) = upstream_media_rt.playout_offsets();
let Some(schedule) = media_v2_handoff_schedule(facts, audio_offset_us, video_offset_us) else {
if facts.has_video {
upstream_media_rt.record_video_freeze(
"v2 dropped stale bundled A/V before UVC/UAC handoff",
);
}
warn!(
rpc_id,
session_id = camera_lease.session_id,
client_bundle_session_id = bundle.session_id,
bundle_seq = bundle.seq,
max_queue_age_ms = facts.max_queue_age_ms,
"📦 v2 dropping whole bundle because it is already outside the freshness budget"
);
continue;
};
let mut mixed_bundle = bundled_events_are_mixed(&events);
let startup_video_priming = !video_presented_once
&& events
.iter()
.any(|event| matches!(event, BundledUpstreamEvent::Video(_)));
let mut planned_events = Vec::with_capacity(events.len());
let mut drop_mixed_bundle = false;
let mut dropped_audio_for_startup_video = false;
for event in events {
let kind = event.kind();
let min_step_us = match kind {
UpstreamMediaKind::Camera => frame_step_us,
UpstreamMediaKind::Microphone => 1,
};
let plan = match upstream_media_rt.plan_bundled_pts(
kind,
event.remote_pts_us(),
min_step_us,
base_remote_pts_us,
epoch,
) {
lesavka_server::upstream_media_runtime::UpstreamPlanDecision::Play(plan) => plan,
lesavka_server::upstream_media_runtime::UpstreamPlanDecision::DropBeforeOverlap => {
if startup_video_priming && kind == UpstreamMediaKind::Microphone {
dropped_audio_for_startup_video = true;
continue;
}
if mixed_bundle {
drop_mixed_bundle = true;
}
continue;
}
lesavka_server::upstream_media_runtime::UpstreamPlanDecision::DropStale(reason) => {
tracing::warn!(
rpc_id,
session_id = camera_lease.session_id,
?kind,
reason,
"📦 bundled upstream packet dropped by freshness planner"
);
if startup_video_priming && kind == UpstreamMediaKind::Microphone {
dropped_audio_for_startup_video = true;
continue;
}
if mixed_bundle {
drop_mixed_bundle = true;
}
continue;
}
lesavka_server::upstream_media_runtime::UpstreamPlanDecision::AwaitingPair => continue,
lesavka_server::upstream_media_runtime::UpstreamPlanDecision::StartupFailed(reason) => {
tracing::error!(
rpc_id,
session_id = camera_lease.session_id,
reason,
"📦 bundled upstream startup failed"
);
break 'bundled_loop;
}
};
if plan.late_by > stale_drop_budget {
tracing::warn!(
debug!(
rpc_id,
session_id = camera_lease.session_id,
client_bundle_session_id = bundle.session_id,
bundle_seq = bundle.seq,
max_queue_age_ms = facts.max_queue_age_ms,
common_delay_ms = schedule.common_delay.as_millis(),
relative_audio_delay_ms = schedule.relative_audio_delay.as_millis(),
relative_video_delay_ms = schedule.relative_video_delay.as_millis(),
audio_offset_us,
video_offset_us,
"📦 v2 scheduled bundled UAC/UVC handoff from one capture clock"
);
match (schedule.audio_due_at, schedule.video_due_at) {
(Some(audio_due_at), Some(video_due_at)) if audio_due_at <= video_due_at => {
push_media_v2_audio(
&mut bundle.audio,
&mut clock,
&mut sink,
&upstream_media_rt,
audio_due_at,
)
.await;
feed_media_v2_video(
bundle.video.take(),
&mut clock,
&relay,
&upstream_media_rt,
video_due_at,
&mut video_presented_once,
rpc_id,
session_id = camera_lease.session_id,
?kind,
late_by_ms = plan.late_by.as_millis(),
pts = plan.local_pts_us,
"📦 bundled upstream packet dropped after missing freshness budget"
);
if startup_video_priming && kind == UpstreamMediaKind::Microphone {
dropped_audio_for_startup_video = true;
continue;
}
if mixed_bundle {
drop_mixed_bundle = true;
}
continue;
camera_lease.session_id,
camera_session_id,
)
.await;
}
planned_events.push((event, plan));
}
if dropped_audio_for_startup_video {
mixed_bundle = false;
warn!(
rpc_id,
session_id = camera_lease.session_id,
client_bundle_session_id = bundle.session_id,
bundle_seq = bundle.seq,
"📦 dropped startup audio from a bad bundled packet but kept video-only playout so the camera can start"
);
}
if drop_mixed_bundle {
warn!(
rpc_id,
session_id = camera_lease.session_id,
client_bundle_session_id = bundle.session_id,
bundle_seq = bundle.seq,
"📦 dropping mixed A/V bundle coherently because one side failed sync/freshness planning"
);
continue;
}
for (event, plan) in planned_events {
let kind = event.kind();
tokio::time::sleep_until(plan.due_at).await;
let actual_late_by = tokio::time::Instant::now()
.checked_duration_since(plan.due_at)
.unwrap_or_default();
if actual_late_by > stale_drop_budget {
tracing::warn!(
(Some(audio_due_at), Some(video_due_at)) => {
feed_media_v2_video(
bundle.video.take(),
&mut clock,
&relay,
&upstream_media_rt,
video_due_at,
&mut video_presented_once,
rpc_id,
session_id = camera_lease.session_id,
?kind,
late_by_ms = actual_late_by.as_millis(),
pts = plan.local_pts_us,
"📦 bundled upstream packet dropped after waking too late"
);
if mixed_bundle {
warn!(
rpc_id,
session_id = camera_lease.session_id,
client_bundle_session_id = bundle.session_id,
bundle_seq = bundle.seq,
"📦 stopping the rest of this mixed bundle after a late wake to avoid asymmetric playout"
);
break;
}
continue;
camera_lease.session_id,
camera_session_id,
)
.await;
push_media_v2_audio(
&mut bundle.audio,
&mut clock,
&mut sink,
&upstream_media_rt,
audio_due_at,
)
.await;
}
match event {
BundledUpstreamEvent::Audio(mut packet) => {
packet.pts = plan.local_pts_us;
sink.push(&packet);
upstream_media_rt.mark_audio_presented(packet.pts, plan.due_at);
}
BundledUpstreamEvent::Video(mut packet) => {
packet.pts = plan.local_pts_us;
let presented_pts = packet.pts;
relay.feed(packet);
if !video_presented_once {
info!(
rpc_id,
session_id = camera_lease.session_id,
camera_session_id,
pts = presented_pts,
"📦 first bundled video frame fed to camera sink"
);
video_presented_once = true;
}
upstream_media_rt.mark_video_presented(presented_pts, plan.due_at);
}
(Some(audio_due_at), None) => {
push_media_v2_audio(
&mut bundle.audio,
&mut clock,
&mut sink,
&upstream_media_rt,
audio_due_at,
)
.await;
}
(None, Some(video_due_at)) => {
feed_media_v2_video(
bundle.video.take(),
&mut clock,
&relay,
&upstream_media_rt,
video_due_at,
&mut video_presented_once,
rpc_id,
camera_lease.session_id,
camera_session_id,
)
.await;
}
(None, None) => {}
}
}
outcome = if outcome == "aborted" { "closed" } else { outcome };
sink.finish();
upstream_media_rt.close_camera(camera_lease.generation);
upstream_media_rt.close_microphone(microphone_lease.generation);
@ -568,7 +555,7 @@ impl Relay for Handler {
session_id = camera_lease.session_id,
camera_session_id,
outcome,
"📦 stream_webcam_media lifecycle ended"
"📦 stream_webcam_media v2 lifecycle ended"
);
tx.send(Ok(Empty {})).await.ok();
Ok::<(), Status>(())

View File

@ -2,13 +2,12 @@
#[allow(clippy::items_after_test_module)]
mod tests {
use super::{
BundledUpstreamEvent, UpstreamStreamCleanup, bundled_events_are_mixed,
retain_freshest_audio_packet, retain_freshest_video_packet, retain_startup_video_only,
summarize_bundled_timing,
MediaV2BundleFacts, UpstreamStreamCleanup, media_v2_handoff_schedule,
retain_freshest_audio_packet, retain_freshest_video_packet, summarize_media_v2_bundle,
};
use lesavka_common::lesavka::{AudioPacket, UpstreamMediaBundle, VideoPacket};
use lesavka_server::upstream_media_runtime::{
UpstreamMediaKind, UpstreamMediaRuntime, UpstreamClientTiming,
UpstreamClientTiming, UpstreamMediaKind, UpstreamMediaRuntime,
};
use std::sync::Arc;
@ -99,69 +98,84 @@ mod tests {
}
#[test]
fn bundled_event_timing_uses_client_capture_sidecar_not_packet_pts() {
let video = BundledUpstreamEvent::Video(VideoPacket {
fn media_v2_bundle_summary_uses_client_capture_sidecar_not_packet_pts() {
let bundle = UpstreamMediaBundle {
capture_start_us: 1,
capture_end_us: 2,
video: Some(VideoPacket {
pts: 9_999_000,
client_capture_pts_us: 1_000_000,
client_send_pts_us: 1_001_000,
client_queue_age_ms: 12,
..Default::default()
}),
audio: vec![AudioPacket {
pts: 8_888_000,
client_capture_pts_us: 1_020_000,
client_send_pts_us: 1_021_000,
client_queue_age_ms: 34,
..Default::default()
}],
..Default::default()
};
let summary = summarize_media_v2_bundle(&bundle).expect("summary");
assert!(summary.has_video);
assert!(summary.has_audio);
assert_eq!(summary.capture_span_us, 20_000);
assert_eq!(summary.max_queue_age_ms, 34);
}
#[test]
fn media_v2_schedule_offsets_outputs_without_creating_split_planner() {
let facts = MediaV2BundleFacts {
has_audio: true,
has_video: true,
capture_span_us: 20_000,
max_queue_age_ms: 0,
};
let schedule =
media_v2_handoff_schedule(facts, 0, 979_000).expect("fresh schedule");
let audio_due = schedule.audio_due_at.expect("audio due");
let video_due = schedule.video_due_at.expect("video due");
assert_eq!(schedule.relative_audio_delay.as_millis(), 0);
assert_eq!(schedule.relative_video_delay.as_millis(), 979);
assert_eq!(video_due.duration_since(audio_due).as_millis(), 979);
assert!(schedule.common_delay.as_millis() <= 21);
}
#[test]
fn media_v2_schedule_refuses_bundles_already_past_freshness_budget() {
let facts = MediaV2BundleFacts {
has_audio: true,
has_video: true,
capture_span_us: 20_000,
max_queue_age_ms: 1_000,
};
assert!(media_v2_handoff_schedule(facts, 0, 0).is_none());
}
#[test]
fn legacy_bundled_event_timing_example_documents_quarantined_v1_behavior() {
let video = VideoPacket {
pts: 9_999_000,
client_capture_pts_us: 1_000_000,
client_send_pts_us: 1_001_000,
..Default::default()
});
let audio = BundledUpstreamEvent::Audio(AudioPacket {
};
let audio = AudioPacket {
pts: 8_888_000,
client_capture_pts_us: 1_020_000,
client_send_pts_us: 1_021_000,
..Default::default()
});
assert_eq!(video.remote_pts_us(), 1_000_000);
assert_eq!(audio.remote_pts_us(), 1_020_000);
}
#[test]
fn bundled_timing_summary_flags_bad_bounds_and_wide_mixed_span() {
let events = vec![
BundledUpstreamEvent::Video(VideoPacket {
client_capture_pts_us: 1_000_000,
..Default::default()
}),
BundledUpstreamEvent::Audio(AudioPacket {
client_capture_pts_us: 1_400_000,
..Default::default()
}),
];
let bundle = UpstreamMediaBundle {
capture_start_us: 100,
capture_end_us: 200,
..Default::default()
};
let summary = summarize_bundled_timing(&bundle, &events).expect("timing summary");
assert!(bundled_events_are_mixed(&events));
assert_eq!(summary.capture_span_us, 400_000);
assert!(!summary.capture_bounds_match);
assert!(summary.mixed_span_too_wide);
}
#[test]
fn startup_video_retention_drops_audio_from_bad_mixed_bundle() {
let mut events = vec![
BundledUpstreamEvent::Audio(AudioPacket {
client_capture_pts_us: 1_000_000,
..Default::default()
}),
BundledUpstreamEvent::Video(VideoPacket {
client_capture_pts_us: 1_500_000,
..Default::default()
}),
];
assert!(bundled_events_are_mixed(&events));
assert!(retain_startup_video_only(&mut events));
assert!(!bundled_events_are_mixed(&events));
assert_eq!(events.len(), 1);
assert!(matches!(events[0], BundledUpstreamEvent::Video(_)));
assert_eq!(video.client_capture_pts_us, 1_000_000);
assert_eq!(audio.client_capture_pts_us, 1_020_000);
}
#[test]

File diff suppressed because it is too large Load Diff