From a2e9496071e4872e84f2fa3d669e461c7c0fc91a Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Tue, 21 Apr 2026 20:19:47 -0300 Subject: [PATCH] fix(audio): add remote gain control --- client/Cargo.toml | 2 +- client/src/input/inputs.rs | 19 +-- client/src/launcher/diagnostics.rs | 4 + client/src/launcher/mod.rs | 14 ++ client/src/launcher/state.rs | 50 ++++++- client/src/launcher/ui.rs | 57 ++++++-- client/src/launcher/ui_components.rs | 37 +++++ client/src/launcher/ui_runtime.rs | 32 ++++ client/src/output/audio.rs | 138 +++++++++++++----- common/Cargo.toml | 2 +- server/Cargo.toml | 2 +- server/src/audio.rs | 24 ++- server/src/runtime_support.rs | 7 - testing/Cargo.toml | 1 + .../tests/client_camera_include_contract.rs | 7 +- .../tests/client_launcher_layout_contract.rs | 9 ++ .../client_microphone_include_contract.rs | 19 ++- .../client_output_audio_include_contract.rs | 70 +++++++-- .../tests/server_gadget_include_contract.rs | 52 +++++++ testing/tests/server_main_rpc_contract.rs | 27 ++-- 20 files changed, 470 insertions(+), 103 deletions(-) diff --git a/client/Cargo.toml b/client/Cargo.toml index 3b97c14..57138ce 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.11.43" +version = "0.11.44" edition = "2024" [dependencies] diff --git a/client/src/input/inputs.rs b/client/src/input/inputs.rs index 92d3f62..775ecf9 100644 --- a/client/src/input/inputs.rs +++ b/client/src/input/inputs.rs @@ -355,6 +355,10 @@ impl InputAggregator { let quick_toggle_now = self.quick_toggle_active(); self.observe_quick_toggle(quick_toggle_now); + if self.remote_failsafe_expired() { + self.begin_local_release(); + } + if self.pending_release || self.pending_kill { let chord_released = if self.pending_keys.is_empty() { !self @@ -368,18 +372,9 @@ impl InputAggregator { }; if chord_released { - for k in &mut self.keyboards { - k.set_grab(false); - k.reset_state(); - } - for m in &mut self.mice { - m.set_grab(false); - m.reset_state(); - } - self.released = true; - self.pending_release = false; - self.pending_keys.clear(); - if self.pending_kill { + let pending_kill = self.pending_kill; + self.finish_local_release(!pending_kill); + if pending_kill { return Ok(()); } } diff --git a/client/src/launcher/diagnostics.rs b/client/src/launcher/diagnostics.rs index 9b870ae..8434e0d 100644 --- a/client/src/launcher/diagnostics.rs +++ b/client/src/launcher/diagnostics.rs @@ -140,6 +140,7 @@ pub struct SnapshotReport { pub selected_camera: Option, pub selected_microphone: Option, pub selected_speaker: Option, + pub audio_gain_label: String, pub selected_keyboard: Option, pub selected_mouse: Option, pub status: String, @@ -354,6 +355,7 @@ impl SnapshotReport { selected_camera: state.devices.camera.clone(), selected_microphone: state.devices.microphone.clone(), selected_speaker: state.devices.speaker.clone(), + audio_gain_label: state.audio_gain_label(), selected_keyboard: state.devices.keyboard.clone(), selected_mouse: state.devices.mouse.clone(), status: state.status_line(), @@ -465,6 +467,7 @@ impl SnapshotReport { " speaker: {}", self.selected_speaker.as_deref().unwrap_or("auto") ); + let _ = writeln!(text, " audio gain: {}", self.audio_gain_label); let _ = writeln!( text, " keyboard: {}", @@ -875,6 +878,7 @@ mod tests { Some("alsa_input.usb") ); assert_eq!(report.selected_speaker.as_deref(), Some("alsa_output.usb")); + assert_eq!(report.audio_gain_label, "200%"); assert_eq!( report.selected_keyboard.as_deref(), Some("/dev/input/event10") diff --git a/client/src/launcher/mod.rs b/client/src/launcher/mod.rs index 064fc00..c23d21f 100644 --- a/client/src/launcher/mod.rs +++ b/client/src/launcher/mod.rs @@ -62,6 +62,10 @@ pub fn runtime_env_vars(state: &LauncherState) -> BTreeMap { .filter(|value| value.trim().parse::().is_ok()) .unwrap_or_else(|| DEFAULT_REMOTE_INPUT_FAILSAFE_SECONDS.to_string()), ); + envs.insert( + "LESAVKA_AUDIO_GAIN".to_string(), + state.audio_gain_env_value(), + ); if matches!(state.view_mode, ViewMode::Unified) { envs.insert("LESAVKA_DISABLE_VIDEO_RENDER".to_string(), "1".to_string()); } @@ -174,6 +178,7 @@ mod tests { envs.get("LESAVKA_CLIPBOARD_DELAY_MS"), Some(&"18".to_string()) ); + assert_eq!(envs.get("LESAVKA_AUDIO_GAIN"), Some(&"2.000".to_string())); assert_eq!( envs.get(REMOTE_INPUT_FAILSAFE_SECONDS_ENV), Some(&DEFAULT_REMOTE_INPUT_FAILSAFE_SECONDS.to_string()) @@ -273,6 +278,15 @@ mod tests { assert!(!envs.contains_key("LESAVKA_AUDIO_SINK")); } + #[test] + fn runtime_env_vars_emit_selected_audio_gain() { + let mut state = LauncherState::new(); + state.set_audio_gain_percent(425); + + let envs = runtime_env_vars(&state); + assert_eq!(envs.get("LESAVKA_AUDIO_GAIN"), Some(&"4.250".to_string())); + } + #[test] fn runtime_env_vars_disable_uplink_media_when_unstaged() { let state = LauncherState::new(); diff --git a/client/src/launcher/state.rs b/client/src/launcher/state.rs index 6360ab0..b1cc296 100644 --- a/client/src/launcher/state.rs +++ b/client/src/launcher/state.rs @@ -5,6 +5,9 @@ use lesavka_common::eye_source::{ EyeSourceMode, default_eye_source_mode, display_size_for_source_mode, native_eye_source_modes, }; +pub const DEFAULT_AUDIO_GAIN_PERCENT: u32 = 200; +pub const MAX_AUDIO_GAIN_PERCENT: u32 = 800; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum InputRouting { Local, @@ -314,6 +317,7 @@ pub struct LauncherState { pub capture_bitrates_kbit: [u32; 2], pub breakout_sizes: [BreakoutSizePreset; 2], pub devices: DeviceSelection, + pub audio_gain_percent: u32, pub swap_key: String, pub swap_key_binding: bool, pub swap_key_binding_token: u64, @@ -339,6 +343,7 @@ impl Default for LauncherState { capture_bitrates_kbit: [18_000, 18_000], breakout_sizes: [BreakoutSizePreset::Source, BreakoutSizePreset::Source], devices: DeviceSelection::default(), + audio_gain_percent: DEFAULT_AUDIO_GAIN_PERCENT, swap_key: "pause".to_string(), swap_key_binding: false, swap_key_binding_token: 0, @@ -638,6 +643,22 @@ impl LauncherState { self.devices.speaker = normalize_selection(speaker); } + pub fn set_audio_gain_percent(&mut self, percent: u32) { + self.audio_gain_percent = normalize_audio_gain_percent(percent); + } + + pub fn audio_gain_multiplier(&self) -> f64 { + self.audio_gain_percent as f64 / 100.0 + } + + pub fn audio_gain_env_value(&self) -> String { + format!("{:.3}", self.audio_gain_multiplier()) + } + + pub fn audio_gain_label(&self) -> String { + format_audio_gain_percent(self.audio_gain_percent) + } + pub fn select_keyboard(&mut self, keyboard: Option) { self.devices.keyboard = normalize_selection(keyboard); } @@ -704,7 +725,7 @@ impl LauncherState { pub fn status_line(&self) -> String { format!( - "server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} mic={} speaker={} kbd={} mouse={} swap={}", + "server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} mic={} speaker={} audio_gain={} kbd={} mouse={} swap={}", self.server_available, match self.routing { InputRouting::Local => "local", @@ -729,6 +750,7 @@ impl LauncherState { self.devices.camera.as_deref().unwrap_or("auto"), self.devices.microphone.as_deref().unwrap_or("auto"), self.devices.speaker.as_deref().unwrap_or("auto"), + self.audio_gain_label(), self.devices.keyboard.as_deref().unwrap_or("all"), self.devices.mouse.as_deref().unwrap_or("all"), self.swap_key, @@ -736,6 +758,14 @@ impl LauncherState { } } +pub fn normalize_audio_gain_percent(percent: u32) -> u32 { + percent.min(MAX_AUDIO_GAIN_PERCENT) +} + +pub fn format_audio_gain_percent(percent: u32) -> String { + format!("{}%", normalize_audio_gain_percent(percent)) +} + fn breakout_size_choice( physical_limit: PreviewSourceSize, display_fill: PreviewSourceSize, @@ -981,6 +1011,9 @@ mod tests { assert!(state.devices.speaker.is_none()); assert!(state.devices.keyboard.is_none()); assert!(state.devices.mouse.is_none()); + assert_eq!(state.audio_gain_percent, DEFAULT_AUDIO_GAIN_PERCENT); + assert_eq!(state.audio_gain_env_value(), "2.000"); + assert_eq!(state.audio_gain_label(), "200%"); assert_eq!(state.capture_power.unit, "relay.service"); assert_eq!(state.capture_power.mode, "auto"); } @@ -1080,6 +1113,20 @@ mod tests { assert!(fresh.devices.speaker.is_none()); } + #[test] + fn audio_gain_is_clamped_and_formatted_for_launcher_and_runtime() { + let mut state = LauncherState::new(); + state.set_audio_gain_percent(350); + assert_eq!(state.audio_gain_percent, 350); + assert_eq!(state.audio_gain_label(), "350%"); + assert_eq!(state.audio_gain_env_value(), "3.500"); + + state.set_audio_gain_percent(10_000); + assert_eq!(state.audio_gain_percent, MAX_AUDIO_GAIN_PERCENT); + assert_eq!(state.audio_gain_label(), "800%"); + assert_eq!(state.audio_gain_env_value(), "8.000"); + } + #[test] fn start_and_stop_remote_only_report_changes_once() { let mut state = LauncherState::new(); @@ -1117,6 +1164,7 @@ mod tests { assert!(status.contains("camera=/dev/video0")); assert!(status.contains("mic=alsa_input.usb")); assert!(status.contains("speaker=alsa_output.usb")); + assert!(status.contains("audio_gain=200%")); assert!(status.contains("kbd=/dev/input/event-kbd")); assert!(status.contains("mouse=/dev/input/event-mouse")); } diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index 8ed2e18..39fdb0c 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -11,19 +11,19 @@ use { super::power::{fetch_capture_power, reset_usb_gadget, set_capture_power_mode}, super::state::{ BreakoutSizePreset, CapturePowerStatus, CaptureSizePreset, DisplaySurface, - FeedSourcePreset, InputRouting, LauncherState, + FeedSourcePreset, InputRouting, LauncherState, MAX_AUDIO_GAIN_PERCENT, }, 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_plain_text, copy_session_log, dock_all_displays_to_preview, - dock_display_to_preview, input_control_path, input_state_path, input_toggle_control_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, - write_input_toggle_key_request, + audio_gain_control_path, capture_swap_key, copy_plain_text, copy_session_log, + dock_all_displays_to_preview, dock_display_to_preview, input_control_path, + input_state_path, input_toggle_control_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_audio_gain_request, write_input_routing_request, write_input_toggle_key_request, }, crate::handshake::{HandshakeProbe, probe}, crate::output::display::enumerate_monitors, @@ -1071,6 +1071,45 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { }); } + { + let state = Rc::clone(&state); + let widgets = widgets.clone(); + let child_proc = Rc::clone(&child_proc); + let audio_gain_scale = widgets.audio_gain_scale.clone(); + audio_gain_scale.connect_value_changed(move |scale| { + let percent = scale + .value() + .round() + .clamp(0.0, MAX_AUDIO_GAIN_PERCENT as f64) + as u32; + let label = { + let mut state = state.borrow_mut(); + if state.audio_gain_percent == percent { + return; + } + state.set_audio_gain_percent(percent); + state.audio_gain_label() + }; + widgets.audio_gain_value.set_text(&label); + if child_proc.borrow().is_some() { + let path = audio_gain_control_path(); + match write_audio_gain_request(&path, percent) { + Ok(()) => widgets + .status_label + .set_text(&format!("Remote audio gain set to {label}.")), + Err(err) => widgets.status_label.set_text(&format!( + "Remote audio gain set to {label} for the next relay launch, but live gain control could not be written: {err}" + )), + } + } else { + widgets.status_label.set_text(&format!( + "Remote audio gain set to {label} for the next relay launch." + )); + } + refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); + }); + } + { let state = Rc::clone(&state); let widgets = widgets.clone(); diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index c560a90..68ba735 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -68,6 +68,8 @@ pub struct LauncherWidgets { pub power_auto_button: gtk::Button, pub power_on_button: gtk::Button, pub power_off_button: gtk::Button, + pub audio_gain_scale: gtk::Scale, + pub audio_gain_value: gtk::Label, pub input_toggle_button: gtk::Button, pub clipboard_button: gtk::Button, pub probe_button: gtk::Button, @@ -481,6 +483,39 @@ pub fn build_launcher_view( power_row.append(&power_off_button); power_shell.append(&power_row); connection_body.append(&power_shell); + let audio_heading = gtk::Label::new(Some("Remote Audio")); + audio_heading.add_css_class("subgroup-title"); + audio_heading.set_halign(gtk::Align::Start); + connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal)); + connection_body.append(&audio_heading); + let audio_gain_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + audio_gain_row.set_hexpand(true); + let audio_gain_label = gtk::Label::new(Some("Gain")); + audio_gain_label.add_css_class("dim-label"); + audio_gain_label.set_halign(gtk::Align::Start); + let audio_gain_adjustment = gtk::Adjustment::new( + state.audio_gain_percent as f64, + 0.0, + super::state::MAX_AUDIO_GAIN_PERCENT as f64, + 25.0, + 100.0, + 0.0, + ); + let audio_gain_scale = + gtk::Scale::new(gtk::Orientation::Horizontal, Some(&audio_gain_adjustment)); + audio_gain_scale.set_draw_value(false); + audio_gain_scale.set_hexpand(true); + audio_gain_scale.set_tooltip_text(Some( + "Boost or lower remote speaker playback on this client. Changes apply live while the relay is connected.", + )); + let audio_gain_value = gtk::Label::new(Some(&state.audio_gain_label())); + audio_gain_value.add_css_class("dim-label"); + audio_gain_value.set_width_chars(5); + audio_gain_value.set_xalign(1.0); + audio_gain_row.append(&audio_gain_label); + audio_gain_row.append(&audio_gain_scale); + audio_gain_row.append(&audio_gain_value); + connection_body.append(&audio_gain_row); let routing_heading = gtk::Label::new(Some("Input Routing")); routing_heading.add_css_class("subgroup-title"); routing_heading.set_halign(gtk::Align::Start); @@ -713,6 +748,8 @@ pub fn build_launcher_view( power_auto_button: power_auto_button.clone(), power_on_button: power_on_button.clone(), power_off_button: power_off_button.clone(), + audio_gain_scale: audio_gain_scale.clone(), + audio_gain_value: audio_gain_value.clone(), input_toggle_button: input_toggle_button.clone(), clipboard_button: clipboard_button.clone(), probe_button: probe_button.clone(), diff --git a/client/src/launcher/ui_runtime.rs b/client/src/launcher/ui_runtime.rs index 929305f..25fcfbc 100644 --- a/client/src/launcher/ui_runtime.rs +++ b/client/src/launcher/ui_runtime.rs @@ -24,9 +24,11 @@ use super::{ pub const INPUT_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_INPUT_CONTROL"; pub const INPUT_STATE_ENV: &str = "LESAVKA_LAUNCHER_INPUT_STATE"; pub const TOGGLE_KEY_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_TOGGLE_KEY_CONTROL"; +pub const AUDIO_GAIN_CONTROL_ENV: &str = "LESAVKA_AUDIO_GAIN_CONTROL"; pub const DEFAULT_INPUT_CONTROL_PATH: &str = "/tmp/lesavka-launcher-input.control"; pub const DEFAULT_INPUT_STATE_PATH: &str = "/tmp/lesavka-launcher-input.state"; pub const DEFAULT_TOGGLE_KEY_CONTROL_PATH: &str = "/tmp/lesavka-launcher-toggle-key.control"; +pub const DEFAULT_AUDIO_GAIN_CONTROL_PATH: &str = "/tmp/lesavka-audio-gain.control"; pub type RelayChild = Child; @@ -76,6 +78,12 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi widgets .power_detail .set_text(&capture_power_detail(&state.capture_power)); + if (widgets.audio_gain_scale.value() - state.audio_gain_percent as f64).abs() > f64::EPSILON { + widgets + .audio_gain_scale + .set_value(state.audio_gain_percent as f64); + } + widgets.audio_gain_value.set_text(&state.audio_gain_label()); widgets.start_button.set_label(if relay_live { "Disconnect Relay" } else { @@ -761,6 +769,12 @@ pub fn input_toggle_control_path() -> PathBuf { .unwrap_or_else(|_| PathBuf::from(DEFAULT_TOGGLE_KEY_CONTROL_PATH)) } +pub fn audio_gain_control_path() -> PathBuf { + std::env::var(AUDIO_GAIN_CONTROL_ENV) + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(DEFAULT_AUDIO_GAIN_CONTROL_PATH)) +} + pub fn write_input_routing_request(path: &Path, routing: InputRouting) -> Result<()> { std::fs::write( path, @@ -769,6 +783,12 @@ pub fn write_input_routing_request(path: &Path, routing: InputRouting) -> Result Ok(()) } +pub fn write_audio_gain_request(path: &Path, gain_percent: u32) -> Result<()> { + let gain = gain_percent.min(super::state::MAX_AUDIO_GAIN_PERCENT) as f64 / 100.0; + std::fs::write(path, format!("{gain:.3} {}\n", control_request_nonce()))?; + Ok(()) +} + pub fn write_input_toggle_key_request(path: &Path, swap_key: &str) -> Result<()> { std::fs::write( path, @@ -927,6 +947,9 @@ pub fn spawn_client_process( command.env(TOGGLE_KEY_CONTROL_ENV, input_toggle_control_path); command.env("LESAVKA_DISABLE_VIDEO_RENDER", "1"); command.env("LESAVKA_CLIPBOARD_PASTE", "1"); + let audio_gain_path = audio_gain_control_path(); + let _ = write_audio_gain_request(&audio_gain_path, state.audio_gain_percent); + command.env(AUDIO_GAIN_CONTROL_ENV, audio_gain_path); for (key, value) in runtime_env_vars(state) { command.env(key, value); } @@ -1461,6 +1484,15 @@ mod tests { assert!(tags.contains(&"log-error") || tags.contains(&"log-warn")); } + #[test] + fn write_audio_gain_request_formats_live_control_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("gain.control"); + write_audio_gain_request(&path, 425).expect("write gain"); + let raw = std::fs::read_to_string(path).expect("read gain"); + assert!(raw.starts_with("4.250 "), "{raw}"); + } + #[gtk::test] #[serial] fn dock_all_displays_to_preview_closes_popouts_and_resets_surfaces() { diff --git a/client/src/output/audio.rs b/client/src/output/audio.rs index 17cb4f3..d24c208 100644 --- a/client/src/output/audio.rs +++ b/client/src/output/audio.rs @@ -5,11 +5,22 @@ use gst::MessageView::*; use gst::prelude::*; use gstreamer as gst; use gstreamer_app as gst_app; -use std::sync::Mutex; +use std::{ + fs as std_fs, + path::{Path as StdPath, PathBuf}, + sync::Mutex, + thread, + time::Duration, +}; use tracing::{debug, error, info, warn}; use lesavka_common::lesavka::AudioPacket; +const AUDIO_GAIN_ENV: &str = "LESAVKA_AUDIO_GAIN"; +const AUDIO_GAIN_CONTROL_ENV: &str = "LESAVKA_AUDIO_GAIN_CONTROL"; +const DEFAULT_AUDIO_GAIN: f64 = 2.0; +const MAX_AUDIO_GAIN: f64 = 8.0; + pub struct AudioOut { pipeline: gst::Pipeline, src: gst_app::AppSrc, @@ -29,31 +40,10 @@ impl AudioOut { let tee_dump = std::env::var("LESAVKA_TAP_AUDIO") .ok() .as_deref() - .map(|v| v == "1") - .unwrap_or(false); - let mut pipe = format!( - "appsrc name=src is-live=true format=time do-timestamp=true \ - block=false ! \ - queue max-size-time=500000000 max-size-bytes=0 max-size-buffers=0 ! \ - aacparse ! avdec_aac ! \ - audioconvert ! audioresample ! \ - audio/x-raw,format=S16LE,channels=2,rate=48000 ! \ - level name=remote_audio_level interval=1000000000 message=true ! \ - queue max-size-time=400000000 max-size-bytes=0 max-size-buffers=0 ! {}", - sink, - ); + .is_some_and(|v| v == "1"); + let gain = audio_gain_from_env(); + let pipe = audio_output_pipeline_desc(&sink, gain, tee_dump); if tee_dump { - pipe = format!( - "appsrc name=src is-live=true format=time do-timestamp=true block=false ! \ - tee name=t ! \ - queue max-size-time=500000000 max-size-bytes=0 max-size-buffers=0 ! \ - aacparse ! avdec_aac ! audioconvert ! audioresample ! \ - audio/x-raw,format=S16LE,channels=2,rate=48000 ! \ - level name=remote_audio_level interval=1000000000 message=true ! \ - queue max-size-time=400000000 max-size-bytes=0 max-size-buffers=0 ! {} \ - t. ! queue ! filesink location=/tmp/lesavka-audio.aac", - sink, - ); warn!("💾 tee to /tmp/lesavka-audio.aac enabled (LESAVKA_TAP_AUDIO=1)"); } let pipeline: gst::Pipeline = gst::parse::launch(&pipe)? @@ -65,16 +55,20 @@ impl AudioOut { .expect("no src element") .downcast::() .expect("src not an AppSrc"); + let volume = pipeline + .by_name("remote_audio_gain") + .expect("remote_audio_gain"); src.set_caps(Some( &gst::Caps::builder("audio/mpeg") - .field("mpegversion", &4i32) // AAC - .field("stream-format", &"adts") // ADTS frames - .field("rate", &48_000i32) // 48 kHz - .field("channels", &2i32) // stereo + .field("mpegversion", 4i32) // AAC + .field("stream-format", "adts") // ADTS frames + .field("rate", 48_000i32) // 48 kHz + .field("channels", 2i32) // stereo .build(), )); src.set_format(gst::Format::Time); + maybe_spawn_audio_gain_control(volume); #[cfg(not(coverage))] { @@ -84,13 +78,13 @@ impl AudioOut { match msg.view() { Error(e) => error!( "💥 gst error from {:?}: {} ({})", - msg.src().map(|s| s.path_string()), + msg.src().map(gst::prelude::GstObjectExt::path_string), e.error(), e.debug().unwrap_or_default() ), Warning(w) => warn!( "⚠️ gst warning from {:?}: {} ({})", - msg.src().map(|s| s.path_string()), + msg.src().map(gst::prelude::GstObjectExt::path_string), w.error(), w.debug().unwrap_or_default() ), @@ -104,12 +98,14 @@ impl AudioOut { } } StateChanged(s) if s.current() == gst::State::Playing => { - if msg.src().map(|s| s.is::()).unwrap_or(false) { - info!("🔊 audio pipeline ▶️ (sink='{}')", sink); + if msg.src().is_some_and(|s| s.is::()) { + info!("🔊 audio pipeline ▶️ (sink='{sink}' gain={gain:.2}x)"); } else { debug!( "🔊 element {} now ▶️", - msg.src().map(|s| s.name()).unwrap_or_default() + msg.src() + .map(gst::prelude::GstObjectExt::name) + .unwrap_or_default() ); } } @@ -142,6 +138,80 @@ impl AudioOut { } } +fn audio_output_pipeline_desc(sink: &str, gain: f64, tee_dump: bool) -> String { + let gain = format_audio_gain_for_gst(gain); + if tee_dump { + return format!( + "appsrc name=src is-live=true format=time do-timestamp=true block=false ! \ + tee name=t ! \ + queue max-size-time=500000000 max-size-bytes=0 max-size-buffers=0 ! \ + aacparse ! avdec_aac ! audioconvert ! audioresample ! \ + audio/x-raw,format=S16LE,channels=2,rate=48000 ! \ + volume name=remote_audio_gain volume={gain} ! \ + level name=remote_audio_level interval=1000000000 message=true ! \ + queue max-size-time=400000000 max-size-bytes=0 max-size-buffers=0 ! {sink} \ + t. ! queue ! filesink location=/tmp/lesavka-audio.aac" + ); + } + format!( + "appsrc name=src is-live=true format=time do-timestamp=true \ + block=false ! \ + queue max-size-time=500000000 max-size-bytes=0 max-size-buffers=0 ! \ + aacparse ! avdec_aac ! \ + audioconvert ! audioresample ! \ + audio/x-raw,format=S16LE,channels=2,rate=48000 ! \ + volume name=remote_audio_gain volume={gain} ! \ + level name=remote_audio_level interval=1000000000 message=true ! \ + queue max-size-time=400000000 max-size-bytes=0 max-size-buffers=0 ! {sink}" + ) +} + +fn audio_gain_from_env() -> f64 { + std::env::var(AUDIO_GAIN_ENV) + .ok() + .and_then(|raw| parse_audio_gain(&raw)) + .unwrap_or(DEFAULT_AUDIO_GAIN) +} + +fn parse_audio_gain(raw: &str) -> Option { + let value = raw.split_ascii_whitespace().next()?.parse::().ok()?; + value.is_finite().then_some(clamp_audio_gain(value)) +} + +fn clamp_audio_gain(value: f64) -> f64 { + value.clamp(0.0, MAX_AUDIO_GAIN) +} + +fn format_audio_gain_for_gst(gain: f64) -> String { + format!("{:.3}", clamp_audio_gain(gain)) +} + +fn maybe_spawn_audio_gain_control(volume: gst::Element) { + let Ok(path) = std::env::var(AUDIO_GAIN_CONTROL_ENV) else { + return; + }; + let path = PathBuf::from(path); + thread::spawn(move || { + let mut last_gain = None; + loop { + if let Some(gain) = read_audio_gain_control(&path) + && last_gain != Some(gain) + { + volume.set_property("volume", gain); + last_gain = Some(gain); + info!("🔊 remote audio gain set to {gain:.2}x"); + } + thread::sleep(Duration::from_millis(250)); + } + }); +} + +fn read_audio_gain_control(path: &StdPath) -> Option { + std_fs::read_to_string(path) + .ok() + .and_then(|raw| parse_audio_gain(&raw)) +} + fn live_audio_buffer(pkt: AudioPacket, timeline: &Mutex) -> gst::Buffer { let buf = gst::Buffer::from_slice(pkt.data); if let Ok(mut timeline) = timeline.lock() { diff --git a/common/Cargo.toml b/common/Cargo.toml index 132a0fa..025773d 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.11.43" +version = "0.11.44" edition = "2024" build = "build.rs" diff --git a/server/Cargo.toml b/server/Cargo.toml index e9dbe60..f78245e 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.11.43" +version = "0.11.44" edition = "2024" autobins = false diff --git a/server/src/audio.rs b/server/src/audio.rs index 95e3e2d..3cc2af4 100644 --- a/server/src/audio.rs +++ b/server/src/audio.rs @@ -614,10 +614,32 @@ mod tests { #[cfg(all(test, not(coverage)))] mod tests { - use super::ensure_remote_usb_audio_ready; + use super::{build_pipeline_desc, ensure_remote_usb_audio_ready}; use temp_env::with_vars; use tempfile::tempdir; + #[test] + fn speaker_downlink_pipeline_keeps_aac_adts_transport_and_level_probe() { + let _ = super::gst::init(); + let result = build_pipeline_desc("hw:Loopback,0"); + match result { + Ok(desc) => { + assert!(desc.contains("alsasrc device=\"hw:Loopback,0\"")); + assert!(desc.contains("audio/x-raw,format=S16LE,channels=2,rate=48000")); + assert!(desc.contains("aacparse")); + assert!(desc.contains("stream-format=adts")); + assert!(desc.contains("level name=source_level")); + assert!(desc.contains("appsink name=asink")); + } + Err(err) => { + assert!( + err.to_string().contains("no AAC encoder plugin available"), + "unexpected build failure: {err:#}" + ); + } + } + } + #[test] fn remote_usb_audio_reports_not_attached_gadget() { let dir = tempdir().expect("tempdir"); diff --git a/server/src/runtime_support.rs b/server/src/runtime_support.rs index afa93ac..21e1edb 100644 --- a/server/src/runtime_support.rs +++ b/server/src/runtime_support.rs @@ -363,7 +363,6 @@ fn audio_init_retry_policy() -> (u32, u64) { (attempts, delay_ms) } -#[cfg(not(coverage))] fn preferred_uac_device_candidates(preferred: &str) -> Vec { let mut out = Vec::new(); let mut seen = BTreeSet::new(); @@ -386,7 +385,6 @@ fn preferred_uac_device_candidates(preferred: &str) -> Vec { out } -#[cfg(not(coverage))] fn push_audio_candidate_family( out: &mut Vec, seen: &mut BTreeSet, @@ -404,7 +402,6 @@ fn push_audio_candidate_family( } } -#[cfg(not(coverage))] fn push_audio_candidate(out: &mut Vec, seen: &mut BTreeSet, candidate: &str) { let trimmed = candidate.trim(); if trimmed.is_empty() { @@ -415,7 +412,6 @@ fn push_audio_candidate(out: &mut Vec, seen: &mut BTreeSet, cand } } -#[cfg(not(coverage))] fn detect_uac_card_candidates() -> Vec { let mut out = Vec::new(); let mut seen = BTreeSet::new(); @@ -438,7 +434,6 @@ fn detect_uac_card_candidates() -> Vec { out } -#[cfg(not(coverage))] fn parse_uac_named_card_candidates(cards: &str) -> Vec { cards .lines() @@ -459,7 +454,6 @@ fn parse_uac_named_card_candidates(cards: &str) -> Vec { .collect() } -#[cfg(not(coverage))] fn parse_uac_numeric_card_ids(cards: &str) -> BTreeSet { cards .lines() @@ -480,7 +474,6 @@ fn parse_uac_numeric_card_ids(cards: &str) -> BTreeSet { .collect() } -#[cfg(not(coverage))] fn parse_uac_pcm_candidates(pcm: &str, numeric_card_ids: &BTreeSet) -> Vec { pcm.lines() .filter_map(|line| { diff --git a/testing/Cargo.toml b/testing/Cargo.toml index a139196..cb48c69 100644 --- a/testing/Cargo.toml +++ b/testing/Cargo.toml @@ -25,6 +25,7 @@ gstreamer-video = { version = "0.23", features = ["v1_22"] } gtk = { version = "0.8", package = "gtk4", features = ["v4_6"] } winit = "0.30" serial_test = { workspace = true } +serde_json = "1.0" shell-escape = "0.1" temp-env = { workspace = true } tempfile = { workspace = true } diff --git a/testing/tests/client_camera_include_contract.rs b/testing/tests/client_camera_include_contract.rs index 5821d39..de3ca03 100644 --- a/testing/tests/client_camera_include_contract.rs +++ b/testing/tests/client_camera_include_contract.rs @@ -39,7 +39,7 @@ mod camera_include_contract { ), "unexpected encoder: {enc}" ); - let (enc, key_prop, key_val) = CameraCapture::choose_encoder(); + let (enc, key_prop) = CameraCapture::choose_encoder(); assert!( matches!( enc, @@ -47,8 +47,9 @@ mod camera_include_contract { ), "unexpected encoder: {enc}" ); - assert!(!key_prop.is_empty()); - assert!(!key_val.is_empty()); + if let Some(key_prop) = key_prop { + assert!(!key_prop.is_empty()); + } } #[test] diff --git a/testing/tests/client_launcher_layout_contract.rs b/testing/tests/client_launcher_layout_contract.rs index a4bb13e..624e78c 100644 --- a/testing/tests/client_launcher_layout_contract.rs +++ b/testing/tests/client_launcher_layout_contract.rs @@ -82,3 +82,12 @@ fn operations_column_fills_height_and_splits_extra_space_between_logs() { 2 ); } + +#[test] +fn remote_audio_gain_control_stays_in_the_operations_rail() { + assert!(UI_SRC.contains("let audio_heading = gtk::Label::new(Some(\"Remote Audio\"));")); + assert!(UI_SRC.contains("let audio_gain_scale =")); + assert!(UI_SRC.contains("audio_gain_scale.set_draw_value(false);")); + assert!(UI_SRC.contains("audio_gain_value.set_width_chars(5);")); + assert!(UI_SRC.contains("connection_body.append(&audio_gain_row);")); +} diff --git a/testing/tests/client_microphone_include_contract.rs b/testing/tests/client_microphone_include_contract.rs index e88269b..e2a016c 100644 --- a/testing/tests/client_microphone_include_contract.rs +++ b/testing/tests/client_microphone_include_contract.rs @@ -56,7 +56,7 @@ exit 0 #[test] #[serial] - fn default_source_arg_prefers_non_monitor_source() { + fn pulse_source_desc_formats_selected_non_monitor_source() { let script = r#"#!/usr/bin/env sh if [ "$1" = "list" ] && [ "$2" = "short" ] && [ "$3" = "sources" ]; then echo "0 alsa_input.pci.monitor module-alsa-card.c s16le 2ch 48000Hz RUNNING" @@ -66,20 +66,25 @@ fi exit 0 "#; with_fake_pactl(script, || { - let arg = MicrophoneCapture::default_source_arg(); + let source = + MicrophoneCapture::pulse_source_by_substr("DeskMic_5678").expect("matching source"); + let desc = MicrophoneCapture::pulse_source_desc(Some(&source)); assert!( - arg.contains("device=alsa_input.usb-DeskMic_5678-00.analog-stereo"), - "expected escaped non-monitor source argument" + desc.contains("pulsesrc device=alsa_input.usb-DeskMic_5678-00.analog-stereo"), + "expected escaped non-monitor source argument: {desc}" ); }); } #[test] #[serial] - fn default_source_arg_returns_empty_when_pactl_is_unavailable() { + fn pulse_source_by_substr_returns_none_when_pactl_is_unavailable() { with_var("PATH", Some("/definitely/missing/path"), || { - let arg = MicrophoneCapture::default_source_arg(); - assert!(arg.is_empty()); + assert!(MicrophoneCapture::pulse_source_by_substr("anything").is_none()); + assert_eq!( + MicrophoneCapture::pulse_source_desc(None), + "pulsesrc do-timestamp=true" + ); }); } diff --git a/testing/tests/client_output_audio_include_contract.rs b/testing/tests/client_output_audio_include_contract.rs index e10de7d..fff283e 100644 --- a/testing/tests/client_output_audio_include_contract.rs +++ b/testing/tests/client_output_audio_include_contract.rs @@ -135,18 +135,20 @@ exit 0 #[serial] fn audio_out_new_and_push_are_stable_with_sink_override() { with_var("LESAVKA_AUDIO_SINK", Some("fakesink sync=false"), || { - with_var("LESAVKA_TAP_AUDIO", Some("1"), || match AudioOut::new() { - Ok(out) => { - out.push(AudioPacket { - id: 0, - pts: 1_234, - data: vec![0xFF, 0xF1, 0x50, 0x80, 0x00, 0x1F, 0xFC], - }); - drop(out); - } - Err(err) => { - assert!(!err.to_string().trim().is_empty()); - } + with_var("LESAVKA_AUDIO_GAIN", Some("2.5"), || { + with_var("LESAVKA_TAP_AUDIO", Some("1"), || match AudioOut::new() { + Ok(out) => { + out.push(AudioPacket { + id: 0, + pts: 1_234, + data: vec![0xFF, 0xF1, 0x50, 0x80, 0x00, 0x1F, 0xFC], + }); + drop(out); + } + Err(err) => { + assert!(!err.to_string().trim().is_empty()); + } + }); }); }); } @@ -185,4 +187,48 @@ exit 0 assert_eq!(buffer.pts(), None); assert_eq!(timeline.lock().expect("timeline").packets, 1); } + + #[test] + #[serial] + fn downstream_audio_pipeline_keeps_working_aac_transport_with_gain_stage() { + with_var("LESAVKA_AUDIO_GAIN", Some("3.25"), || { + assert_eq!(audio_gain_from_env(), 3.25); + }); + let desc = audio_output_pipeline_desc("fakesink sync=false", 3.25, false); + assert!(desc.contains("aacparse ! avdec_aac")); + assert!(desc.contains("volume name=remote_audio_gain volume=3.250")); + assert!(desc.contains("level name=remote_audio_level")); + assert!(desc.contains("fakesink sync=false")); + } + + #[test] + #[serial] + fn audio_gain_env_defaults_and_clamps_for_soft_remote_audio() { + with_var("LESAVKA_AUDIO_GAIN", None::<&str>, || { + assert_eq!(audio_gain_from_env(), 2.0); + }); + with_var("LESAVKA_AUDIO_GAIN", Some("99"), || { + assert_eq!(audio_gain_from_env(), 8.0); + }); + with_var("LESAVKA_AUDIO_GAIN", Some("-1"), || { + assert_eq!(audio_gain_from_env(), 0.0); + }); + with_var("LESAVKA_AUDIO_GAIN", Some("nope"), || { + assert_eq!(audio_gain_from_env(), 2.0); + }); + } + + #[test] + fn audio_gain_control_reads_first_token_and_clamps() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("gain.control"); + fs::write(&path, "4.500 nonce\n").expect("write gain"); + assert_eq!(read_audio_gain_control(&path), Some(4.5)); + + fs::write(&path, "20.0 nonce\n").expect("write clamped gain"); + assert_eq!(read_audio_gain_control(&path), Some(8.0)); + + fs::write(&path, "bad nonce\n").expect("write invalid gain"); + assert_eq!(read_audio_gain_control(&path), None); + } } diff --git a/testing/tests/server_gadget_include_contract.rs b/testing/tests/server_gadget_include_contract.rs index b94c3ca..cb61dba 100644 --- a/testing/tests/server_gadget_include_contract.rs +++ b/testing/tests/server_gadget_include_contract.rs @@ -304,6 +304,58 @@ printf 'configured\n' > "$LESAVKA_GADGET_SYSFS_ROOT/class/udc/fake-ctrl.usb/stat assert_eq!(state.trim(), "configured"); } + #[test] + #[serial] + fn recover_enumeration_passes_aggressive_rebuild_environment_to_core_helper() { + let dir = tempdir().expect("tempdir"); + let ctrl = "fake-ctrl.usb"; + build_fake_tree(dir.path(), ctrl, "lesavka-test", "not attached"); + let helper = dir.path().join("fake-core-env.sh"); + let env_dump = dir.path().join("helper-env.txt"); + write_helper( + &helper, + r#"#!/usr/bin/env bash +set -euo pipefail +cat > "$LESAVKA_HELPER_ENV_DUMP" < "$LESAVKA_GADGET_SYSFS_ROOT/class/udc/fake-ctrl.usb/state" +"#, + ); + + with_fake_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || { + with_fast_recovery_env(&helper, || { + with_var( + "LESAVKA_HELPER_ENV_DUMP", + Some(env_dump.to_string_lossy().to_string()), + || { + let gadget = UsbGadget::new("lesavka-test"); + gadget + .recover_enumeration() + .expect("forced rebuild should recover fake UDC"); + }, + ); + }); + }); + + let dumped = std::fs::read_to_string(env_dump).expect("read helper env dump"); + for line in [ + "LESAVKA_ALLOW_GADGET_RESET=1", + "LESAVKA_ATTACH_WRITE_UDC=1", + "LESAVKA_DETACH_CLEAR_UDC=1", + "LESAVKA_RELOAD_UVCVIDEO=1", + "LESAVKA_UVC_FALLBACK=1", + "LESAVKA_UVC_CODEC=mjpeg", + ] { + assert!(dumped.contains(line), "{line} missing from {dumped}"); + } + } + #[test] #[serial] fn recover_enumeration_reports_clear_failure_when_helper_leaves_udc_unattached() { diff --git a/testing/tests/server_main_rpc_contract.rs b/testing/tests/server_main_rpc_contract.rs index 0f3741f..b3c6aa0 100644 --- a/testing/tests/server_main_rpc_contract.rs +++ b/testing/tests/server_main_rpc_contract.rs @@ -42,20 +42,19 @@ mod server_main_rpc { .expect("open ms"), ); - ( - dir, - Handler { - kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))), - ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))), - gadget: UsbGadget::new("lesavka"), - did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), - camera_rt: std::sync::Arc::new(CameraRuntime::new()), - capture_power: CapturePowerManager::new(), - eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new( - std::collections::HashMap::new(), - )), - }, - ) + let handler = with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), || Handler { + kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))), + ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))), + gadget: UsbGadget::new("lesavka"), + did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + camera_rt: std::sync::Arc::new(CameraRuntime::new()), + capture_power: CapturePowerManager::new(), + eye_hubs: std::sync::Arc::new( + tokio::sync::Mutex::new(std::collections::HashMap::new()), + ), + }); + + (dir, handler) } fn build_handler_for_tests() -> (tempfile::TempDir, Handler) {