fix(audio): stop USB recovery flapping

This commit is contained in:
Brad Stein 2026-04-21 13:31:49 -03:00
parent c65fcd1137
commit a8a59fd538
11 changed files with 155 additions and 33 deletions

View File

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

View File

@ -733,7 +733,7 @@ fn audio_usb_auto_recover_enabled() -> bool {
"0" | "false" | "no" | "off"
)
})
.unwrap_or(true)
.unwrap_or(false)
}
#[cfg(not(coverage))]

View File

@ -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;

View File

@ -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: &gtk::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);

View File

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

View File

@ -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)");
}
}

View File

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

View File

@ -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<AudioStream> {
// NB: one *logical* speaker → id==0. A 2nd logical stream could be
// added later (for multichannel) 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<String> {
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<String> {
))
}
#[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<Option<(String, String)>> {
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");
}
}

View File

@ -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 {

View File

@ -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"

View File

@ -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 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_err(), "reopen_hid should fail without /dev/hidg*");
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);
},
);
},