fix(audio): stop USB recovery flapping
This commit is contained in:
parent
c65fcd1137
commit
a8a59fd538
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.11.35"
|
||||
version = "0.11.36"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -733,7 +733,7 @@ fn audio_usb_auto_recover_enabled() -> bool {
|
||||
"0" | "false" | "no" | "off"
|
||||
)
|
||||
})
|
||||
.unwrap_or(true)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.11.35"
|
||||
version = "0.11.36"
|
||||
edition = "2024"
|
||||
build = "build.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)");
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.11.35"
|
||||
version = "0.11.36"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -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 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<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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user