fix: make UI media settings authoritative

This commit is contained in:
Brad Stein 2026-05-02 22:59:18 -03:00
parent 5c1c24038c
commit 4dea0407b8
18 changed files with 99 additions and 80 deletions

View File

@ -655,3 +655,22 @@ Context: manual Tethys testing showed the desktop was awake and HDMI was on, but
- [x] Add a first-frame watchdog for eye capture streams so opened-but-empty sources surface as explicit diagnostics.
- [ ] Re-run a manual two-eye session and confirm right-eye failures now appear in the session log with the concrete source error.
- [ ] If `eye-r` still reports `poll error ... EINVAL`, recover/reseat the right HDMI capture path or add a dedicated eye-capture soft recovery path separate from UVC/UAC.
## 0.17.36 Call Media Stability Checklist
Context: a manual Google Meet test on 0.17.34/0.17.35 was much worse than the earlier baseline:
remote audio sounded like choppy chunks/clicks and the video was visibly choppy. Live Theia
configuration showed the installer-generated UVC gadget was advertising `640x480 @ 20fps`, the
client camera pipeline was using that server profile as the outgoing packet profile even when the UI
selected `720p@30`, and the UAC speech path used a very tight 20ms/5ms sink buffer/latency with
downstream appsrc dropping.
- [x] Treat probe/analyzer measurements as suspect until the copied Tethys capture is visually and audibly stable.
- [x] Make UI-selected camera quality king: the launcher camera profile now drives the outgoing uplink profile by default instead of being downscaled to stale server caps.
- [x] Keep an explicit `LESAVKA_CAM_LOCK_TO_SERVER_PROFILE=1` lab escape hatch for debugging the server UVC gadget contract.
- [x] Restore generated UVC gadget fallback defaults to `1280x720 @ 30fps` for sessions without an explicit UI/session profile.
- [x] Align runtime UVC fallback defaults with the generated 30fps gadget profile.
- [x] Raise UAC speech sink buffering and appsrc limits so speech favors intelligibility over bare-minimum latency under jitter.
- [x] Stop default downstream appsrc leaking on the UAC speech path; shredded chunks are worse than modest added latency for calls.
- [ ] Reinstall/restart Theia services so `/etc/lesavka/uvc.env` is refreshed from `640x480 @ 20fps` to `1280x720 @ 30fps`.
- [ ] Re-run manual Google Meet before trusting mirrored probe calibration; verify speech is intelligible and video cadence is stable by eye.

6
Cargo.lock generated
View File

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

View File

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

View File

@ -237,7 +237,7 @@ impl LesavkaClientApp {
width = cfg.width,
height = cfg.height,
fps = cfg.fps,
"📸 using camera settings from server"
"📸 using server camera caps as codec/fallback; launcher camera quality remains authoritative"
);
}
let ep = vid_ep.clone();

View File

@ -82,7 +82,7 @@ mod tests {
("LESAVKA_CAM_WIDTH", Some("1280")),
("LESAVKA_CAM_HEIGHT", Some("720")),
("LESAVKA_CAM_FPS", Some("30")),
("LESAVKA_CAM_ALLOW_PROFILE_OVERRIDE", None),
("LESAVKA_CAM_LOCK_TO_SERVER_PROFILE", None),
],
|| assert_eq!(resolved_capture_profile(Some(cfg)), (1280, 720, 30)),
);
@ -90,8 +90,8 @@ mod tests {
#[test]
#[serial]
/// Guards the browser-facing UVC contract against launcher quality selection.
fn negotiated_output_profile_uses_server_uvc_contract_by_default() {
/// UI-selected launcher quality is the source of truth for the camera uplink.
fn negotiated_output_profile_follows_launcher_quality_by_default() {
let cfg = CameraConfig {
codec: CameraCodec::Mjpeg,
width: 640,
@ -103,35 +103,7 @@ mod tests {
("LESAVKA_CAM_WIDTH", Some("1280")),
("LESAVKA_CAM_HEIGHT", Some("720")),
("LESAVKA_CAM_FPS", Some("30")),
("LESAVKA_CAM_ALLOW_PROFILE_OVERRIDE", None),
],
|| {
let capture_profile = resolved_capture_profile(Some(cfg));
assert_eq!(capture_profile, (1280, 720, 30));
assert_eq!(
resolved_output_profile(Some(cfg), capture_profile),
(640, 480, 20)
);
},
);
}
#[test]
#[serial]
/// Keeps the explicit lab override available for controlled camera debugging.
fn explicit_profile_override_keeps_lab_mode_available() {
let cfg = CameraConfig {
codec: CameraCodec::Mjpeg,
width: 640,
height: 480,
fps: 20,
};
temp_env::with_vars(
[
("LESAVKA_CAM_WIDTH", Some("1280")),
("LESAVKA_CAM_HEIGHT", Some("720")),
("LESAVKA_CAM_FPS", Some("30")),
("LESAVKA_CAM_ALLOW_PROFILE_OVERRIDE", Some("1")),
("LESAVKA_CAM_LOCK_TO_SERVER_PROFILE", None),
],
|| {
let capture_profile = resolved_capture_profile(Some(cfg));
@ -143,4 +115,32 @@ mod tests {
},
);
}
#[test]
#[serial]
/// Keeps the explicit lab lock available for controlled gadget debugging.
fn explicit_server_profile_lock_keeps_lab_mode_available() {
let cfg = CameraConfig {
codec: CameraCodec::Mjpeg,
width: 640,
height: 480,
fps: 20,
};
temp_env::with_vars(
[
("LESAVKA_CAM_WIDTH", Some("1280")),
("LESAVKA_CAM_HEIGHT", Some("720")),
("LESAVKA_CAM_FPS", Some("30")),
("LESAVKA_CAM_LOCK_TO_SERVER_PROFILE", Some("1")),
],
|| {
let capture_profile = resolved_capture_profile(Some(cfg));
assert_eq!(capture_profile, (1280, 720, 30));
assert_eq!(
resolved_output_profile(Some(cfg), capture_profile),
(640, 480, 20)
);
},
);
}
}

View File

@ -291,7 +291,7 @@ fn resolved_output_profile(
capture_profile: (u32, u32, u32),
) -> (u32, u32, u32) {
match cfg {
Some(cfg) if !env_flag_enabled("LESAVKA_CAM_ALLOW_PROFILE_OVERRIDE") => {
Some(cfg) if env_flag_enabled("LESAVKA_CAM_LOCK_TO_SERVER_PROFILE") => {
(cfg.width, cfg.height, cfg.fps.max(1))
}
_ => capture_profile,

View File

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

View File

@ -162,7 +162,7 @@ UVC_MAXBURST=${LESAVKA_UVC_MAXBURST:-1}
UVC_INTERVAL=${LESAVKA_UVC_INTERVAL:-}
UVC_WIDTH=${LESAVKA_UVC_WIDTH:-1280}
UVC_HEIGHT=${LESAVKA_UVC_HEIGHT:-720}
UVC_FPS=${LESAVKA_UVC_FPS:-25}
UVC_FPS=${LESAVKA_UVC_FPS:-30}
UVC_DISABLE_IRQ=${LESAVKA_UVC_DISABLE_IRQ:-}
UVC_BULK=${LESAVKA_UVC_BULK:-}
UVC_CODEC=${LESAVKA_UVC_CODEC:-yuyv}

View File

@ -82,10 +82,10 @@ render_uvc_env_file() {
LESAVKA_UVC_DEBUG=${LESAVKA_UVC_DEBUG:-1}
LESAVKA_UVC_MAXPACKET=${LESAVKA_UVC_MAXPACKET:-1024}
LESAVKA_UVC_LIMIT_PCT=${LESAVKA_UVC_LIMIT_PCT:-100}
LESAVKA_UVC_FPS=${LESAVKA_UVC_FPS:-20}
LESAVKA_UVC_INTERVAL=${LESAVKA_UVC_INTERVAL:-500000}
LESAVKA_UVC_WIDTH=${LESAVKA_UVC_WIDTH:-640}
LESAVKA_UVC_HEIGHT=${LESAVKA_UVC_HEIGHT:-480}
LESAVKA_UVC_FPS=${LESAVKA_UVC_FPS:-30}
LESAVKA_UVC_INTERVAL=${LESAVKA_UVC_INTERVAL:-333333}
LESAVKA_UVC_WIDTH=${LESAVKA_UVC_WIDTH:-1280}
LESAVKA_UVC_HEIGHT=${LESAVKA_UVC_HEIGHT:-720}
LESAVKA_UVC_CODEC=${INSTALL_UVC_CODEC}
LESAVKA_UVC_BLOCKING=${LESAVKA_UVC_BLOCKING:-1}
LESAVKA_UVC_CONTROL_READ_ONLY=${LESAVKA_UVC_CONTROL_READ_ONLY:-0}

View File

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

View File

@ -68,11 +68,11 @@ fn voice_input_caps() -> gst::Caps {
}
fn voice_sink_buffer_time_us() -> i64 {
positive_voice_sink_timing_env("LESAVKA_UAC_BUFFER_TIME_US", 20_000)
positive_voice_sink_timing_env("LESAVKA_UAC_BUFFER_TIME_US", 120_000)
}
fn voice_sink_latency_time_us() -> i64 {
positive_voice_sink_timing_env("LESAVKA_UAC_LATENCY_TIME_US", 5_000)
positive_voice_sink_timing_env("LESAVKA_UAC_LATENCY_TIME_US", 40_000)
}
fn voice_sink_compensation_us() -> i64 {
@ -129,15 +129,15 @@ fn voice_sink_delay_queue_enabled(compensation_us: i64) -> bool {
}
fn voice_appsrc_max_buffers() -> u64 {
positive_voice_appsrc_limit_env("LESAVKA_UAC_APP_MAX_BUFFERS", 8)
positive_voice_appsrc_limit_env("LESAVKA_UAC_APP_MAX_BUFFERS", 16)
}
fn voice_appsrc_max_bytes() -> u64 {
positive_voice_appsrc_limit_env("LESAVKA_UAC_APP_MAX_BYTES", 32_768)
positive_voice_appsrc_limit_env("LESAVKA_UAC_APP_MAX_BYTES", 65_536)
}
fn voice_appsrc_max_time_ns() -> u64 {
positive_voice_appsrc_limit_env("LESAVKA_UAC_APP_MAX_TIME_NS", 80_000_000)
positive_voice_appsrc_limit_env("LESAVKA_UAC_APP_MAX_TIME_NS", 200_000_000)
}
fn positive_voice_appsrc_limit_env(name: &str, default: u64) -> u64 {
@ -163,7 +163,7 @@ fn configure_voice_appsrc(appsrc: &gst_app::AppSrc) {
appsrc.set_property("max-time", voice_appsrc_max_time_ns());
}
if appsrc.has_property("leaky-type", None) {
appsrc.set_property_from_str("leaky-type", "downstream");
appsrc.set_property_from_str("leaky-type", "none");
}
}
@ -282,7 +282,7 @@ impl Voice {
compensation_us,
delay_queue_enabled,
clock_align_enabled,
"🎤 UAC sink low-latency timing armed"
"🎤 UAC sink call-stability timing armed"
);
if clock_align_enabled {
crate::media_timing::prepare_pipeline_clock_sync(&pipeline);
@ -407,8 +407,8 @@ mod voice_sink_timing_tests {
temp_env::with_var_unset("LESAVKA_UAC_HDMI_COMPENSATION_US", || {
temp_env::with_var("LESAVKA_CAM_OUTPUT", Some("uvc"), || {
update_camera_config();
assert_eq!(voice_sink_buffer_time_us(), 20_000);
assert_eq!(voice_sink_latency_time_us(), 5_000);
assert_eq!(voice_sink_buffer_time_us(), 120_000);
assert_eq!(voice_sink_latency_time_us(), 40_000);
assert_eq!(voice_sink_compensation_us(), 0);
});
});
@ -418,13 +418,13 @@ mod voice_sink_timing_tests {
}
#[test]
fn voice_appsrc_limits_default_to_a_short_freshness_window() {
fn voice_appsrc_limits_default_to_a_call_stability_window() {
temp_env::with_var_unset("LESAVKA_UAC_APP_MAX_BUFFERS", || {
temp_env::with_var_unset("LESAVKA_UAC_APP_MAX_BYTES", || {
temp_env::with_var_unset("LESAVKA_UAC_APP_MAX_TIME_NS", || {
assert_eq!(voice_appsrc_max_buffers(), 8);
assert_eq!(voice_appsrc_max_bytes(), 32_768);
assert_eq!(voice_appsrc_max_time_ns(), 80_000_000);
assert_eq!(voice_appsrc_max_buffers(), 16);
assert_eq!(voice_appsrc_max_bytes(), 65_536);
assert_eq!(voice_appsrc_max_time_ns(), 200_000_000);
});
});
});
@ -445,9 +445,9 @@ mod voice_sink_timing_tests {
temp_env::with_var("LESAVKA_UAC_APP_MAX_BUFFERS", Some("0"), || {
temp_env::with_var("LESAVKA_UAC_APP_MAX_BYTES", Some("nope"), || {
temp_env::with_var("LESAVKA_UAC_APP_MAX_TIME_NS", Some("0"), || {
assert_eq!(voice_appsrc_max_buffers(), 8);
assert_eq!(voice_appsrc_max_bytes(), 32_768);
assert_eq!(voice_appsrc_max_time_ns(), 80_000_000);
assert_eq!(voice_appsrc_max_buffers(), 16);
assert_eq!(voice_appsrc_max_bytes(), 65_536);
assert_eq!(voice_appsrc_max_time_ns(), 200_000_000);
});
});
});
@ -471,8 +471,8 @@ mod voice_sink_timing_tests {
temp_env::with_var("LESAVKA_UAC_BUFFER_TIME_US", Some("0"), || {
temp_env::with_var("LESAVKA_UAC_LATENCY_TIME_US", Some("-5"), || {
temp_env::with_var("LESAVKA_UAC_COMPENSATION_US", Some("166667"), || {
assert_eq!(voice_sink_buffer_time_us(), 20_000);
assert_eq!(voice_sink_latency_time_us(), 5_000);
assert_eq!(voice_sink_buffer_time_us(), 120_000);
assert_eq!(voice_sink_latency_time_us(), 40_000);
assert_eq!(voice_sink_compensation_us(), 166_667);
});
});
@ -484,8 +484,8 @@ mod voice_sink_timing_tests {
temp_env::with_var("LESAVKA_UAC_COMPENSATION_US", Some("-5"), || {
temp_env::with_var("LESAVKA_UAC_BUFFER_TIME_US", Some("0"), || {
temp_env::with_var("LESAVKA_UAC_LATENCY_TIME_US", Some("-5"), || {
assert_eq!(voice_sink_buffer_time_us(), 20_000);
assert_eq!(voice_sink_latency_time_us(), 5_000);
assert_eq!(voice_sink_buffer_time_us(), 120_000);
assert_eq!(voice_sink_latency_time_us(), 40_000);
assert_eq!(voice_sink_compensation_us(), 0);
});
});

View File

@ -670,7 +670,7 @@ impl UvcConfig {
fn from_env() -> Self {
let width = env_u32("LESAVKA_UVC_WIDTH", 1280);
let height = env_u32("LESAVKA_UVC_HEIGHT", 720);
let fps = env_u32("LESAVKA_UVC_FPS", 25).max(1);
let fps = env_u32("LESAVKA_UVC_FPS", 30).max(1);
let interval = env_u32("LESAVKA_UVC_INTERVAL", 0);
let mut max_packet = env_u32("LESAVKA_UVC_MAXPACKET", 1024);
let frame_size = env_u32("LESAVKA_UVC_FRAME_SIZE", width * height * 2);

View File

@ -37,7 +37,7 @@ impl UvcConfig {
fn from_env() -> Self {
let width = env_u32("LESAVKA_UVC_WIDTH", 1280);
let height = env_u32("LESAVKA_UVC_HEIGHT", 720);
let fps = env_u32("LESAVKA_UVC_FPS", 25).max(1);
let fps = env_u32("LESAVKA_UVC_FPS", 30).max(1);
let frame_size = env_u32("LESAVKA_UVC_FRAME_SIZE", width * height * 2);
let interval = env_u32("LESAVKA_UVC_INTERVAL", 0);
let bulk = env::var("LESAVKA_UVC_BULK").is_ok();

View File

@ -152,7 +152,7 @@ fn select_uvc_config() -> CameraConfig {
}
})
})
.unwrap_or(25);
.unwrap_or(30);
let codec = select_uvc_codec(None);
CameraConfig {
@ -191,7 +191,7 @@ fn select_uvc_config() -> CameraConfig {
}
})
})
.unwrap_or(25);
.unwrap_or(30);
let codec = select_uvc_codec(Some(&uvc_env));
CameraConfig {

View File

@ -130,7 +130,7 @@ impl UvcConfig {
pub(crate) fn from_env() -> Self {
let width = env_u32("LESAVKA_UVC_WIDTH", 1280);
let height = env_u32("LESAVKA_UVC_HEIGHT", 720);
let fps = env_u32("LESAVKA_UVC_FPS", 25).max(1);
let fps = env_u32("LESAVKA_UVC_FPS", 30).max(1);
let interval = env_u32("LESAVKA_UVC_INTERVAL", 0);
let mut max_packet = env_u32("LESAVKA_UVC_MAXPACKET", 1024);
let frame_size = env_u32("LESAVKA_UVC_FRAME_SIZE", width * height * 2);

View File

@ -106,10 +106,10 @@ fn relay_address_locks_but_media_staging_stays_available_while_relay_is_live() {
)
);
assert!(UI_RUNTIME_SRC.contains("Soft-pause or resume this feed in the running relay"));
assert!(UI_SRC.contains("Camera selection staged for the next relay launch"));
assert!(UI_SRC.contains("Camera quality staged for the next relay launch"));
assert!(UI_SRC.contains("Microphone selection staged for the next relay launch"));
assert!(UI_SRC.contains("Speaker selection staged for the next relay launch"));
assert!(UI_SRC.contains("{feed_label} selection is staged for the next relay launch"));
assert!(UI_SRC.contains("Camera quality"));
assert!(UI_SRC.contains("Microphone"));
assert!(UI_SRC.contains("Speaker"));
assert!(UI_RUNTIME_SRC.contains("\"Connect\""));
assert!(UI_RUNTIME_SRC.contains("\"Disconnect\""));
}
@ -170,7 +170,7 @@ fn launcher_webcam_quality_selection_reaches_preview_and_relay_env() {
assert!(DEVICE_TEST_SRC.contains("build_camera_preview_pipeline(&device, mode)"));
assert!(DEVICE_TEST_SRC.contains("capsfilter caps=\\\"video/x-raw"));
assert!(CAMERA_SRC.contains("fn resolved_capture_profile"));
assert!(CAMERA_SRC.contains("LESAVKA_CAM_ALLOW_PROFILE_OVERRIDE"));
assert!(CAMERA_SRC.contains("LESAVKA_CAM_LOCK_TO_SERVER_PROFILE"));
assert!(LAUNCHER_MOD_SRC.contains("\"LESAVKA_CAM_WIDTH\""));
assert!(LAUNCHER_MOD_SRC.contains("\"LESAVKA_CAM_H264_KBIT\""));
}

View File

@ -65,7 +65,7 @@ fn camera_config_zero_interval_falls_back_to_default_fps() {
assert_eq!(cfg.codec, CameraCodec::Mjpeg);
assert_eq!(cfg.width, 800);
assert_eq!(cfg.height, 600);
assert_eq!(cfg.fps, 25);
assert_eq!(cfg.fps, 30);
});
});
});
@ -169,7 +169,7 @@ fn camera_config_defaults_when_uvc_dimensions_and_rate_are_missing() {
assert_eq!(cfg.codec, CameraCodec::Mjpeg);
assert_eq!(cfg.width, 1280);
assert_eq!(cfg.height, 720);
assert_eq!(cfg.fps, 25);
assert_eq!(cfg.fps, 30);
});
});
});

View File

@ -93,9 +93,9 @@ fn server_install_pins_hdmi_camera_and_display_defaults() {
assert!(SERVER_INSTALL.contains("${LESAVKA_UPSTREAM_STALE_DROP_MS:-80}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_INSTALL_SERVER_BIND_ADDR:-0.0.0.0:50051}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_MAXPACKET:-1024}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_INTERVAL:-500000}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_WIDTH:-640}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_HEIGHT:-480}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_INTERVAL:-333333}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_WIDTH:-1280}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_HEIGHT:-720}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_CONTROL_READ_ONLY:-0}"));
assert!(
!SERVER_INSTALL.contains("LESAVKA_UVC_CODEC=${LESAVKA_UVC_CODEC:-mjpeg}"),