From a8a59fd538d70c612913c756624dde24223e577c Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Tue, 21 Apr 2026 13:31:49 -0300 Subject: [PATCH] fix(audio): stop USB recovery flapping --- client/Cargo.toml | 2 +- client/src/app.rs | 2 +- client/src/launcher/device_test.rs | 4 +- client/src/launcher/ui_components.rs | 35 +++++---- common/Cargo.toml | 2 +- common/src/cli.rs | 2 +- server/Cargo.toml | 2 +- server/src/audio.rs | 93 ++++++++++++++++++++++- server/src/main.rs | 10 ++- testing/Cargo.toml | 1 + testing/tests/server_main_rpc_contract.rs | 35 +++++++-- 11 files changed, 155 insertions(+), 33 deletions(-) diff --git a/client/Cargo.toml b/client/Cargo.toml index 2ce98b4..c132649 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.11.35" +version = "0.11.36" edition = "2024" [dependencies] diff --git a/client/src/app.rs b/client/src/app.rs index 1d663cd..a7adbaa 100644 --- a/client/src/app.rs +++ b/client/src/app.rs @@ -733,7 +733,7 @@ fn audio_usb_auto_recover_enabled() -> bool { "0" | "false" | "no" | "off" ) }) - .unwrap_or(true) + .unwrap_or(false) } #[cfg(not(coverage))] diff --git a/client/src/launcher/device_test.rs b/client/src/launcher/device_test.rs index 98b1a9b..2184adf 100644 --- a/client/src/launcher/device_test.rs +++ b/client/src/launcher/device_test.rs @@ -11,8 +11,8 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; -const CAMERA_PREVIEW_WIDTH: i32 = 256; -const CAMERA_PREVIEW_HEIGHT: i32 = 144; +const CAMERA_PREVIEW_WIDTH: i32 = 192; +const CAMERA_PREVIEW_HEIGHT: i32 = 108; const CAMERA_PREVIEW_IDLE: &str = "Select a webcam and click Start Preview."; const MIC_MONITOR_RATE: i32 = 16_000; const MIC_MONITOR_CHANNELS: i32 = 1; diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index fd6bfc6..e5d6897 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -111,8 +111,10 @@ const LESAVKA_ICON_SEARCH_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/ass const LAUNCHER_DEFAULT_WIDTH: i32 = 1380; const LAUNCHER_DEFAULT_HEIGHT: i32 = 860; const OPERATIONS_RAIL_WIDTH: i32 = 288; -const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 144; -const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 256; +const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 108; +const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 192; +const DEVICE_PANEL_HEIGHT: i32 = 236; +const SIDE_LOG_HEIGHT: i32 = 132; pub fn build_launcher_view( app: >k::Application, @@ -201,6 +203,7 @@ pub fn build_launcher_view( staging_row.set_vexpand(false); staging_row.set_valign(gtk::Align::Start); staging_row.set_homogeneous(true); + staging_row.set_size_request(-1, DEVICE_PANEL_HEIGHT); workspace.append(&staging_row); let device_refresh_button = gtk::Button::with_label("Refresh Devices"); @@ -213,6 +216,7 @@ pub fn build_launcher_view( devices_panel.set_hexpand(true); devices_panel.set_vexpand(false); devices_panel.set_valign(gtk::Align::Fill); + devices_panel.set_size_request(-1, DEVICE_PANEL_HEIGHT); devices_body.set_spacing(8); let control_group = build_subgroup("Control Inputs"); @@ -321,14 +325,15 @@ pub fn build_launcher_view( let (preview_panel, preview_body) = build_panel("Device Testing"); preview_panel.set_hexpand(true); - preview_panel.set_vexpand(true); + preview_panel.set_vexpand(false); preview_panel.set_valign(gtk::Align::Fill); - preview_body.set_vexpand(true); + preview_panel.set_size_request(-1, DEVICE_PANEL_HEIGHT); + preview_body.set_vexpand(false); preview_body.set_spacing(8); let testing_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); testing_row.set_hexpand(true); - testing_row.set_vexpand(true); - testing_row.set_valign(gtk::Align::Fill); + testing_row.set_vexpand(false); + testing_row.set_valign(gtk::Align::Start); let camera_preview = gtk::Picture::new(); camera_preview.set_can_shrink(false); camera_preview.set_hexpand(false); @@ -364,15 +369,15 @@ pub fn build_launcher_view( camera_preview_shell.append(&camera_preview_frame); let webcam_group = build_subgroup("Webcam Preview"); webcam_group.set_hexpand(true); - webcam_group.set_vexpand(true); - webcam_group.set_valign(gtk::Align::Fill); + webcam_group.set_vexpand(false); + webcam_group.set_valign(gtk::Align::Start); webcam_group.append(&camera_preview_shell); testing_row.append(&webcam_group); let playback_group = build_subgroup("Mic Playback"); playback_group.set_hexpand(false); - playback_group.set_vexpand(true); - playback_group.set_valign(gtk::Align::Fill); + playback_group.set_vexpand(false); + playback_group.set_valign(gtk::Align::Start); playback_group.set_size_request(72, -1); let playback_body = gtk::Box::new(gtk::Orientation::Vertical, 6); playback_body.set_halign(gtk::Align::Center); @@ -384,7 +389,7 @@ pub fn build_launcher_view( audio_check_meter.set_hexpand(false); audio_check_meter.set_vexpand(false); audio_check_meter.set_halign(gtk::Align::Center); - audio_check_meter.set_size_request(20, CAMERA_PREVIEW_VIEWPORT_HEIGHT - 40); + audio_check_meter.set_size_request(20, CAMERA_PREVIEW_VIEWPORT_HEIGHT - 28); audio_check_meter.set_show_text(false); audio_check_meter.set_text(Some("Idle")); playback_body.append(&audio_check_meter); @@ -513,8 +518,8 @@ pub fn build_launcher_view( let diagnostics_scroll = gtk::ScrolledWindow::builder() .hexpand(true) .vexpand(false) - .min_content_height(150) - .max_content_height(150) + .min_content_height(SIDE_LOG_HEIGHT) + .max_content_height(SIDE_LOG_HEIGHT) .child(&diagnostics_shell) .build(); diagnostics_body.append(&diagnostics_toolbar); @@ -553,8 +558,8 @@ pub fn build_launcher_view( let log_scroll = gtk::ScrolledWindow::builder() .hexpand(true) .vexpand(false) - .min_content_height(150) - .max_content_height(150) + .min_content_height(SIDE_LOG_HEIGHT) + .max_content_height(SIDE_LOG_HEIGHT) .child(&session_log_view) .build(); console_body.append(&console_toolbar); diff --git a/common/Cargo.toml b/common/Cargo.toml index fadb9f0..c79192d 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.11.35" +version = "0.11.36" edition = "2024" build = "build.rs" diff --git a/common/src/cli.rs b/common/src/cli.rs index 8a5724e..4286b96 100644 --- a/common/src/cli.rs +++ b/common/src/cli.rs @@ -17,6 +17,6 @@ mod tests { #[test] fn banner_includes_version() { - assert_eq!(banner("0.11.35"), "lesavka-common CLI (v0.11.35)"); + assert_eq!(banner("0.11.36"), "lesavka-common CLI (v0.11.36)"); } } diff --git a/server/Cargo.toml b/server/Cargo.toml index dd32b10..ef73e30 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.11.35" +version = "0.11.36" edition = "2024" autobins = false diff --git a/server/src/audio.rs b/server/src/audio.rs index c9fece8..95e3e2d 100644 --- a/server/src/audio.rs +++ b/server/src/audio.rs @@ -9,6 +9,7 @@ use gst::MessageView::*; use gst::prelude::*; use gstreamer as gst; use gstreamer_app as gst_app; +use std::fs; use std::sync::{ Arc, Mutex, atomic::{AtomicBool, AtomicU64, Ordering}, @@ -71,6 +72,7 @@ pub async fn ear(alsa_dev: &str, id: u32) -> anyhow::Result { // NB: one *logical* speaker → id==0. A 2nd logical stream could be // added later (for multi‑channel) without changing the client. gst::init().context("gst init")?; + ensure_remote_usb_audio_ready(alsa_dev)?; /*──────────── pipeline description ──────────── * @@ -214,8 +216,7 @@ fn build_pipeline_desc(dev: &str) -> anyhow::Result { Ok(format!( concat!( - "alsasrc device=\"{dev}\" do-timestamp=true provide-clock=false ", - "use-driver-timestamps=false buffer-time=200000 latency-time=10000 ! ", + "alsasrc device=\"{dev}\" do-timestamp=true ! ", "audio/x-raw,format=S16LE,channels=2,rate=48000 ! ", "level name=source_level interval=1000000000 message=true ! ", "audioconvert ! audioresample ! {enc} bitrate=192000 ! ", @@ -230,6 +231,49 @@ fn build_pipeline_desc(dev: &str) -> anyhow::Result { )) } +#[cfg(not(coverage))] +fn ensure_remote_usb_audio_ready(alsa_dev: &str) -> anyhow::Result<()> { + if !alsa_dev_uses_remote_uac_gadget(alsa_dev) { + return Ok(()); + } + + let Some((controller, state)) = current_usb_gadget_state()? else { + return Ok(()); + }; + if state == "not attached" { + return Err(anyhow!( + "remote USB gadget is not attached (UDC {controller} state={state}); remote speaker audio cannot stream until the controlled PC enumerates Lesavka USB" + )); + } + Ok(()) +} + +#[cfg(not(coverage))] +fn alsa_dev_uses_remote_uac_gadget(alsa_dev: &str) -> bool { + matches!(alsa_dev, "hw:UAC2Gadget,0" | "hw:UAC2_Gadget,0") + || alsa_dev.contains("UAC2Gadget") + || alsa_dev.contains("UAC2_Gadget") +} + +#[cfg(not(coverage))] +fn current_usb_gadget_state() -> anyhow::Result> { + let configfs_root = std::env::var("LESAVKA_GADGET_CONFIGFS_ROOT") + .unwrap_or_else(|_| "/sys/kernel/config/usb_gadget".to_string()); + let sysfs_root = std::env::var("LESAVKA_GADGET_SYSFS_ROOT").unwrap_or_else(|_| "/sys".into()); + let udc = fs::read_to_string(format!("{configfs_root}/lesavka/UDC")) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let Some(controller) = udc else { + return Ok(None); + }; + let state = fs::read_to_string(format!("{sysfs_root}/class/udc/{controller}/state")) + .with_context(|| format!("reading UDC state for {controller}"))? + .trim() + .to_string(); + Ok(Some((controller, state))) +} + #[cfg(not(coverage))] struct AudioSourceHealth { started_at: Instant, @@ -567,3 +611,48 @@ mod tests { voice.finish(); } } + +#[cfg(all(test, not(coverage)))] +mod tests { + use super::ensure_remote_usb_audio_ready; + use temp_env::with_vars; + use tempfile::tempdir; + + #[test] + fn remote_usb_audio_reports_not_attached_gadget() { + let dir = tempdir().expect("tempdir"); + let cfg_root = dir.path().join("cfg"); + let sys_root = dir.path().join("sys"); + let udc_dir = sys_root.join("class/udc/fake-ctrl.usb"); + std::fs::create_dir_all(cfg_root.join("lesavka")).expect("cfg"); + std::fs::create_dir_all(&udc_dir).expect("udc"); + std::fs::write(cfg_root.join("lesavka/UDC"), "fake-ctrl.usb\n").expect("udc file"); + std::fs::write(udc_dir.join("state"), "not attached\n").expect("state"); + + with_vars( + [ + ( + "LESAVKA_GADGET_CONFIGFS_ROOT", + Some(cfg_root.to_string_lossy().to_string()), + ), + ( + "LESAVKA_GADGET_SYSFS_ROOT", + Some(sys_root.to_string_lossy().to_string()), + ), + ], + || { + let err = ensure_remote_usb_audio_ready("hw:UAC2Gadget,0") + .expect_err("not attached gadget should block remote speaker audio"); + assert!( + err.to_string() + .contains("remote USB gadget is not attached") + ); + }, + ); + } + + #[test] + fn remote_usb_audio_allows_non_gadget_override() { + ensure_remote_usb_audio_ready("hw:Loopback,0").expect("non-gadget override"); + } +} diff --git a/server/src/main.rs b/server/src/main.rs index b1cc90b..72232e7 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -717,7 +717,7 @@ impl Relay for Handler { let s = runtime_support::open_ear_with_retry(&dev, 0) .await - .map_err(|e| Status::internal(format!("{e:#}")))?; + .map_err(|e| remote_audio_status(format!("{e:#}")))?; Ok(Response::new(Box::pin(s))) } @@ -746,6 +746,14 @@ impl Relay for Handler { } } +fn remote_audio_status(message: String) -> Status { + if message.contains("remote USB gadget is not attached") { + Status::unavailable(message) + } else { + Status::internal(message) + } +} + #[cfg(coverage)] #[tonic::async_trait] impl Relay for Handler { diff --git a/testing/Cargo.toml b/testing/Cargo.toml index 74d91ae..a139196 100644 --- a/testing/Cargo.toml +++ b/testing/Cargo.toml @@ -35,4 +35,5 @@ tonic-reflection = "0.13" tracing = "0.1" tracing-appender = "0.2" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "ansi"] } +udev = "0.8" v4l = "0.14" diff --git a/testing/tests/server_main_rpc_contract.rs b/testing/tests/server_main_rpc_contract.rs index 735983a..0f3741f 100644 --- a/testing/tests/server_main_rpc_contract.rs +++ b/testing/tests/server_main_rpc_contract.rs @@ -64,11 +64,29 @@ mod server_main_rpc { #[test] #[serial] - fn reopen_hid_returns_error_without_hid_endpoints() { + fn reopen_hid_tolerates_missing_hid_endpoints() { let (_dir, handler) = build_handler_for_tests(); - let rt = tokio::runtime::Runtime::new().expect("runtime"); - let result = rt.block_on(handler.reopen_hid()); - assert!(result.is_err(), "reopen_hid should fail without /dev/hidg*"); + let missing_dir = tempdir().expect("missing hid dir"); + let hid_dir = missing_dir.path().join("missing"); + with_var( + "LESAVKA_HID_DIR", + Some(hid_dir.to_string_lossy().to_string()), + || { + let rt = tokio::runtime::Runtime::new().expect("runtime"); + let result = rt.block_on(handler.reopen_hid()); + assert!( + result.is_ok(), + "reopen_hid should keep the server alive while HID endpoints are absent" + ); + let endpoints = rt.block_on(async { + ( + handler.kb.lock().await.is_none(), + handler.ms.lock().await.is_none(), + ) + }); + assert_eq!(endpoints, (true, true)); + }, + ); } #[test] @@ -361,7 +379,7 @@ mod server_main_rpc { #[test] #[cfg(coverage)] #[serial] - fn reset_usb_returns_internal_error_when_reopen_fails_after_successful_cycle() { + fn reset_usb_tolerates_missing_hid_after_successful_cycle() { let dir = tempdir().expect("tempdir"); std::fs::write(dir.path().join("hidg0.bin"), "").expect("create kb file"); std::fs::write(dir.path().join("hidg1.bin"), "").expect("create ms file"); @@ -417,12 +435,13 @@ mod server_main_rpc { Some(dir.path().join("missing").to_string_lossy().to_string()), || { let rt = tokio::runtime::Runtime::new().expect("runtime"); - let err = rt + let reply = rt .block_on(async { handler.reset_usb(tonic::Request::new(Empty {})).await }) - .expect_err("reopen hid should fail after successful cycle"); - assert_eq!(err.code(), tonic::Code::Internal); + .expect("missing HID should not fail USB reset") + .into_inner(); + assert!(reply.ok); }, ); },