lesavka: steady diagnostics and broaden uplink staging

This commit is contained in:
Brad Stein 2026-04-20 12:12:29 -03:00
parent ba443371e6
commit 74893c97ae
12 changed files with 379 additions and 84 deletions

View File

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

View File

@ -21,20 +21,18 @@ impl MicrophoneCapture {
pub fn new() -> Result<Self> {
gst::init().ok(); // idempotent
/* pulsesrc (default mic) → AAC/ADTS → appsink -------------------*/
// Optional override: LESAVKA_MIC_SOURCE=<pulsedevicename>
// If not provided or not found, fall back to first non-monitor source.
let device_arg = match std::env::var("LESAVKA_MIC_SOURCE") {
Ok(s) if !s.is_empty() => match Self::pulse_source_by_substr(&s) {
Some(full) => format!("device={}", escape(full.into())),
/* preferred path: pipewiresrc; fallback: pulsesrc ----------------*/
let source_desc = match std::env::var("LESAVKA_MIC_SOURCE") {
Ok(s) if !s.is_empty() => match Self::resolve_source_desc(&s) {
Some(desc) => desc,
None => {
warn!("🎤 requested mic '{s}' not found; using default");
Self::default_source_arg()
Self::default_source_desc()
}
},
_ => Self::default_source_arg(),
_ => Self::default_source_desc(),
};
debug!("🎤 device: {device_arg}");
debug!("🎤 source: {source_desc}");
let aac = ["avenc_aac", "fdkaacenc", "faac", "opusenc"]
.into_iter()
.find(|e| gst::ElementFactory::find(e).is_some())
@ -47,7 +45,7 @@ impl MicrophoneCapture {
"aacparse ! capsfilter caps=audio/mpeg,stream-format=adts,rate=48000,channels=2"
};
let desc = format!(
"pulsesrc {device_arg} do-timestamp=true ! \
"{source_desc} ! \
audio/x-raw,format=S16LE,channels=2,rate=48000 ! \
audioconvert ! audioresample ! {aac} bitrate=128000 ! \
{parser} ! \
@ -70,7 +68,7 @@ impl MicrophoneCapture {
if s.current() == gst::State::Playing
&& msg.src().map(|s| s.is::<gst::Pipeline>()).unwrap_or(false) =>
{
info!("🎤 mic pipeline ▶️ (source=pulsesrc)")
info!("🎤 mic pipeline ▶️")
}
Error(e) => error!(
"🎤💥 mic: {} ({})",
@ -120,6 +118,64 @@ impl MicrophoneCapture {
}
}
fn resolve_source_desc(fragment: &str) -> Option<String> {
if Self::pipewire_source_available()
&& let Some(full) = Self::pipewire_source_by_substr(fragment)
{
return Some(Self::pipewire_source_desc(Some(&full)));
}
Self::pulse_source_by_substr(fragment).map(|full| Self::pulse_source_desc(Some(&full)))
}
fn pipewire_source_available() -> bool {
gst::ElementFactory::find("pipewiresrc").is_some()
}
fn pipewire_source_desc(source: Option<&str>) -> String {
match source {
Some(source) if !source.trim().is_empty() => {
format!(
"pipewiresrc target-object={} do-timestamp=true",
escape(source.to_string().into())
)
}
_ => "pipewiresrc do-timestamp=true".to_string(),
}
}
fn pulse_source_desc(source: Option<&str>) -> String {
match source {
Some(source) if !source.trim().is_empty() => {
format!(
"pulsesrc device={} do-timestamp=true",
escape(source.to_string().into())
)
}
_ => "pulsesrc do-timestamp=true".to_string(),
}
}
fn pipewire_source_by_substr(fragment: &str) -> Option<String> {
let out = std::process::Command::new("pw-dump").output().ok()?;
let list = serde_json::from_slice::<serde_json::Value>(&out.stdout).ok()?;
let objects = list.as_array()?;
objects.iter().find_map(|object| {
let props = object.get("info")?.get("props")?.as_object()?;
if props.get("media.class")?.as_str()? != "Audio/Source" {
return None;
}
let name = props
.get("node.name")
.or_else(|| props.get("node.nick"))?
.as_str()?;
if name.contains(fragment) && !name.ends_with(".monitor") {
Some(name.to_owned())
} else {
None
}
})
}
fn pulse_source_by_substr(fragment: &str) -> Option<String> {
use std::process::Command;
let out = Command::new("pactl")
@ -139,23 +195,11 @@ impl MicrophoneCapture {
})
}
/// Pick the first non-monitor Pulse source if available; otherwise empty.
fn default_source_arg() -> String {
use std::process::Command;
let out = Command::new("pactl")
.args(["list", "short", "sources"])
.output();
if let Ok(out) = out {
let list = String::from_utf8_lossy(&out.stdout);
if let Some(name) = list
.lines()
.filter_map(|ln| ln.split_whitespace().nth(1))
.find(|name| !name.ends_with(".monitor"))
{
return format!("device={}", escape(name.into()));
}
fn default_source_desc() -> String {
if Self::pipewire_source_available() {
return Self::pipewire_source_desc(None);
}
String::new()
Self::pulse_source_desc(None)
}
}

View File

@ -621,13 +621,19 @@ fn camera_preview_pipeline_desc(device: &str) -> String {
}
fn microphone_monitor_pipeline_desc(source: &str, sink: Option<&str>) -> String {
let source = gst_quote(source);
let source_element = if gst::ElementFactory::find("pipewiresrc").is_some() {
let source = gst_quote(source);
format!("pipewiresrc target-object=\"{source}\" do-timestamp=true")
} else {
let source = gst_quote(source);
format!("pulsesrc device=\"{source}\" do-timestamp=true")
};
let sink_prop = sink
.map(gst_quote)
.map(|value| format!(" device=\"{value}\""))
.unwrap_or_default();
format!(
"pulsesrc device=\"{source}\" ! \
"{source_element} ! \
audioconvert ! audioresample ! \
audio/x-raw,format=S16LE,rate={MIC_MONITOR_RATE},channels={MIC_MONITOR_CHANNELS} ! \
tee name=t \

View File

@ -1,6 +1,7 @@
use std::collections::BTreeSet;
use evdev::{AbsoluteAxisCode, Device, EventType, KeyCode, RelativeAxisCode};
use serde_json::Value;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DeviceCatalog {
@ -26,8 +27,8 @@ impl DeviceCatalog {
fn discover_with_camera_override(override_dir: Option<String>) -> Self {
let cameras = discover_camera_devices(override_dir);
let microphones = discover_pactl_devices("sources");
let speakers = discover_pactl_devices("sinks");
let microphones = discover_microphone_devices();
let speakers = discover_speaker_devices();
let keyboards = discover_input_devices(InputDeviceKind::Keyboard);
let mice = discover_input_devices(InputDeviceKind::Mouse);
Self {
@ -40,6 +41,32 @@ impl DeviceCatalog {
}
}
fn discover_microphone_devices() -> Vec<String> {
let mut set = BTreeSet::new();
for source in discover_pactl_devices("sources") {
if !source.ends_with(".monitor") {
set.insert(source);
}
}
for source in discover_pipewire_audio_nodes("Audio/Source") {
if !source.ends_with(".monitor") {
set.insert(source);
}
}
set.into_iter().collect()
}
fn discover_speaker_devices() -> Vec<String> {
let mut set = BTreeSet::new();
for sink in discover_pactl_devices("sinks") {
set.insert(sink);
}
for sink in discover_pipewire_audio_nodes("Audio/Sink") {
set.insert(sink);
}
set.into_iter().collect()
}
fn discover_camera_devices(override_dir: Option<String>) -> Vec<String> {
let dir = override_dir.unwrap_or_else(|| "/dev/v4l/by-id".to_string());
let Ok(iter) = std::fs::read_dir(dir) else {
@ -84,6 +111,49 @@ pub fn parse_pactl_short(stdout: &str) -> Vec<String> {
set.into_iter().collect()
}
fn discover_pipewire_audio_nodes(media_class: &str) -> Vec<String> {
let output = std::process::Command::new("pw-dump").output();
let Ok(output) = output else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
parse_pipewire_audio_nodes(&output.stdout, media_class)
}
pub fn parse_pipewire_audio_nodes(stdout: &[u8], media_class: &str) -> Vec<String> {
let Ok(Value::Array(objects)) = serde_json::from_slice::<Value>(stdout) else {
return Vec::new();
};
let mut set = BTreeSet::new();
for object in objects {
let Some(props) = object
.get("info")
.and_then(|info| info.get("props"))
.and_then(Value::as_object)
else {
continue;
};
if props.get("media.class").and_then(Value::as_str) != Some(media_class) {
continue;
}
let Some(name) = props
.get("node.name")
.or_else(|| props.get("node.nick"))
.or_else(|| props.get("device.name"))
.and_then(Value::as_str)
else {
continue;
};
if !name.trim().is_empty() {
set.insert(name.to_string());
}
}
set.into_iter().collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InputDeviceKind {
Keyboard,
@ -231,4 +301,48 @@ mod tests {
catalog.speakers.push("sink-1".to_string());
assert!(!catalog.is_empty());
}
#[test]
fn parse_pipewire_audio_nodes_collects_named_audio_nodes() {
let sample = br#"
[
{
"info": {
"props": {
"media.class": "Audio/Source",
"node.name": "alsa_input.usb-TestMic-00.mono-fallback"
}
}
},
{
"info": {
"props": {
"media.class": "Audio/Source",
"node.name": "bluez_input.80:C3:BA:76:26:AB"
}
}
},
{
"info": {
"props": {
"media.class": "Audio/Sink",
"node.name": "alsa_output.pci-0000_00_1f.3.analog-stereo"
}
}
}
]
"#;
assert_eq!(
parse_pipewire_audio_nodes(sample, "Audio/Source"),
vec![
"alsa_input.usb-TestMic-00.mono-fallback".to_string(),
"bluez_input.80:C3:BA:76:26:AB".to_string(),
]
);
}
#[test]
fn parse_pipewire_audio_nodes_ignores_invalid_payloads() {
assert!(parse_pipewire_audio_nodes(b"not json", "Audio/Source").is_empty());
}
}

View File

@ -16,13 +16,13 @@ use {
super::ui_components::{build_launcher_view, sync_input_device_combo, sync_stage_device_combo},
super::ui_runtime::{
RelayChild, append_session_log, apply_popout_window_size, attach_child_log_streams,
capture_swap_key, copy_session_log, dock_all_displays_to_preview, dock_display_to_preview,
input_control_path, input_state_path, next_input_routing, open_diagnostics_popout,
open_popout_window, open_session_log_popout, path_marker, present_popout_windows,
read_input_routing_state, reap_exited_child, refresh_launcher_ui, refresh_test_buttons,
routing_name, selected_combo_value, selected_server_addr, shutdown_launcher_runtime,
spawn_client_process, stop_child_process, toggle_key_label, update_test_action_result,
write_input_routing_request,
capture_swap_key, copy_plain_text, copy_session_log, dock_all_displays_to_preview,
dock_display_to_preview, input_control_path, input_state_path, next_input_routing,
open_diagnostics_popout, open_popout_window, open_session_log_popout, path_marker,
present_popout_windows, read_input_routing_state, reap_exited_child, refresh_launcher_ui,
refresh_test_buttons, routing_name, selected_combo_value, selected_server_addr,
shutdown_launcher_runtime, spawn_client_process, stop_child_process, toggle_key_label,
update_test_action_result, write_input_routing_request,
},
crate::handshake::{HandshakeProbe, probe},
crate::output::display::enumerate_monitors,
@ -1455,7 +1455,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
{
let widgets = widgets.clone();
widgets.diagnostics_copy_button.connect_clicked(move |_| {
if let Err(err) = copy_session_log(&widgets.diagnostics_buffer) {
if let Err(err) = copy_plain_text(&widgets.diagnostics_rendered_text.borrow()) {
widgets
.status_label
.set_text(&format!("Could not copy the diagnostics report: {err}"));
@ -1475,8 +1475,9 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
open_diagnostics_popout(
&app,
&diagnostics_popout,
&widgets.diagnostics_popout_label,
&widgets.diagnostics_popout_scroll,
&widgets.diagnostics_buffer,
&widgets.diagnostics_rendered_text,
);
widgets
.status_label

View File

@ -52,8 +52,9 @@ pub struct PopoutWindowHandle {
pub struct LauncherWidgets {
pub status_label: gtk::Label,
pub diagnostics_log: Rc<RefCell<DiagnosticsLog>>,
pub diagnostics_buffer: gtk::TextBuffer,
pub diagnostics_label: gtk::Label,
pub diagnostics_scroll: gtk::ScrolledWindow,
pub diagnostics_popout_label: Rc<RefCell<Option<gtk::Label>>>,
pub diagnostics_popout_scroll: Rc<RefCell<Option<gtk::ScrolledWindow>>>,
pub diagnostics_rendered_text: Rc<RefCell<String>>,
pub session_log_buffer: gtk::TextBuffer,
@ -199,19 +200,16 @@ pub fn build_launcher_view(
staging_row.set_vexpand(false);
workspace.append(&staging_row);
let (devices_panel, devices_body) = build_panel("Device Staging");
devices_panel.set_hexpand(true);
devices_panel.set_vexpand(false);
devices_body.set_spacing(8);
let devices_actions = gtk::Box::new(gtk::Orientation::Horizontal, 8);
devices_actions.set_halign(gtk::Align::End);
let device_refresh_button = gtk::Button::with_label("Refresh Devices");
stabilize_button(&device_refresh_button, 132);
device_refresh_button.set_tooltip_text(Some(
"Re-scan webcams, microphones, speakers, keyboards, and mice without restarting Lesavka.",
));
devices_actions.append(&device_refresh_button);
devices_body.append(&devices_actions);
let (devices_panel, devices_body) =
build_panel_with_action("Device Staging", Some(device_refresh_button.upcast_ref()));
devices_panel.set_hexpand(true);
devices_panel.set_vexpand(false);
devices_body.set_spacing(8);
let control_group = build_subgroup("Control Inputs");
let control_row = gtk::Box::new(gtk::Orientation::Horizontal, 12);
@ -469,18 +467,24 @@ pub fn build_launcher_view(
diagnostics_toolbar.append(&diagnostics_copy_button);
diagnostics_toolbar.append(&diagnostics_popout_button);
let diagnostics_log = Rc::new(RefCell::new(DiagnosticsLog::new(16)));
let diagnostics_buffer = gtk::TextBuffer::new(None);
let diagnostics_view = gtk::TextView::with_buffer(&diagnostics_buffer);
diagnostics_view.add_css_class("status-log");
diagnostics_view.set_editable(false);
diagnostics_view.set_cursor_visible(false);
diagnostics_view.set_monospace(true);
diagnostics_view.set_wrap_mode(gtk::WrapMode::None);
let diagnostics_label = gtk::Label::new(None);
diagnostics_label.add_css_class("status-log");
diagnostics_label.set_selectable(true);
diagnostics_label.set_xalign(0.0);
diagnostics_label.set_yalign(0.0);
diagnostics_label.set_wrap(false);
diagnostics_label.set_halign(gtk::Align::Start);
diagnostics_label.set_valign(gtk::Align::Start);
diagnostics_label.set_hexpand(true);
let diagnostics_shell = gtk::Box::new(gtk::Orientation::Vertical, 0);
diagnostics_shell.set_hexpand(true);
diagnostics_shell.set_vexpand(false);
diagnostics_shell.append(&diagnostics_label);
let diagnostics_scroll = gtk::ScrolledWindow::builder()
.hexpand(true)
.vexpand(false)
.min_content_height(190)
.child(&diagnostics_view)
.child(&diagnostics_shell)
.build();
diagnostics_body.append(&diagnostics_toolbar);
diagnostics_body.append(&diagnostics_scroll);
@ -632,13 +636,15 @@ pub fn build_launcher_view(
state.breakout_size_options(1),
state.breakout_size_preset(1),
);
let diagnostics_popout_label = Rc::new(RefCell::new(None));
let diagnostics_popout_scroll = Rc::new(RefCell::new(None));
let widgets = LauncherWidgets {
status_label: status_label.clone(),
diagnostics_log: diagnostics_log.clone(),
diagnostics_buffer: diagnostics_buffer.clone(),
diagnostics_label: diagnostics_label.clone(),
diagnostics_scroll: diagnostics_scroll.clone(),
diagnostics_popout_label: diagnostics_popout_label.clone(),
diagnostics_popout_scroll: diagnostics_popout_scroll.clone(),
diagnostics_rendered_text: Rc::new(RefCell::new(String::new())),
session_log_buffer: session_log_buffer.clone(),
@ -792,7 +798,8 @@ pub fn install_css(window: &gtk::ApplicationWindow) {
label.status-line {
opacity: 0.9;
}
textview.status-log {
textview.status-log,
label.status-log {
font-family: monospace;
background: rgba(0, 0, 0, 0.22);
border-radius: 14px;
@ -841,13 +848,25 @@ pub fn install_window_icon(window: &impl IsA<gtk::Window>) {
}
fn build_panel(title: &str) -> (gtk::Box, gtk::Box) {
build_panel_with_action(title, None)
}
fn build_panel_with_action(title: &str, action: Option<&gtk::Widget>) -> (gtk::Box, gtk::Box) {
let panel = gtk::Box::new(gtk::Orientation::Vertical, 8);
panel.add_css_class("panel");
let header = gtk::Box::new(gtk::Orientation::Horizontal, 8);
header.set_hexpand(true);
header.set_halign(gtk::Align::Fill);
let heading = gtk::Label::new(Some(title));
heading.add_css_class("panel-title");
heading.set_halign(gtk::Align::Start);
panel.append(&heading);
heading.set_hexpand(true);
header.append(&heading);
if let Some(action) = action {
header.append(action);
}
panel.append(&header);
let body = gtk::Box::new(gtk::Orientation::Vertical, 8);
panel.append(&body);

View File

@ -937,9 +937,13 @@ pub fn copy_session_log(buffer: &gtk::TextBuffer) -> Result<()> {
let text = buffer
.text(&buffer.start_iter(), &buffer.end_iter(), false)
.to_string();
copy_plain_text(&text)
}
pub fn copy_plain_text(text: &str) -> Result<()> {
let display = gtk::gdk::Display::default()
.ok_or_else(|| anyhow::anyhow!("no desktop clipboard is available in this session"))?;
display.clipboard().set_text(&text);
display.clipboard().set_text(text);
Ok(())
}
@ -993,14 +997,30 @@ pub fn refresh_diagnostics_report(
}
};
*widgets.diagnostics_rendered_text.borrow_mut() = rendered.clone();
widgets.diagnostics_buffer.set_text(&rendered);
restore_adjustment(&diagnostics_adjustment, previous_value, was_at_bottom);
if let Some((adjustment, previous_value, was_at_bottom)) = popout_state.as_ref() {
let update_docked = was_at_bottom || widgets.diagnostics_label.text().is_empty();
if update_docked {
widgets.diagnostics_label.set_text(&rendered);
restore_adjustment(&diagnostics_adjustment, previous_value, was_at_bottom);
}
let update_popout = popout_state
.as_ref()
.map(|(_, _, was_at_bottom)| *was_at_bottom)
.unwrap_or(false);
if let Some(label) = widgets.diagnostics_popout_label.borrow().as_ref()
&& (update_popout || label.text().is_empty())
{
label.set_text(&rendered);
}
if update_popout
&& let Some((adjustment, previous_value, was_at_bottom)) = popout_state.as_ref()
{
restore_adjustment(adjustment, *previous_value, *was_at_bottom);
}
glib::idle_add_local_once(move || {
restore_adjustment(&diagnostics_adjustment, previous_value, was_at_bottom);
if let Some((adjustment, previous_value, was_at_bottom)) = popout_state {
if update_docked {
restore_adjustment(&diagnostics_adjustment, previous_value, was_at_bottom);
}
if update_popout && let Some((adjustment, previous_value, was_at_bottom)) = popout_state {
restore_adjustment(&adjustment, previous_value, was_at_bottom);
}
});
@ -1025,18 +1045,82 @@ pub fn open_session_log_popout(
pub fn open_diagnostics_popout(
app: &gtk::Application,
handle: &Rc<RefCell<Option<gtk::ApplicationWindow>>>,
label_handle: &Rc<RefCell<Option<gtk::Label>>>,
scroll_handle: &Rc<RefCell<Option<gtk::ScrolledWindow>>>,
buffer: &gtk::TextBuffer,
rendered_text: &Rc<RefCell<String>>,
) {
open_text_buffer_popout(
app,
handle,
Some(scroll_handle),
buffer,
"Lesavka Diagnostics",
"Copy Report",
gtk::WrapMode::None,
);
if let Some(window) = handle.borrow().as_ref() {
window.present();
return;
}
let window = gtk::ApplicationWindow::builder()
.application(app)
.title("Lesavka Diagnostics")
.default_width(980)
.default_height(680)
.build();
super::ui_components::install_css(&window);
super::ui_components::install_window_icon(&window);
let root = gtk::Box::new(gtk::Orientation::Vertical, 10);
root.set_margin_start(14);
root.set_margin_end(14);
root.set_margin_top(14);
root.set_margin_bottom(14);
let toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 8);
let copy_button = gtk::Button::with_label("Copy Report");
toolbar.append(&copy_button);
root.append(&toolbar);
let current_text = rendered_text.borrow().clone();
let label = gtk::Label::new(Some(&current_text));
label.add_css_class("status-log");
label.set_selectable(true);
label.set_xalign(0.0);
label.set_yalign(0.0);
label.set_wrap(false);
label.set_halign(gtk::Align::Start);
label.set_valign(gtk::Align::Start);
label.set_hexpand(true);
let shell = gtk::Box::new(gtk::Orientation::Vertical, 0);
shell.set_hexpand(true);
shell.set_vexpand(false);
shell.append(&label);
let scroll = gtk::ScrolledWindow::builder()
.hexpand(true)
.vexpand(true)
.child(&shell)
.build();
*label_handle.borrow_mut() = Some(label.clone());
*scroll_handle.borrow_mut() = Some(scroll.clone());
root.append(&scroll);
window.set_child(Some(&root));
window.maximize();
{
let rendered_text = Rc::clone(rendered_text);
copy_button.connect_clicked(move |_| {
let current_text = rendered_text.borrow().clone();
let _ = copy_plain_text(&current_text);
});
}
{
let handle = Rc::clone(handle);
let label_handle = Rc::clone(label_handle);
let scroll_handle = Rc::clone(scroll_handle);
window.connect_close_request(move |_| {
handle.borrow_mut().take();
label_handle.borrow_mut().take();
scroll_handle.borrow_mut().take();
glib::Propagation::Proceed
});
}
*handle.borrow_mut() = Some(window.clone());
window.present();
}
fn open_text_buffer_popout(
@ -1165,6 +1249,7 @@ pub fn shutdown_launcher_runtime(
}
if let Some(window) = diagnostics_popout.borrow_mut().take() {
widgets.diagnostics_popout_label.borrow_mut().take();
widgets.diagnostics_popout_scroll.borrow_mut().take();
window.set_child(Option::<&gtk::Widget>::None);
window.hide();

View File

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

View File

@ -17,6 +17,6 @@ mod tests {
#[test]
fn banner_includes_version() {
assert_eq!(banner("0.11.25"), "lesavka-common CLI (v0.11.25)");
assert_eq!(banner("0.11.26"), "lesavka-common CLI (v0.11.26)");
}
}

View File

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

View File

@ -323,6 +323,9 @@ impl Voice {
.context("make alsasink")?;
alsa_sink.set_property("device", &alsa_dev);
alsa_sink.set_property("sync", false);
alsa_sink.set_property("async", false);
alsa_sink.set_property("enable-last-sample", false);
pipeline.add_many(&[
appsrc.upcast_ref(),

View File

@ -345,18 +345,36 @@ fn preferred_uac_device_candidates(preferred: &str) -> Vec<String> {
"hw:Lesavka,0",
];
let allow_aliases = auto_family.contains(&preferred);
push_audio_candidate(&mut out, &mut seen, preferred);
push_audio_candidate_family(&mut out, &mut seen, preferred);
if allow_aliases {
for alias in auto_family {
push_audio_candidate(&mut out, &mut seen, alias);
push_audio_candidate_family(&mut out, &mut seen, alias);
}
for detected in detect_uac_card_candidates() {
push_audio_candidate(&mut out, &mut seen, &detected);
push_audio_candidate_family(&mut out, &mut seen, &detected);
}
}
out
}
#[cfg(not(coverage))]
fn push_audio_candidate_family(
out: &mut Vec<String>,
seen: &mut BTreeSet<String>,
candidate: &str,
) {
let trimmed = candidate.trim();
if trimmed.is_empty() {
return;
}
push_audio_candidate(out, seen, trimmed);
if let Some(rest) = trimmed.strip_prefix("hw:") {
push_audio_candidate(out, seen, &format!("plughw:{rest}"));
} else if let Some(rest) = trimmed.strip_prefix("plughw:") {
push_audio_candidate(out, seen, &format!("hw:{rest}"));
}
}
#[cfg(not(coverage))]
fn push_audio_candidate(out: &mut Vec<String>, seen: &mut BTreeSet<String>, candidate: &str) {
let trimmed = candidate.trim();
@ -498,13 +516,18 @@ mod tests {
#[test]
fn preferred_uac_device_candidates_keeps_custom_override_only() {
let candidates = preferred_uac_device_candidates("hw:7,0");
assert_eq!(candidates, vec!["hw:7,0"]);
assert_eq!(candidates, vec!["hw:7,0", "plughw:7,0"]);
}
#[test]
fn preferred_uac_device_candidates_expands_known_aliases() {
let candidates = preferred_uac_device_candidates("hw:UAC2Gadget,0");
assert!(candidates.iter().any(|value| value == "hw:UAC2Gadget,0"));
assert!(
candidates
.iter()
.any(|value| value == "plughw:UAC2Gadget,0")
);
assert!(candidates.iter().any(|value| value == "hw:UAC2_Gadget,0"));
assert!(candidates.iter().any(|value| value == "hw:Composite,0"));
}