diff --git a/Cargo.lock b/Cargo.lock index 33b072f..bb688e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.22.17" +version = "0.22.18" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.22.17" +version = "0.22.18" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.22.17" +version = "0.22.18" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index b1cdd1b..e15ccc2 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.22.17" +version = "0.22.18" edition = "2024" [dependencies] diff --git a/client/src/sync_probe/capture.rs b/client/src/sync_probe/capture.rs index 7fa98ca..99a6a8b 100644 --- a/client/src/sync_probe/capture.rs +++ b/client/src/sync_probe/capture.rs @@ -68,7 +68,7 @@ const AUDIO_SAMPLE_RATE: i32 = 48_000; #[cfg(any(not(coverage), test))] const AUDIO_CHANNELS: usize = 2; #[cfg(any(not(coverage), test))] -const AUDIO_CHUNK_MS: u64 = 10; +const AUDIO_CHUNK_MS: u64 = 20; #[cfg(any(not(coverage), test))] const AUDIO_PULSE_FREQUENCY_HZ: f64 = 1_800.0; #[cfg(any(not(coverage), test))] diff --git a/client/src/sync_probe/capture/runtime.rs b/client/src/sync_probe/capture/runtime.rs index 23b9dd0..3b5a23c 100644 --- a/client/src/sync_probe/capture/runtime.rs +++ b/client/src/sync_probe/capture/runtime.rs @@ -271,6 +271,7 @@ fn spawn_audio_thread( let packet = AudioPacket { id: 0, pts: source_pts_us, + frame_duration_us: AUDIO_CHUNK_MS.saturating_mul(1_000) as u32, data: chunk, ..Default::default() }; diff --git a/client/src/sync_probe/capture/tests/runtime_packets.rs b/client/src/sync_probe/capture/tests/runtime_packets.rs index 8fa323b..cf88ec3 100644 --- a/client/src/sync_probe/capture/tests/runtime_packets.rs +++ b/client/src/sync_probe/capture/tests/runtime_packets.rs @@ -19,12 +19,17 @@ async fn runtime_audio_probe_emits_nontrivial_pcm_packets() { let mut packet_count = 0usize; let mut total_bytes = 0usize; let mut largest_packet = 0usize; + let expected_duration_us = 20_000; loop { let next = audio_queue.pop_fresh().await; let Some(packet) = next.packet else { break; }; + assert_eq!( + packet.frame_duration_us, expected_duration_us, + "synthetic audio metadata should match its PCM payload duration" + ); packet_count += 1; total_bytes += packet.data.len(); largest_packet = largest_packet.max(packet.data.len()); @@ -334,7 +339,7 @@ async fn runtime_probe_hevc_video_and_audio_can_form_one_local_bundle() { videos.len() ); assert!( - audios.len() >= 120, + audios.len() >= 90, "expected local PCM audio packets, got {}", audios.len() ); @@ -357,8 +362,8 @@ async fn runtime_probe_hevc_video_and_audio_can_form_one_local_bundle() { video.pts ); assert!( - paired_audio.iter().any(|packet| packet.data.len() >= 1_920), - "expected paired audio to carry full 10ms stereo PCM packets" + paired_audio.iter().any(|packet| packet.data.len() >= 3_840), + "expected paired audio to carry full 20ms stereo PCM packets" ); } diff --git a/client/src/sync_probe/runner/bundled_transport.rs b/client/src/sync_probe/runner/bundled_transport.rs index bd63930..465308d 100644 --- a/client/src/sync_probe/runner/bundled_transport.rs +++ b/client/src/sync_probe/runner/bundled_transport.rs @@ -5,8 +5,9 @@ //! streaming `UpstreamMediaBundle` messages to the server. use anyhow::{Context, Result}; -use lesavka_common::lesavka::{ - AudioPacket, UpstreamMediaBundle, VideoPacket, relay_client::RelayClient, +use lesavka_common::{ + audio_transport::{self, AudioTransportProfile, UpstreamAudioCodec}, + lesavka::{AudioPacket, UpstreamMediaBundle, VideoPacket, relay_client::RelayClient}, }; use std::fs::File; use std::io::Write; @@ -43,6 +44,7 @@ pub async fn run_bundled_probe_stream( .context("opening sync probe audio dump")?; let mut send_log = open_debug_dump("LESAVKA_SYNC_PROBE_SEND_LOG") .context("opening sync probe send log")?; + let mut audio_transport = ProbeAudioTransport::from_env(); let outbound = async_stream::stream! { let mut pending_audio = Vec::::new(); let mut audio_done = false; @@ -79,6 +81,7 @@ pub async fn run_bundled_probe_stream( &mut pending_audio, &mut audio_done, &mut audio_seq, + &mut audio_transport, audio_dump.as_mut(), probe_start, ).await; @@ -129,8 +132,10 @@ pub async fn run_bundled_probe_stream( next.queue_depth, probe_start, ); - write_probe_audio_dump(audio_dump.as_mut(), &packet); - pending_audio.push(packet); + if let Some(packet) = audio_transport.encode_packet(packet) { + write_probe_audio_dump(audio_dump.as_mut(), &packet); + pending_audio.push(packet); + } retain_newest_probe_audio(&mut pending_audio); } else if next.closed { audio_done = true; @@ -176,20 +181,26 @@ fn build_probe_bundle( capture_start_us: u64, capture_end_us: u64, ) -> UpstreamMediaBundle { - UpstreamMediaBundle { + let profile = audio + .first() + .map(audio_transport::packet_audio_profile) + .unwrap_or_else(AudioTransportProfile::pcm_s16le); + let mut bundle = UpstreamMediaBundle { session_id, seq, capture_start_us, capture_end_us, video, audio, - audio_sample_rate: 48_000, - audio_channels: 2, - audio_encoding: lesavka_common::lesavka::AudioEncoding::PcmS16le as i32, + audio_sample_rate: profile.sample_rate, + audio_channels: profile.channels, + audio_encoding: profile.encoding as i32, video_width: camera.width, video_height: camera.height, video_fps: camera.fps, - } + }; + audio_transport::mark_bundle_audio_profile(&mut bundle, profile); + bundle } /// Drain one short audio grace window when a video packet arrives first. @@ -204,6 +215,7 @@ async fn collect_probe_audio_grace( pending_audio: &mut Vec, audio_done: &mut bool, audio_seq: &mut u64, + audio_transport: &mut ProbeAudioTransport, audio_dump: Option<&mut File>, probe_start: Instant, ) { @@ -216,14 +228,65 @@ async fn collect_probe_audio_grace( }; if let Some(mut packet) = next.packet { stamp_probe_audio_packet(&mut packet, audio_seq, next.queue_depth, probe_start); - write_probe_audio_dump(audio_dump, &packet); - pending_audio.push(packet); + if let Some(packet) = audio_transport.encode_packet(packet) { + write_probe_audio_dump(audio_dump, &packet); + pending_audio.push(packet); + } retain_newest_probe_audio(pending_audio); } else if next.closed { *audio_done = true; } } +/// Runtime audio transport for synthetic probe packets. +/// +/// Inputs: `LESAVKA_UPLINK_AUDIO_CODEC` and installed GStreamer plugins. +/// Outputs: packet bytes and metadata matching the selected upstream audio +/// route. Why: the blind client->server->RCT test must exercise the same Opus +/// compression path as the live microphone, not silently measure PCM. +struct ProbeAudioTransport { + encoder: Option, +} + +impl ProbeAudioTransport { + fn from_env() -> Self { + match crate::input::audio_codec::requested_upstream_audio_codec_from_env() { + UpstreamAudioCodec::PcmS16le => Self { encoder: None }, + UpstreamAudioCodec::Opus => match crate::input::audio_codec::OpusPacketEncoder::new() { + Ok(encoder) => { + tracing::info!("🧪 sync probe Opus audio transport enabled"); + Self { + encoder: Some(encoder), + } + } + Err(err) => { + tracing::warn!( + "🧪⚠️ sync probe Opus requested but unavailable ({err:#}); falling back to PCM" + ); + Self { encoder: None } + } + }, + } + } + + fn encode_packet(&mut self, mut packet: AudioPacket) -> Option { + audio_transport::mark_packet_pcm_s16le(&mut packet); + let Some(encoder) = self.encoder.as_mut() else { + return Some(packet); + }; + match encoder.encode_packet(packet.clone()) { + Ok(Some(encoded)) => Some(encoded), + Ok(None) => None, + Err(err) => { + tracing::warn!( + "🧪⚠️ sync probe Opus encode failed; sending PCM fallback for this packet: {err:#}" + ); + Some(packet) + } + } + } +} + /// Stamp one synthetic audio packet with client-side transport telemetry. /// /// Inputs: mutable packet, sequence counter, queue depth, and probe clock. diff --git a/client/src/sync_probe/runner/bundled_transport/tests.rs b/client/src/sync_probe/runner/bundled_transport/tests.rs index c4270ba..f9b7a47 100644 --- a/client/src/sync_probe/runner/bundled_transport/tests.rs +++ b/client/src/sync_probe/runner/bundled_transport/tests.rs @@ -88,6 +88,59 @@ fn hevc_probe_bundle_preserves_paired_media_and_server_metadata() { ); } +#[test] +/// Verifies the blind sync probe can describe compressed Opus audio bundles. +/// +/// Inputs: one synthetic video packet plus a pre-compressed Opus-like audio +/// packet. Outputs: assertions over bundle metadata. Why: the client-to-RCT +/// hardware probe must not silently report PCM evidence when the operator asked +/// to validate the Opus upstream route. +fn probe_bundle_uses_opus_metadata_when_audio_packets_are_opus() { + let camera = CameraConfig { + codec: CameraCodec::Hevc, + width: 1280, + height: 720, + fps: 30, + }; + let probe_start = Instant::now(); + let mut video = VideoPacket { + pts: 1_000_000, + data: vec![0, 0, 0, 1, 0x26, 0xaa, 0xbb], + ..Default::default() + }; + let mut video_seq = 0; + stamp_probe_video_packet(&mut video, &mut video_seq, 1, camera.fps, probe_start); + + let mut audio = AudioPacket { + pts: 1_000_000, + data: vec![0x7f; 160], + frame_duration_us: 20_000, + ..Default::default() + }; + lesavka_common::audio_transport::mark_packet_opus(&mut audio); + let mut audio_seq = 0; + stamp_probe_audio_packet(&mut audio, &mut audio_seq, 1, probe_start); + + let bundle = build_probe_bundle( + PROBE_BUNDLE_SESSION_ID, + 42, + &camera, + Some(video), + vec![audio], + 1_000_000, + 1_000_000, + ); + + assert_eq!( + bundle.audio_encoding, + lesavka_common::lesavka::AudioEncoding::Opus as i32 + ); + assert_eq!(bundle.audio_sample_rate, 48_000); + assert_eq!(bundle.audio_channels, 2); + assert_eq!(bundle.audio[0].frame_duration_us, 20_000); + assert_eq!(bundle.audio[0].data.len(), 160); +} + #[test] /// Verifies a synthetic HEVC event train leaves as paired A/V bundles. /// diff --git a/client/src/sync_probe/timeline.rs b/client/src/sync_probe/timeline.rs index 11f4b74..6838f3b 100644 --- a/client/src/sync_probe/timeline.rs +++ b/client/src/sync_probe/timeline.rs @@ -13,6 +13,10 @@ use std::time::Duration; use crate::input::camera::{CameraCodec, CameraConfig}; use crate::sync_probe::schedule::PulseSchedule; +const AUDIO_SAMPLE_RATE_HZ: u32 = 48_000; +const AUDIO_CHANNEL_COUNT: u32 = 2; +const AUDIO_CHUNK_MS: u32 = 20; + #[derive(Debug, Serialize)] pub struct ProbeTimeline { schema: &'static str, @@ -72,9 +76,9 @@ impl ProbeTimeline { camera_height: camera.height, camera_fps: camera.fps, camera_codec: codec_label(camera.codec), - audio_sample_rate: 48_000, - audio_channels: 2, - audio_chunk_ms: 10, + audio_sample_rate: AUDIO_SAMPLE_RATE_HZ, + audio_channels: AUDIO_CHANNEL_COUNT, + audio_chunk_ms: AUDIO_CHUNK_MS, warmup_us: micros(schedule.warmup_boundary()), duration_us: micros(duration), pulse_period_ms: millis(schedule.pulse_period()), @@ -188,6 +192,9 @@ mod tests { ); assert_eq!(timeline.warmup_us, 4_000_000); assert_eq!(timeline.camera_codec, "mjpeg"); + assert_eq!(timeline.audio_sample_rate, 48_000); + assert_eq!(timeline.audio_channels, 2); + assert_eq!(timeline.audio_chunk_ms, 20); assert!(timeline.event_width_codes.is_empty()); assert_eq!(timeline.events.len(), 4); assert_eq!(timeline.events[0].event_id, 0); diff --git a/common/Cargo.toml b/common/Cargo.toml index d60c002..9550958 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.22.17" +version = "0.22.18" edition = "2024" build = "build.rs" diff --git a/scripts/install/server.sh b/scripts/install/server.sh index c9fd9de..a796e7a 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -72,18 +72,26 @@ DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=0 DEFAULT_MJPEG_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US=135090 DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US=1280x720@20=0,1280x720@30=0,1920x1080@20=0,1920x1080@30=0 DEFAULT_MJPEG_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US=1280x720@20=162659,1280x720@30=135090,1920x1080@20=160045,1920x1080@30=127952 +DEFAULT_MJPEG_OPUS_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=-34199 +DEFAULT_MJPEG_OPUS_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US=513850 +DEFAULT_MJPEG_OPUS_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US=1280x720@20=-34199,1280x720@30=-34199,1920x1080@20=-34199,1920x1080@30=-34199 +DEFAULT_MJPEG_OPUS_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US=1280x720@20=541419,1280x720@30=513850,1920x1080@20=538805,1920x1080@30=506712 DEFAULT_HEVC_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=0 DEFAULT_HEVC_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US=110000 DEFAULT_HEVC_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US=1280x720@20=0,1280x720@30=0,1920x1080@20=0,1920x1080@30=0 DEFAULT_HEVC_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US=1280x720@20=173852,1280x720@30=110000,1920x1080@20=160045,1920x1080@30=127952 DEFAULT_MJPEG_PCM_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=$DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US DEFAULT_MJPEG_PCM_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US=$DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US +DEFAULT_MJPEG_PCM_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US=$DEFAULT_MJPEG_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US +DEFAULT_MJPEG_PCM_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US=$DEFAULT_MJPEG_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US DEFAULT_HEVC_PCM_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=$DEFAULT_HEVC_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US DEFAULT_HEVC_PCM_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US=$DEFAULT_HEVC_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US -DEFAULT_MJPEG_OPUS_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=$DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US -DEFAULT_MJPEG_OPUS_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US=$DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US +DEFAULT_HEVC_PCM_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US=$DEFAULT_HEVC_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US +DEFAULT_HEVC_PCM_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US=$DEFAULT_HEVC_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US DEFAULT_HEVC_OPUS_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=$DEFAULT_HEVC_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US DEFAULT_HEVC_OPUS_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US=$DEFAULT_HEVC_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US +DEFAULT_HEVC_OPUS_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US=$DEFAULT_HEVC_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US +DEFAULT_HEVC_OPUS_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US=$DEFAULT_HEVC_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US LEGACY_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=-45000 PREVIOUS_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=720000 PREVIOUS_TUNED_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=1260000 @@ -1516,12 +1524,20 @@ SERVER_ENV_TMP=$(mktemp) printf 'LESAVKA_UPSTREAM_HEVC_VIDEO_PLAYOUT_OFFSET_US=%s\n' "${LESAVKA_UPSTREAM_HEVC_VIDEO_PLAYOUT_OFFSET_US:-$DEFAULT_HEVC_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US}" printf 'LESAVKA_UPSTREAM_MJPEG_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US=%s\n' "${LESAVKA_UPSTREAM_MJPEG_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US:-$DEFAULT_MJPEG_PCM_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US}" printf 'LESAVKA_UPSTREAM_MJPEG_PCM_AUDIO_PLAYOUT_OFFSET_US=%s\n' "${LESAVKA_UPSTREAM_MJPEG_PCM_AUDIO_PLAYOUT_OFFSET_US:-$DEFAULT_MJPEG_PCM_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US}" + printf 'LESAVKA_UPSTREAM_MJPEG_PCM_VIDEO_PLAYOUT_MODE_OFFSETS_US=%s\n' "${LESAVKA_UPSTREAM_MJPEG_PCM_VIDEO_PLAYOUT_MODE_OFFSETS_US:-$DEFAULT_MJPEG_PCM_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US}" + printf 'LESAVKA_UPSTREAM_MJPEG_PCM_VIDEO_PLAYOUT_OFFSET_US=%s\n' "${LESAVKA_UPSTREAM_MJPEG_PCM_VIDEO_PLAYOUT_OFFSET_US:-$DEFAULT_MJPEG_PCM_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US}" printf 'LESAVKA_UPSTREAM_HEVC_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US=%s\n' "${LESAVKA_UPSTREAM_HEVC_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US:-$DEFAULT_HEVC_PCM_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US}" printf 'LESAVKA_UPSTREAM_HEVC_PCM_AUDIO_PLAYOUT_OFFSET_US=%s\n' "${LESAVKA_UPSTREAM_HEVC_PCM_AUDIO_PLAYOUT_OFFSET_US:-$DEFAULT_HEVC_PCM_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US}" + printf 'LESAVKA_UPSTREAM_HEVC_PCM_VIDEO_PLAYOUT_MODE_OFFSETS_US=%s\n' "${LESAVKA_UPSTREAM_HEVC_PCM_VIDEO_PLAYOUT_MODE_OFFSETS_US:-$DEFAULT_HEVC_PCM_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US}" + printf 'LESAVKA_UPSTREAM_HEVC_PCM_VIDEO_PLAYOUT_OFFSET_US=%s\n' "${LESAVKA_UPSTREAM_HEVC_PCM_VIDEO_PLAYOUT_OFFSET_US:-$DEFAULT_HEVC_PCM_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US}" printf 'LESAVKA_UPSTREAM_MJPEG_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US=%s\n' "${LESAVKA_UPSTREAM_MJPEG_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US:-$DEFAULT_MJPEG_OPUS_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US}" printf 'LESAVKA_UPSTREAM_MJPEG_OPUS_AUDIO_PLAYOUT_OFFSET_US=%s\n' "${LESAVKA_UPSTREAM_MJPEG_OPUS_AUDIO_PLAYOUT_OFFSET_US:-$DEFAULT_MJPEG_OPUS_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US}" + printf 'LESAVKA_UPSTREAM_MJPEG_OPUS_VIDEO_PLAYOUT_MODE_OFFSETS_US=%s\n' "${LESAVKA_UPSTREAM_MJPEG_OPUS_VIDEO_PLAYOUT_MODE_OFFSETS_US:-$DEFAULT_MJPEG_OPUS_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US}" + printf 'LESAVKA_UPSTREAM_MJPEG_OPUS_VIDEO_PLAYOUT_OFFSET_US=%s\n' "${LESAVKA_UPSTREAM_MJPEG_OPUS_VIDEO_PLAYOUT_OFFSET_US:-$DEFAULT_MJPEG_OPUS_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US}" printf 'LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US=%s\n' "${LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US:-$DEFAULT_HEVC_OPUS_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US}" printf 'LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_OFFSET_US=%s\n' "${LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_OFFSET_US:-$DEFAULT_HEVC_OPUS_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US}" + printf 'LESAVKA_UPSTREAM_HEVC_OPUS_VIDEO_PLAYOUT_MODE_OFFSETS_US=%s\n' "${LESAVKA_UPSTREAM_HEVC_OPUS_VIDEO_PLAYOUT_MODE_OFFSETS_US:-$DEFAULT_HEVC_OPUS_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US}" + printf 'LESAVKA_UPSTREAM_HEVC_OPUS_VIDEO_PLAYOUT_OFFSET_US=%s\n' "${LESAVKA_UPSTREAM_HEVC_OPUS_VIDEO_PLAYOUT_OFFSET_US:-$DEFAULT_HEVC_OPUS_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US}" printf 'LESAVKA_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US=%s\n' "${LESAVKA_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US:-$DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US}" printf 'LESAVKA_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US=%s\n' "${LESAVKA_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US:-$DEFAULT_MJPEG_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US}" printf 'LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=%s\n' "$(resolve_upstream_audio_playout_offset_us)" diff --git a/server/Cargo.toml b/server/Cargo.toml index 4f0227f..87f60b1 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.22.17" +version = "0.22.18" edition = "2024" autobins = false diff --git a/server/src/bin/lesavka-uvc.real.inc b/server/src/bin/lesavka-uvc.real.inc index 9283ef0..0c90348 100644 --- a/server/src/bin/lesavka-uvc.real.inc +++ b/server/src/bin/lesavka-uvc.real.inc @@ -46,7 +46,8 @@ const V4L2_MEMORY_MMAP: u32 = 1; const V4L2_FIELD_NONE: u32 = 1; const V4L2_PIX_FMT_MJPEG: u32 = u32::from_le_bytes(*b"MJPG"); const MAX_MJPEG_FRAME_BYTES: usize = 8 * 1024 * 1024; -const EMPTY_MJPEG_FRAME: &[u8] = &[0xff, 0xd8, 0xff, 0xd9]; +const MINIMAL_MJPEG_FRAME: &[u8] = &[0xff, 0xd8, 0xff, 0xd9]; +const IDLE_MJPEG_FRAME: &[u8] = include_bytes!("lesavka_uvc/idle_1280x720_black.jpg"); const DEFAULT_UVC_BUFFER_COUNT: u32 = 2; const DEFAULT_UVC_IDLE_PUMP_MS: u64 = 2; const DEFAULT_UVC_FRAME_MAX_AGE_MS: u64 = 1_000; @@ -250,7 +251,7 @@ impl UvcVideoStream { fd, buffers: Vec::new(), frame_path: frame_spool_path(), - latest_frame: EMPTY_MJPEG_FRAME.to_vec(), + latest_frame: IDLE_MJPEG_FRAME.to_vec(), frame_max_bytes: MAX_MJPEG_FRAME_BYTES, streaming: false, } @@ -445,7 +446,7 @@ impl UvcVideoStream { { self.latest_frame = frame; } else if !looks_like_mjpeg_frame(&self.latest_frame) { - self.latest_frame = EMPTY_MJPEG_FRAME.to_vec(); + self.latest_frame = IDLE_MJPEG_FRAME.to_vec(); } } @@ -461,8 +462,10 @@ impl UvcVideoStream { fn frame_for_buffer(&self, buffer_len: usize) -> &[u8] { if self.latest_frame.len() <= buffer_len && looks_like_mjpeg_frame(&self.latest_frame) { &self.latest_frame + } else if IDLE_MJPEG_FRAME.len() <= buffer_len { + IDLE_MJPEG_FRAME } else { - EMPTY_MJPEG_FRAME + MINIMAL_MJPEG_FRAME } } } @@ -509,7 +512,7 @@ fn frame_spool_path() -> std::path::PathBuf { } fn looks_like_mjpeg_frame(frame: &[u8]) -> bool { - frame.len() > EMPTY_MJPEG_FRAME.len() + frame.len() > MINIMAL_MJPEG_FRAME.len() && frame.starts_with(&[0xff, 0xd8]) && frame.ends_with(&[0xff, 0xd9]) } diff --git a/server/src/bin/lesavka_uvc/coverage_model.rs b/server/src/bin/lesavka_uvc/coverage_model.rs index e54465d..32d422e 100644 --- a/server/src/bin/lesavka_uvc/coverage_model.rs +++ b/server/src/bin/lesavka_uvc/coverage_model.rs @@ -58,7 +58,9 @@ const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 10_000_000; #[cfg(coverage)] const MAX_MJPEG_FRAME_BYTES: usize = 8 * 1024 * 1024; #[cfg(coverage)] -const EMPTY_MJPEG_FRAME: &[u8] = &[0xff, 0xd8, 0xff, 0xd9]; +const MINIMAL_MJPEG_FRAME: &[u8] = &[0xff, 0xd8, 0xff, 0xd9]; +#[cfg(coverage)] +const IDLE_MJPEG_FRAME: &[u8] = include_bytes!("idle_1280x720_black.jpg"); #[cfg(coverage)] #[repr(C)] @@ -162,7 +164,7 @@ impl UvcVideoStream { Self { buffers: Vec::new(), frame_path: frame_spool_path(), - latest_frame: EMPTY_MJPEG_FRAME.to_vec(), + latest_frame: IDLE_MJPEG_FRAME.to_vec(), frame_max_bytes: MAX_MJPEG_FRAME_BYTES, } } @@ -179,7 +181,7 @@ impl UvcVideoStream { { self.latest_frame = frame; } else if !looks_like_mjpeg_frame(&self.latest_frame) { - self.latest_frame = EMPTY_MJPEG_FRAME.to_vec(); + self.latest_frame = IDLE_MJPEG_FRAME.to_vec(); } } @@ -195,8 +197,10 @@ impl UvcVideoStream { fn frame_for_buffer(&self, buffer_len: usize) -> &[u8] { if self.latest_frame.len() <= buffer_len && looks_like_mjpeg_frame(&self.latest_frame) { &self.latest_frame + } else if IDLE_MJPEG_FRAME.len() <= buffer_len { + IDLE_MJPEG_FRAME } else { - EMPTY_MJPEG_FRAME + MINIMAL_MJPEG_FRAME } } } @@ -210,7 +214,7 @@ fn frame_spool_path() -> std::path::PathBuf { #[cfg(coverage)] fn looks_like_mjpeg_frame(frame: &[u8]) -> bool { - frame.len() > EMPTY_MJPEG_FRAME.len() + frame.len() > MINIMAL_MJPEG_FRAME.len() && frame.starts_with(&[0xff, 0xd8]) && frame.ends_with(&[0xff, 0xd9]) } diff --git a/server/src/bin/lesavka_uvc/idle_1280x720_black.jpg b/server/src/bin/lesavka_uvc/idle_1280x720_black.jpg new file mode 100644 index 0000000..7c497ce Binary files /dev/null and b/server/src/bin/lesavka_uvc/idle_1280x720_black.jpg differ diff --git a/server/src/calibration.rs b/server/src/calibration.rs index 5374672..558c500 100644 --- a/server/src/calibration.rs +++ b/server/src/calibration.rs @@ -16,13 +16,20 @@ use mode_env::{current_uvc_mode, lookup_mode_offset_us}; pub use profile_offsets::{ FACTORY_HEVC_AUDIO_MODE_OFFSETS_US, FACTORY_HEVC_AUDIO_OFFSET_US, FACTORY_HEVC_OPUS_AUDIO_MODE_OFFSETS_US, FACTORY_HEVC_OPUS_AUDIO_OFFSET_US, + FACTORY_HEVC_OPUS_VIDEO_MODE_OFFSETS_US, FACTORY_HEVC_OPUS_VIDEO_OFFSET_US, FACTORY_HEVC_PCM_AUDIO_MODE_OFFSETS_US, FACTORY_HEVC_PCM_AUDIO_OFFSET_US, + FACTORY_HEVC_PCM_VIDEO_MODE_OFFSETS_US, FACTORY_HEVC_PCM_VIDEO_OFFSET_US, FACTORY_HEVC_VIDEO_MODE_OFFSETS_US, FACTORY_HEVC_VIDEO_OFFSET_1280X720_20_US, FACTORY_HEVC_VIDEO_OFFSET_1280X720_30_US, FACTORY_HEVC_VIDEO_OFFSET_1920X1080_20_US, FACTORY_HEVC_VIDEO_OFFSET_1920X1080_30_US, FACTORY_HEVC_VIDEO_OFFSET_US, FACTORY_MJPEG_AUDIO_MODE_OFFSETS_US, FACTORY_MJPEG_AUDIO_OFFSET_US, FACTORY_MJPEG_OPUS_AUDIO_MODE_OFFSETS_US, FACTORY_MJPEG_OPUS_AUDIO_OFFSET_US, + FACTORY_MJPEG_OPUS_VIDEO_MODE_OFFSETS_US, FACTORY_MJPEG_OPUS_VIDEO_OFFSET_1280X720_20_US, + FACTORY_MJPEG_OPUS_VIDEO_OFFSET_1280X720_30_US, + FACTORY_MJPEG_OPUS_VIDEO_OFFSET_1920X1080_20_US, + FACTORY_MJPEG_OPUS_VIDEO_OFFSET_1920X1080_30_US, FACTORY_MJPEG_OPUS_VIDEO_OFFSET_US, FACTORY_MJPEG_PCM_AUDIO_MODE_OFFSETS_US, FACTORY_MJPEG_PCM_AUDIO_OFFSET_US, + FACTORY_MJPEG_PCM_VIDEO_MODE_OFFSETS_US, FACTORY_MJPEG_PCM_VIDEO_OFFSET_US, FACTORY_MJPEG_VIDEO_MODE_OFFSETS_US, FACTORY_MJPEG_VIDEO_OFFSET_1280X720_20_US, FACTORY_MJPEG_VIDEO_OFFSET_1280X720_30_US, FACTORY_MJPEG_VIDEO_OFFSET_1920X1080_20_US, FACTORY_MJPEG_VIDEO_OFFSET_1920X1080_30_US, FACTORY_MJPEG_VIDEO_OFFSET_US, @@ -30,6 +37,7 @@ pub use profile_offsets::{ use profile_offsets::{ configured_profile_offset_us, current_profile, factory_audio_mode_offsets_us, factory_audio_scalar_offset_us, factory_video_mode_offsets_us, factory_video_scalar_offset_us, + normalize_calibration_profile, }; const LEGACY_FACTORY_MJPEG_AUDIO_OFFSET_US: i64 = -45_000; @@ -358,24 +366,25 @@ fn parse_snapshot(raw: &str) -> CalibrationSnapshot { /// Inputs are the typed parameters; output is the return value or side effect. fn migrate_legacy_snapshot(mut state: CalibrationSnapshot) -> CalibrationSnapshot { let current_profile = current_profile(); + let stored_profile = normalize_calibration_profile(&state.profile); let source_allows_migration = matches!(state.source.as_str(), "factory" | "env"); let confidence_allows_migration = matches!(state.confidence.as_str(), "factory" | "configured"); let detail_allows_profile_migration = state .detail .contains("loaded upstream A/V calibration defaults") || state.detail.contains("restored release-shipped"); - if state.profile != current_profile + if stored_profile != current_profile && source_allows_migration && confidence_allows_migration && detail_allows_profile_migration { let mut replacement = factory_snapshot_from_env(format!( "migrated factory upstream A/V calibration profile from {} to {}", - state.profile, current_profile + stored_profile, current_profile )); replacement.detail = format!( "migrated factory upstream A/V calibration profile from {} to {}", - state.profile, replacement.profile + stored_profile, replacement.profile ); return replacement; } @@ -391,7 +400,7 @@ fn migrate_legacy_snapshot(mut state: CalibrationSnapshot) -> CalibrationSnapsho && state.active_audio_offset_us == state.default_audio_offset_us; let untouched_legacy_video = is_stale_video_offset_us(state.default_video_offset_us) && state.active_video_offset_us == state.default_video_offset_us; - if state.profile == current_profile + if stored_profile == current_profile && source_allows_migration && confidence_allows_migration && untouched_legacy_audio @@ -403,6 +412,7 @@ fn migrate_legacy_snapshot(mut state: CalibrationSnapshot) -> CalibrationSnapsho state.active_audio_offset_us = state.factory_audio_offset_us; state.default_video_offset_us = state.factory_video_offset_us; state.active_video_offset_us = state.factory_video_offset_us; + state.profile = current_profile; state.source = "factory".to_string(); state.confidence = FACTORY_CONFIDENCE.to_string(); state.detail = format!( diff --git a/server/src/calibration/profile_offsets.rs b/server/src/calibration/profile_offsets.rs index a5baee7..534ec18 100644 --- a/server/src/calibration/profile_offsets.rs +++ b/server/src/calibration/profile_offsets.rs @@ -3,10 +3,22 @@ pub const FACTORY_MJPEG_VIDEO_OFFSET_1280X720_20_US: i64 = 162_659; pub const FACTORY_MJPEG_VIDEO_OFFSET_1280X720_30_US: i64 = 135_090; pub const FACTORY_MJPEG_VIDEO_OFFSET_1920X1080_20_US: i64 = 160_045; pub const FACTORY_MJPEG_VIDEO_OFFSET_1920X1080_30_US: i64 = 127_952; +pub const FACTORY_MJPEG_OPUS_AUDIO_OFFSET_US: i64 = -34_199; +pub const FACTORY_MJPEG_OPUS_VIDEO_OFFSET_1280X720_20_US: i64 = 541_419; +pub const FACTORY_MJPEG_OPUS_VIDEO_OFFSET_1280X720_30_US: i64 = 513_850; +pub const FACTORY_MJPEG_OPUS_VIDEO_OFFSET_1920X1080_20_US: i64 = 538_805; +pub const FACTORY_MJPEG_OPUS_VIDEO_OFFSET_1920X1080_30_US: i64 = 506_712; pub const FACTORY_MJPEG_AUDIO_MODE_OFFSETS_US: &str = "1280x720@20=0,1280x720@30=0,1920x1080@20=0,1920x1080@30=0"; pub const FACTORY_MJPEG_VIDEO_MODE_OFFSETS_US: &str = "1280x720@20=162659,1280x720@30=135090,1920x1080@20=160045,1920x1080@30=127952"; +pub const FACTORY_MJPEG_OPUS_AUDIO_MODE_OFFSETS_US: &str = + "1280x720@20=-34199,1280x720@30=-34199,1920x1080@20=-34199,1920x1080@30=-34199"; +// 1280x720@30 was measured through the blind client->server->RCT Opus path. +// The sibling MJPEG modes carry the same Opus-vs-PCM video delta until the next +// full mode matrix replaces them with direct hardware measurements. +pub const FACTORY_MJPEG_OPUS_VIDEO_MODE_OFFSETS_US: &str = + "1280x720@20=541419,1280x720@30=513850,1920x1080@20=538805,1920x1080@30=506712"; pub const FACTORY_HEVC_AUDIO_OFFSET_US: i64 = 0; pub const FACTORY_HEVC_VIDEO_OFFSET_1280X720_20_US: i64 = 173_852; pub const FACTORY_HEVC_VIDEO_OFFSET_1280X720_30_US: i64 = 110_000; @@ -18,12 +30,17 @@ pub const FACTORY_HEVC_VIDEO_MODE_OFFSETS_US: &str = "1280x720@20=173852,1280x720@30=110000,1920x1080@20=160045,1920x1080@30=127952"; pub const FACTORY_MJPEG_PCM_AUDIO_MODE_OFFSETS_US: &str = FACTORY_MJPEG_AUDIO_MODE_OFFSETS_US; pub const FACTORY_HEVC_PCM_AUDIO_MODE_OFFSETS_US: &str = FACTORY_HEVC_AUDIO_MODE_OFFSETS_US; -pub const FACTORY_MJPEG_OPUS_AUDIO_MODE_OFFSETS_US: &str = FACTORY_MJPEG_AUDIO_MODE_OFFSETS_US; pub const FACTORY_HEVC_OPUS_AUDIO_MODE_OFFSETS_US: &str = FACTORY_HEVC_AUDIO_MODE_OFFSETS_US; +pub const FACTORY_MJPEG_PCM_VIDEO_MODE_OFFSETS_US: &str = FACTORY_MJPEG_VIDEO_MODE_OFFSETS_US; +pub const FACTORY_HEVC_PCM_VIDEO_MODE_OFFSETS_US: &str = FACTORY_HEVC_VIDEO_MODE_OFFSETS_US; +pub const FACTORY_HEVC_OPUS_VIDEO_MODE_OFFSETS_US: &str = FACTORY_HEVC_VIDEO_MODE_OFFSETS_US; pub const FACTORY_MJPEG_PCM_AUDIO_OFFSET_US: i64 = FACTORY_MJPEG_AUDIO_OFFSET_US; pub const FACTORY_HEVC_PCM_AUDIO_OFFSET_US: i64 = FACTORY_HEVC_AUDIO_OFFSET_US; -pub const FACTORY_MJPEG_OPUS_AUDIO_OFFSET_US: i64 = FACTORY_MJPEG_AUDIO_OFFSET_US; pub const FACTORY_HEVC_OPUS_AUDIO_OFFSET_US: i64 = FACTORY_HEVC_AUDIO_OFFSET_US; +pub const FACTORY_MJPEG_PCM_VIDEO_OFFSET_US: i64 = FACTORY_MJPEG_VIDEO_OFFSET_US; +pub const FACTORY_HEVC_PCM_VIDEO_OFFSET_US: i64 = FACTORY_HEVC_VIDEO_OFFSET_US; +pub const FACTORY_MJPEG_OPUS_VIDEO_OFFSET_US: i64 = FACTORY_MJPEG_OPUS_VIDEO_OFFSET_1280X720_30_US; +pub const FACTORY_HEVC_OPUS_VIDEO_OFFSET_US: i64 = FACTORY_HEVC_VIDEO_OFFSET_US; // Direct UVC/UAC output-delay probes against the lab RC target showed a // per-mode sync center for MJPEG/UVC video. This is output-path compensation, // not a freshness buffer. The scalar fallback follows the default UVC mode. @@ -103,6 +120,15 @@ pub(super) fn current_profile() -> String { format!("{camera_profile}+{audio_profile}") } +/// Normalize a persisted calibration profile into the current `camera+audio` +/// shape. Why: pre-Opus snapshots stored only `mjpeg` or `hevc`, and those +/// should compare as the PCM profiles they always represented. +pub(super) fn normalize_calibration_profile(value: &str) -> String { + let camera_profile = camera_profile_from_calibration_profile(value); + let audio_profile = audio_profile_from_calibration_profile(value); + format!("{camera_profile}+{audio_profile}") +} + /// Resolve the camera side of the active calibration profile. /// /// Inputs: process environment. Output: `mjpeg` or `hevc`. Why: the camera @@ -227,9 +253,13 @@ pub(super) fn factory_audio_mode_offsets_us(profile: &str) -> &'static str { /// where HEVC decode and MJPEG re-emission can shift sync, so each ingress /// profile needs its own baked server-to-RCT center points. pub(super) fn factory_video_mode_offsets_us(profile: &str) -> &'static str { - match camera_profile_from_calibration_profile(profile).as_str() { - HEVC_PROFILE => FACTORY_HEVC_VIDEO_MODE_OFFSETS_US, - _ => FACTORY_MJPEG_VIDEO_MODE_OFFSETS_US, + let camera = camera_profile_from_calibration_profile(profile); + let audio = audio_profile_from_calibration_profile(profile); + match (camera.as_str(), audio.as_str()) { + (HEVC_PROFILE, OPUS_AUDIO_PROFILE) => FACTORY_HEVC_OPUS_VIDEO_MODE_OFFSETS_US, + (HEVC_PROFILE, _) => FACTORY_HEVC_PCM_VIDEO_MODE_OFFSETS_US, + (MJPEG_PROFILE, OPUS_AUDIO_PROFILE) => FACTORY_MJPEG_OPUS_VIDEO_MODE_OFFSETS_US, + _ => FACTORY_MJPEG_PCM_VIDEO_MODE_OFFSETS_US, } } @@ -256,8 +286,12 @@ pub(super) fn factory_audio_scalar_offset_us(profile: &str) -> i64 { /// most common calibrated profile instead of silently borrowing stale MJPEG /// values for HEVC. pub(super) fn factory_video_scalar_offset_us(profile: &str) -> i64 { - match camera_profile_from_calibration_profile(profile).as_str() { - HEVC_PROFILE => FACTORY_HEVC_VIDEO_OFFSET_US, - _ => FACTORY_MJPEG_VIDEO_OFFSET_US, + let camera = camera_profile_from_calibration_profile(profile); + let audio = audio_profile_from_calibration_profile(profile); + match (camera.as_str(), audio.as_str()) { + (HEVC_PROFILE, OPUS_AUDIO_PROFILE) => FACTORY_HEVC_OPUS_VIDEO_OFFSET_US, + (HEVC_PROFILE, _) => FACTORY_HEVC_PCM_VIDEO_OFFSET_US, + (MJPEG_PROFILE, OPUS_AUDIO_PROFILE) => FACTORY_MJPEG_OPUS_VIDEO_OFFSET_US, + _ => FACTORY_MJPEG_PCM_VIDEO_OFFSET_US, } } diff --git a/server/src/calibration/tests/mod.rs b/server/src/calibration/tests/mod.rs index 87bd0dc..599ed6b 100644 --- a/server/src/calibration/tests/mod.rs +++ b/server/src/calibration/tests/mod.rs @@ -46,6 +46,14 @@ fn with_clean_offset_env(test: impl FnOnce()) { "LESAVKA_UPSTREAM_MJPEG_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US", None::<&str>, ), + ( + "LESAVKA_UPSTREAM_MJPEG_OPUS_VIDEO_PLAYOUT_MODE_OFFSETS_US", + None::<&str>, + ), + ( + "LESAVKA_UPSTREAM_MJPEG_OPUS_VIDEO_PLAYOUT_OFFSET_US", + None::<&str>, + ), ( "LESAVKA_UPSTREAM_HEVC_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US", None::<&str>, @@ -54,6 +62,14 @@ fn with_clean_offset_env(test: impl FnOnce()) { "LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US", None::<&str>, ), + ( + "LESAVKA_UPSTREAM_HEVC_OPUS_VIDEO_PLAYOUT_MODE_OFFSETS_US", + None::<&str>, + ), + ( + "LESAVKA_UPSTREAM_HEVC_OPUS_VIDEO_PLAYOUT_OFFSET_US", + None::<&str>, + ), ( "LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_OFFSET_US", None::<&str>, @@ -253,6 +269,29 @@ fn opus_audio_profile_gets_its_own_factory_and_env_namespace() { }); } +#[test] +fn mjpeg_opus_profile_uses_blind_client_rct_factory_offsets() { + with_clean_offset_env(|| { + temp_env::with_vars( + [ + ("LESAVKA_UPLINK_CAMERA_CODEC", Some("mjpeg")), + ("LESAVKA_UPLINK_AUDIO_CODEC", Some("opus")), + ("LESAVKA_UVC_WIDTH", Some("1280")), + ("LESAVKA_UVC_HEIGHT", Some("720")), + ("LESAVKA_UVC_FPS", Some("30")), + ], + || { + let state = snapshot_from_env(); + assert_eq!(state.profile, "mjpeg+opus"); + assert_eq!(state.factory_audio_offset_us, -34_199); + assert_eq!(state.factory_video_offset_us, 513_850); + assert_eq!(state.default_audio_offset_us, -34_199); + assert_eq!(state.default_video_offset_us, 513_850); + }, + ); + }); +} + #[test] fn opus_audio_profile_specific_map_does_not_overwrite_pcm_baseline() { with_clean_offset_env(|| { @@ -546,20 +585,22 @@ fn load_migrates_untouched_legacy_factory_mjpeg_baseline() { ) .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, 0); - assert_eq!(state.default_audio_offset_us, 0); - assert_eq!(state.active_video_offset_us, FACTORY_MJPEG_VIDEO_OFFSET_US); - assert_eq!(state.default_video_offset_us, FACTORY_MJPEG_VIDEO_OFFSET_US); - assert_eq!(state.source, "factory"); - assert_eq!( - runtime.playout_offsets(), - (FACTORY_MJPEG_VIDEO_OFFSET_US, 0) - ); - assert!(state.detail.contains("migrated legacy MJPEG")); + with_clean_offset_env(|| { + 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, 0); + assert_eq!(state.default_audio_offset_us, 0); + assert_eq!(state.active_video_offset_us, FACTORY_MJPEG_VIDEO_OFFSET_US); + assert_eq!(state.default_video_offset_us, FACTORY_MJPEG_VIDEO_OFFSET_US); + assert_eq!(state.source, "factory"); + assert_eq!( + runtime.playout_offsets(), + (FACTORY_MJPEG_VIDEO_OFFSET_US, 0) + ); + assert!(state.detail.contains("migrated legacy MJPEG")); + }); }); } @@ -583,20 +624,22 @@ fn load_migrates_untouched_previous_factory_mjpeg_baseline() { ) .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, 0); - assert_eq!(state.default_audio_offset_us, 0); - assert_eq!(state.active_video_offset_us, FACTORY_MJPEG_VIDEO_OFFSET_US); - assert_eq!(state.default_video_offset_us, FACTORY_MJPEG_VIDEO_OFFSET_US); - assert_eq!(state.source, "factory"); - assert_eq!( - runtime.playout_offsets(), - (FACTORY_MJPEG_VIDEO_OFFSET_US, 0) - ); - assert!(state.detail.contains("to audio +0.0ms/video +135.1ms")); + with_clean_offset_env(|| { + 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, 0); + assert_eq!(state.default_audio_offset_us, 0); + assert_eq!(state.active_video_offset_us, FACTORY_MJPEG_VIDEO_OFFSET_US); + assert_eq!(state.default_video_offset_us, FACTORY_MJPEG_VIDEO_OFFSET_US); + assert_eq!(state.source, "factory"); + assert_eq!( + runtime.playout_offsets(), + (FACTORY_MJPEG_VIDEO_OFFSET_US, 0) + ); + assert!(state.detail.contains("to audio +0.0ms/video +135.1ms")); + }); }); } @@ -620,20 +663,22 @@ fn load_migrates_overshot_video_factory_mjpeg_baseline() { ) .expect("overshot video 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, 0); - assert_eq!(state.default_audio_offset_us, 0); - assert_eq!(state.active_video_offset_us, FACTORY_MJPEG_VIDEO_OFFSET_US); - assert_eq!(state.default_video_offset_us, FACTORY_MJPEG_VIDEO_OFFSET_US); - assert_eq!(state.source, "factory"); - assert_eq!( - runtime.playout_offsets(), - (FACTORY_MJPEG_VIDEO_OFFSET_US, 0) - ); - assert!(state.detail.contains("from audio +0.0ms/video +1090.0ms")); + with_clean_offset_env(|| { + 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, 0); + assert_eq!(state.default_audio_offset_us, 0); + assert_eq!(state.active_video_offset_us, FACTORY_MJPEG_VIDEO_OFFSET_US); + assert_eq!(state.default_video_offset_us, FACTORY_MJPEG_VIDEO_OFFSET_US); + assert_eq!(state.source, "factory"); + assert_eq!( + runtime.playout_offsets(), + (FACTORY_MJPEG_VIDEO_OFFSET_US, 0) + ); + assert!(state.detail.contains("from audio +0.0ms/video +1090.0ms")); + }); }); } diff --git a/server/src/upstream_media_runtime/tests/mod.rs b/server/src/upstream_media_runtime/tests/mod.rs index 5732cca..e183579 100644 --- a/server/src/upstream_media_runtime/tests/mod.rs +++ b/server/src/upstream_media_runtime/tests/mod.rs @@ -28,6 +28,18 @@ fn with_clean_offset_env(test: impl FnOnce()) { "LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US", None::<&str>, ), + ( + "LESAVKA_UPSTREAM_MJPEG_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US", + None::<&str>, + ), + ( + "LESAVKA_UPSTREAM_MJPEG_OPUS_VIDEO_PLAYOUT_MODE_OFFSETS_US", + None::<&str>, + ), + ( + "LESAVKA_UPSTREAM_MJPEG_OPUS_VIDEO_PLAYOUT_OFFSET_US", + None::<&str>, + ), ], test, ); @@ -110,6 +122,25 @@ fn runtime_can_start_with_opus_specific_audio_calibration_profile() { }); } +#[test] +fn runtime_starts_mjpeg_opus_with_blind_client_rct_offsets() { + with_clean_offset_env(|| { + temp_env::with_vars( + [ + ("LESAVKA_UVC_WIDTH", Some("1280")), + ("LESAVKA_UVC_HEIGHT", Some("720")), + ("LESAVKA_UVC_FPS", Some("30")), + ("LESAVKA_UPLINK_CAMERA_CODEC", Some("mjpeg")), + ("LESAVKA_UPLINK_AUDIO_CODEC", Some("opus")), + ], + || { + let runtime = UpstreamMediaRuntime::new(); + assert_eq!(runtime.playout_offsets(), (513_850, -34_199)); + }, + ); + }); +} + #[test] /// Keeps `runtime_records_client_and_sink_timing_for_upstream_snapshots` explicit because the blind client-to-RCT probe depends on this telemetry to explain freshness losses. /// Inputs are paired camera/microphone timing samples plus sink handoff marks; output is a live snapshot with skew, queue, late, and freeze fields populated. diff --git a/tests/compatibility/server/video/hevc_mjpeg_profile_matrix_contract.rs b/tests/compatibility/server/video/hevc_mjpeg_profile_matrix_contract.rs index 7765374..a701d54 100644 --- a/tests/compatibility/server/video/hevc_mjpeg_profile_matrix_contract.rs +++ b/tests/compatibility/server/video/hevc_mjpeg_profile_matrix_contract.rs @@ -9,8 +9,10 @@ use lesavka_server::calibration::{ FACTORY_HEVC_AUDIO_MODE_OFFSETS_US, FACTORY_HEVC_OPUS_AUDIO_MODE_OFFSETS_US, - FACTORY_HEVC_VIDEO_MODE_OFFSETS_US, FACTORY_MJPEG_AUDIO_MODE_OFFSETS_US, - FACTORY_MJPEG_PCM_AUDIO_MODE_OFFSETS_US, FACTORY_MJPEG_VIDEO_MODE_OFFSETS_US, + FACTORY_HEVC_OPUS_VIDEO_MODE_OFFSETS_US, FACTORY_HEVC_VIDEO_MODE_OFFSETS_US, + FACTORY_MJPEG_AUDIO_MODE_OFFSETS_US, FACTORY_MJPEG_OPUS_AUDIO_MODE_OFFSETS_US, + FACTORY_MJPEG_OPUS_VIDEO_MODE_OFFSETS_US, FACTORY_MJPEG_PCM_AUDIO_MODE_OFFSETS_US, + FACTORY_MJPEG_VIDEO_MODE_OFFSETS_US, }; const MATRIX_SCRIPT: &str = include_str!(concat!( @@ -30,8 +32,20 @@ fn hevc_and_mjpeg_factory_maps_cover_all_supported_uvc_modes() { for mode in SUPPORTED_MODES { assert!(map_contains_mode(FACTORY_MJPEG_AUDIO_MODE_OFFSETS_US, mode)); assert!(map_contains_mode(FACTORY_MJPEG_VIDEO_MODE_OFFSETS_US, mode)); + assert!(map_contains_mode( + FACTORY_MJPEG_OPUS_AUDIO_MODE_OFFSETS_US, + mode + )); + assert!(map_contains_mode( + FACTORY_MJPEG_OPUS_VIDEO_MODE_OFFSETS_US, + mode + )); assert!(map_contains_mode(FACTORY_HEVC_AUDIO_MODE_OFFSETS_US, mode)); assert!(map_contains_mode(FACTORY_HEVC_VIDEO_MODE_OFFSETS_US, mode)); + assert!(map_contains_mode( + FACTORY_HEVC_OPUS_VIDEO_MODE_OFFSETS_US, + mode + )); } } @@ -45,9 +59,17 @@ fn hevc_profile_defaults_are_separate_from_mjpeg_profile_defaults() { FACTORY_MJPEG_PCM_AUDIO_MODE_OFFSETS_US, FACTORY_HEVC_AUDIO_MODE_OFFSETS_US ); + assert_ne!( + FACTORY_MJPEG_OPUS_AUDIO_MODE_OFFSETS_US, FACTORY_MJPEG_AUDIO_MODE_OFFSETS_US, + "MJPEG+Opus should preserve the blind client-to-RCT audio calibration" + ); + assert_ne!( + FACTORY_MJPEG_OPUS_VIDEO_MODE_OFFSETS_US, FACTORY_MJPEG_VIDEO_MODE_OFFSETS_US, + "MJPEG+Opus should not silently borrow the PCM video timing map" + ); assert_eq!( FACTORY_HEVC_OPUS_AUDIO_MODE_OFFSETS_US, FACTORY_HEVC_AUDIO_MODE_OFFSETS_US, - "Opus has a separate audio map that currently inherits PCM until lab calibration lands" + "HEVC+Opus keeps the PCM audio baseline until a dedicated lab pass lands" ); } diff --git a/tests/contract/server/uvc/server_uvc_binary_extra_contract.rs b/tests/contract/server/uvc/server_uvc_binary_extra_contract.rs index a9b3139..4630edc 100644 --- a/tests/contract/server/uvc/server_uvc_binary_extra_contract.rs +++ b/tests/contract/server/uvc/server_uvc_binary_extra_contract.rs @@ -420,7 +420,12 @@ mod uvc_binary_extra { assert_eq!(stream.latest_frame, vec![0xff, 0xd8, 0x11, 0xff, 0xd9]); assert_eq!(stream.frame_payload_limit(), 8); - assert_eq!(stream.frame_for_buffer(4), EMPTY_MJPEG_FRAME); + assert_eq!(stream.frame_for_buffer(4), MINIMAL_MJPEG_FRAME); + assert_eq!( + UvcVideoStream::new(-1).frame_for_buffer(64 * 1024), + IDLE_MJPEG_FRAME + ); + assert!(IDLE_MJPEG_FRAME.len() > 4); } #[test] diff --git a/tests/installer/scripts/install/server_install_script_contract.rs b/tests/installer/scripts/install/server_install_script_contract.rs index ab00068..b91f871 100644 --- a/tests/installer/scripts/install/server_install_script_contract.rs +++ b/tests/installer/scripts/install/server_install_script_contract.rs @@ -96,6 +96,12 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { assert!(SERVER_INSTALL.contains( "DEFAULT_MJPEG_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US=1280x720@20=162659,1280x720@30=135090,1920x1080@20=160045,1920x1080@30=127952" )); + assert!(SERVER_INSTALL.contains( + "DEFAULT_MJPEG_OPUS_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US=1280x720@20=-34199,1280x720@30=-34199,1920x1080@20=-34199,1920x1080@30=-34199" + )); + assert!(SERVER_INSTALL.contains( + "DEFAULT_MJPEG_OPUS_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US=1280x720@20=541419,1280x720@30=513850,1920x1080@20=538805,1920x1080@30=506712" + )); assert!(SERVER_INSTALL.contains("DEFAULT_HEVC_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=0")); assert!(SERVER_INSTALL.contains("DEFAULT_HEVC_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US=110000")); assert!(SERVER_INSTALL.contains( @@ -105,6 +111,12 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { "DEFAULT_HEVC_OPUS_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US=$DEFAULT_HEVC_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US" )); assert!(SERVER_INSTALL.contains("LESAVKA_UPLINK_AUDIO_CODEC=%s")); + assert!( + SERVER_INSTALL.contains("LESAVKA_UPSTREAM_MJPEG_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US=%s") + ); + assert!( + SERVER_INSTALL.contains("LESAVKA_UPSTREAM_MJPEG_OPUS_VIDEO_PLAYOUT_MODE_OFFSETS_US=%s") + ); assert!(SERVER_INSTALL.contains("LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US=%s")); assert!( SERVER_INSTALL.contains("resolve_upstream_video_playout_offset_us"), diff --git a/tests/regression/install/install_preserves_calibration_contract.rs b/tests/regression/install/install_preserves_calibration_contract.rs index 1581a2c..e1f1e85 100644 --- a/tests/regression/install/install_preserves_calibration_contract.rs +++ b/tests/regression/install/install_preserves_calibration_contract.rs @@ -23,9 +23,12 @@ const PROFILE_OFFSETS: &str = include_str!(concat!( fn installer_persists_both_mjpeg_and_hevc_factory_offset_maps() { for marker in [ "DEFAULT_MJPEG_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US=1280x720@20=162659,1280x720@30=135090,1920x1080@20=160045,1920x1080@30=127952", + "DEFAULT_MJPEG_OPUS_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US=1280x720@20=-34199,1280x720@30=-34199,1920x1080@20=-34199,1920x1080@30=-34199", + "DEFAULT_MJPEG_OPUS_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US=1280x720@20=541419,1280x720@30=513850,1920x1080@20=538805,1920x1080@30=506712", "DEFAULT_HEVC_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US=1280x720@20=173852,1280x720@30=110000,1920x1080@20=160045,1920x1080@30=127952", "DEFAULT_HEVC_OPUS_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US=$DEFAULT_HEVC_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US", "LESAVKA_UPSTREAM_MJPEG_VIDEO_PLAYOUT_MODE_OFFSETS_US=%s", + "LESAVKA_UPSTREAM_MJPEG_OPUS_VIDEO_PLAYOUT_MODE_OFFSETS_US=%s", "LESAVKA_UPSTREAM_HEVC_VIDEO_PLAYOUT_MODE_OFFSETS_US=%s", "LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US=%s", "LESAVKA_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US=%s",