diff --git a/client/Cargo.toml b/client/Cargo.toml index 3fbed48..7d601e1 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.11.25" +version = "0.11.26" edition = "2024" [dependencies] diff --git a/client/src/input/microphone.rs b/client/src/input/microphone.rs index 3e88de4..641a44a 100644 --- a/client/src/input/microphone.rs +++ b/client/src/input/microphone.rs @@ -21,20 +21,18 @@ impl MicrophoneCapture { pub fn new() -> Result { gst::init().ok(); // idempotent - /* pulsesrc (default mic) → AAC/ADTS → appsink -------------------*/ - // Optional override: LESAVKA_MIC_SOURCE= - // 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::()).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 { + 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 { + let out = std::process::Command::new("pw-dump").output().ok()?; + let list = serde_json::from_slice::(&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 { 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) } } diff --git a/client/src/launcher/device_test.rs b/client/src/launcher/device_test.rs index b15e58b..d197036 100644 --- a/client/src/launcher/device_test.rs +++ b/client/src/launcher/device_test.rs @@ -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 \ diff --git a/client/src/launcher/devices.rs b/client/src/launcher/devices.rs index da4b6c2..b5450ae 100644 --- a/client/src/launcher/devices.rs +++ b/client/src/launcher/devices.rs @@ -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) -> 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 { + 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 { + 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) -> Vec { 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 { set.into_iter().collect() } +fn discover_pipewire_audio_nodes(media_class: &str) -> Vec { + 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 { + let Ok(Value::Array(objects)) = serde_json::from_slice::(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()); + } } diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index f835604..6736c55 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -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 diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index fb4e912..f1b0030 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -52,8 +52,9 @@ pub struct PopoutWindowHandle { pub struct LauncherWidgets { pub status_label: gtk::Label, pub diagnostics_log: Rc>, - pub diagnostics_buffer: gtk::TextBuffer, + pub diagnostics_label: gtk::Label, pub diagnostics_scroll: gtk::ScrolledWindow, + pub diagnostics_popout_label: Rc>>, pub diagnostics_popout_scroll: Rc>>, pub diagnostics_rendered_text: Rc>, 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: >k::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) { } fn build_panel(title: &str) -> (gtk::Box, gtk::Box) { + build_panel_with_action(title, None) +} + +fn build_panel_with_action(title: &str, action: Option<>k::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); diff --git a/client/src/launcher/ui_runtime.rs b/client/src/launcher/ui_runtime.rs index 78dfcd4..d637782 100644 --- a/client/src/launcher/ui_runtime.rs +++ b/client/src/launcher/ui_runtime.rs @@ -937,9 +937,13 @@ pub fn copy_session_log(buffer: >k::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: >k::Application, handle: &Rc>>, + label_handle: &Rc>>, scroll_handle: &Rc>>, - buffer: >k::TextBuffer, + rendered_text: &Rc>, ) { - 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(©_button); + root.append(&toolbar); + + let current_text = rendered_text.borrow().clone(); + let label = gtk::Label::new(Some(¤t_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(¤t_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::<>k::Widget>::None); window.hide(); diff --git a/common/Cargo.toml b/common/Cargo.toml index 82addd5..40a76a0 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.11.25" +version = "0.11.26" edition = "2024" build = "build.rs" diff --git a/common/src/cli.rs b/common/src/cli.rs index 6741fd3..c7f3156 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.25"), "lesavka-common CLI (v0.11.25)"); + assert_eq!(banner("0.11.26"), "lesavka-common CLI (v0.11.26)"); } } diff --git a/server/Cargo.toml b/server/Cargo.toml index 5a48acb..2fa3e32 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.11.25" +version = "0.11.26" edition = "2024" autobins = false diff --git a/server/src/audio.rs b/server/src/audio.rs index 6750b77..abfaeab 100644 --- a/server/src/audio.rs +++ b/server/src/audio.rs @@ -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(), diff --git a/server/src/runtime_support.rs b/server/src/runtime_support.rs index f01cb1f..59b5a3f 100644 --- a/server/src/runtime_support.rs +++ b/server/src/runtime_support.rs @@ -345,18 +345,36 @@ fn preferred_uac_device_candidates(preferred: &str) -> Vec { "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, + seen: &mut BTreeSet, + 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, seen: &mut BTreeSet, 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")); }