diff --git a/client/src/input/microphone.rs b/client/src/input/microphone.rs index 641a44a..d3c3f2f 100644 --- a/client/src/input/microphone.rs +++ b/client/src/input/microphone.rs @@ -7,10 +7,14 @@ use lesavka_common::lesavka::AudioPacket; use shell_escape::unix::escape; #[cfg(not(coverage))] use std::sync::atomic::{AtomicU64, Ordering}; +use std::{path::Path as StdPath, thread, time::Duration}; use tracing::{debug, warn}; #[cfg(not(coverage))] use tracing::{error, info, trace}; +const MIC_GAIN_ENV: &str = "LESAVKA_MIC_GAIN"; +const MIC_GAIN_CONTROL_ENV: &str = "LESAVKA_MIC_GAIN_CONTROL"; + pub struct MicrophoneCapture { #[allow(dead_code)] // kept alive to hold PLAYING state pipeline: gst::Pipeline, @@ -44,17 +48,24 @@ impl MicrophoneCapture { // AAC → ADTS frames "aacparse ! capsfilter caps=audio/mpeg,stream-format=adts,rate=48000,channels=2" }; + let gain = mic_gain_from_env(); let desc = format!( "{source_desc} ! \ audio/x-raw,format=S16LE,channels=2,rate=48000 ! \ - audioconvert ! audioresample ! {aac} bitrate=128000 ! \ + audioconvert ! audioresample ! \ + volume name=mic_input_gain volume={} ! \ + {aac} bitrate=128000 ! \ {parser} ! \ queue max-size-buffers=100 leaky=downstream ! \ - appsink name=asink emit-signals=true max-buffers=50 drop=true" + appsink name=asink emit-signals=true max-buffers=50 drop=true", + format_mic_gain_for_gst(gain) ); let pipeline: gst::Pipeline = gst::parse::launch(&desc)?.downcast().expect("pipeline"); let sink: gst_app::AppSink = pipeline.by_name("asink").unwrap().downcast().unwrap(); + let volume = pipeline + .by_name("mic_input_gain") + .context("missing mic_input_gain volume")?; #[cfg(not(coverage))] { @@ -89,6 +100,7 @@ impl MicrophoneCapture { pipeline .set_state(gst::State::Playing) .context("start mic pipeline")?; + maybe_spawn_mic_gain_control(volume); Ok(Self { pipeline, sink }) } @@ -203,6 +215,52 @@ impl MicrophoneCapture { } } +fn mic_gain_from_env() -> f64 { + std::env::var(MIC_GAIN_ENV) + .ok() + .and_then(|raw| parse_mic_gain(&raw)) + .unwrap_or(1.0) +} + +fn parse_mic_gain(raw: &str) -> Option { + let value = raw.split_ascii_whitespace().next()?.parse::().ok()?; + value.is_finite().then_some(clamp_mic_gain(value)) +} + +fn clamp_mic_gain(value: f64) -> f64 { + value.clamp(0.0, 4.0) +} + +fn format_mic_gain_for_gst(gain: f64) -> String { + format!("{:.3}", clamp_mic_gain(gain)) +} + +fn maybe_spawn_mic_gain_control(volume: gst::Element) { + let Ok(path) = std::env::var(MIC_GAIN_CONTROL_ENV) else { + return; + }; + let path = std::path::PathBuf::from(path); + thread::spawn(move || { + let mut last_gain = None; + loop { + if let Some(gain) = read_mic_gain_control(&path) + && last_gain != Some(gain) + { + volume.set_property("volume", gain); + last_gain = Some(gain); + tracing::info!("🎤 mic gain set to {gain:.2}x"); + } + thread::sleep(Duration::from_millis(100)); + } + }); +} + +fn read_mic_gain_control(path: &StdPath) -> Option { + std::fs::read_to_string(path) + .ok() + .and_then(|raw| parse_mic_gain(&raw)) +} + impl Drop for MicrophoneCapture { fn drop(&mut self) { let _ = self.pipeline.set_state(gst::State::Null); diff --git a/client/src/launcher/devices.rs b/client/src/launcher/devices.rs index b5450ae..317ccf4 100644 --- a/client/src/launcher/devices.rs +++ b/client/src/launcher/devices.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use evdev::{AbsoluteAxisCode, Device, EventType, KeyCode, RelativeAxisCode}; use serde_json::Value; @@ -56,6 +56,41 @@ fn discover_microphone_devices() -> Vec { set.into_iter().collect() } +fn dedupe_camera_devices(names: impl IntoIterator) -> Vec { + let mut by_physical_device: BTreeMap = BTreeMap::new(); + for name in names { + let key = camera_physical_key(&name); + match by_physical_device.get(&key) { + Some(existing) if camera_device_rank(existing) <= camera_device_rank(&name) => {} + _ => { + by_physical_device.insert(key, name); + } + } + } + by_physical_device.into_values().collect() +} + +fn camera_physical_key(name: &str) -> String { + let trimmed = name.trim(); + trimmed + .strip_prefix("usb-") + .unwrap_or(trimmed) + .split("-video-index") + .next() + .unwrap_or(trimmed) + .to_ascii_lowercase() +} + +fn camera_device_rank(name: &str) -> u8 { + if name.contains("-video-index0") { + 0 + } else if name.contains("-video-index") { + 1 + } else { + 2 + } +} + fn discover_speaker_devices() -> Vec { let mut set = BTreeSet::new(); for sink in discover_pactl_devices("sinks") { @@ -80,7 +115,7 @@ fn discover_camera_devices(override_dir: Option) -> Vec { set.insert(name.to_string_lossy().to_string()); } } - set.into_iter().collect() + dedupe_camera_devices(set) } fn discover_pactl_devices(kind: &str) -> Vec { @@ -268,6 +303,23 @@ mod tests { let _ = std::fs::remove_dir_all(tmp); } + #[test] + fn camera_discovery_prefers_one_endpoint_per_physical_webcam() { + let devices = dedupe_camera_devices([ + "usb-Azurewave_USB2.0_HD_UVC_WebCam_0x0001-video-index1".to_string(), + "usb-Azurewave_USB2.0_HD_UVC_WebCam_0x0001-video-index0".to_string(), + "usb-Logitech_C920-video-index0".to_string(), + ]); + + assert_eq!( + devices, + vec![ + "usb-Azurewave_USB2.0_HD_UVC_WebCam_0x0001-video-index0".to_string(), + "usb-Logitech_C920-video-index0".to_string(), + ] + ); + } + #[test] fn camera_discovery_returns_empty_when_directory_missing() { let devices = discover_camera_devices(Some("/tmp/does-not-exist-lesavka".to_string())); diff --git a/client/src/launcher/mod.rs b/client/src/launcher/mod.rs index f125c8a..ed65273 100644 --- a/client/src/launcher/mod.rs +++ b/client/src/launcher/mod.rs @@ -149,21 +149,30 @@ pub fn runtime_env_vars(state: &LauncherState) -> BTreeMap { "LESAVKA_AUDIO_GAIN".to_string(), state.audio_gain_env_value(), ); + envs.insert("LESAVKA_MIC_GAIN".to_string(), state.mic_gain_env_value()); if matches!(state.view_mode, ViewMode::Unified) { envs.insert("LESAVKA_DISABLE_VIDEO_RENDER".to_string(), "1".to_string()); } - if let Some(camera) = state.devices.camera.as_ref() { + if state.channels.camera + && let Some(camera) = state.devices.camera.as_ref() + { envs.insert("LESAVKA_CAM_SOURCE".to_string(), camera.clone()); } else { envs.insert("LESAVKA_CAM_DISABLE".to_string(), "1".to_string()); } - if let Some(microphone) = state.devices.microphone.as_ref() { + if state.channels.microphone + && let Some(microphone) = state.devices.microphone.as_ref() + { envs.insert("LESAVKA_MIC_SOURCE".to_string(), microphone.clone()); } else { envs.insert("LESAVKA_MIC_DISABLE".to_string(), "1".to_string()); } - if let Some(speaker) = state.devices.speaker.as_ref() { + if state.channels.audio + && let Some(speaker) = state.devices.speaker.as_ref() + { envs.insert("LESAVKA_AUDIO_SINK".to_string(), speaker.clone()); + } else { + envs.insert("LESAVKA_AUDIO_DISABLE".to_string(), "1".to_string()); } if let Some(keyboard) = state.devices.keyboard.as_ref() { envs.insert("LESAVKA_KEYBOARD_DEVICE".to_string(), keyboard.clone()); @@ -249,6 +258,9 @@ mod tests { state.select_camera(Some("/dev/video0".to_string())); state.select_microphone(Some("alsa_input.test".to_string())); state.select_speaker(Some("alsa_output.test".to_string())); + state.set_camera_channel_enabled(true); + state.set_microphone_channel_enabled(true); + state.set_audio_channel_enabled(true); state.select_keyboard(Some("/dev/input/event10".to_string())); state.select_mouse(Some("/dev/input/event11".to_string())); @@ -262,6 +274,7 @@ mod tests { Some(&"18".to_string()) ); assert_eq!(envs.get("LESAVKA_AUDIO_GAIN"), Some(&"2.000".to_string())); + assert_eq!(envs.get("LESAVKA_MIC_GAIN"), Some(&"1.000".to_string())); assert_eq!( envs.get(REMOTE_INPUT_FAILSAFE_SECONDS_ENV), Some(&DEFAULT_REMOTE_INPUT_FAILSAFE_SECONDS.to_string()) @@ -351,13 +364,16 @@ mod tests { } #[test] - fn runtime_env_vars_leave_auto_audio_devices_unset() { + fn runtime_env_vars_disable_enabled_channels_without_real_devices() { let mut state = LauncherState::new(); state.select_microphone(Some("auto".to_string())); state.select_speaker(Some("auto".to_string())); + state.set_microphone_channel_enabled(true); let envs = runtime_env_vars(&state); + assert_eq!(envs.get("LESAVKA_MIC_DISABLE"), Some(&"1".to_string())); assert!(!envs.contains_key("LESAVKA_MIC_SOURCE")); + assert_eq!(envs.get("LESAVKA_AUDIO_DISABLE"), Some(&"1".to_string())); assert!(!envs.contains_key("LESAVKA_AUDIO_SINK")); } @@ -365,9 +381,35 @@ mod tests { fn runtime_env_vars_emit_selected_audio_gain() { let mut state = LauncherState::new(); state.set_audio_gain_percent(425); + state.set_mic_gain_percent(275); let envs = runtime_env_vars(&state); assert_eq!(envs.get("LESAVKA_AUDIO_GAIN"), Some(&"4.250".to_string())); + assert_eq!(envs.get("LESAVKA_MIC_GAIN"), Some(&"2.750".to_string())); + } + + #[test] + fn runtime_env_vars_use_channel_toggles_for_media_inclusion() { + let mut state = LauncherState::new(); + + let envs = runtime_env_vars(&state); + assert_eq!(envs.get("LESAVKA_CAM_DISABLE"), Some(&"1".to_string())); + assert_eq!(envs.get("LESAVKA_MIC_DISABLE"), Some(&"1".to_string())); + assert_eq!(envs.get("LESAVKA_AUDIO_DISABLE"), Some(&"1".to_string())); + + state.select_camera(Some("/dev/video0".to_string())); + state.select_microphone(Some("alsa_input.usb".to_string())); + state.select_speaker(Some("alsa_output.usb".to_string())); + state.set_camera_channel_enabled(true); + state.set_microphone_channel_enabled(true); + let envs = runtime_env_vars(&state); + assert!(!envs.contains_key("LESAVKA_CAM_DISABLE")); + assert!(!envs.contains_key("LESAVKA_MIC_DISABLE")); + assert!(!envs.contains_key("LESAVKA_AUDIO_DISABLE")); + + state.set_audio_channel_enabled(false); + let envs = runtime_env_vars(&state); + assert_eq!(envs.get("LESAVKA_AUDIO_DISABLE"), Some(&"1".to_string())); } #[test] diff --git a/client/src/launcher/state.rs b/client/src/launcher/state.rs index b1cc296..bf6526c 100644 --- a/client/src/launcher/state.rs +++ b/client/src/launcher/state.rs @@ -7,6 +7,8 @@ use lesavka_common::eye_source::{ pub const DEFAULT_AUDIO_GAIN_PERCENT: u32 = 200; pub const MAX_AUDIO_GAIN_PERCENT: u32 = 800; +pub const DEFAULT_MIC_GAIN_PERCENT: u32 = 100; +pub const MAX_MIC_GAIN_PERCENT: u32 = 400; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum InputRouting { @@ -301,6 +303,23 @@ pub struct DeviceSelection { pub mouse: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChannelSelection { + pub camera: bool, + pub microphone: bool, + pub audio: bool, +} + +impl Default for ChannelSelection { + fn default() -> Self { + Self { + camera: false, + microphone: false, + audio: true, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LauncherState { pub server_available: bool, @@ -317,7 +336,9 @@ pub struct LauncherState { pub capture_bitrates_kbit: [u32; 2], pub breakout_sizes: [BreakoutSizePreset; 2], pub devices: DeviceSelection, + pub channels: ChannelSelection, pub audio_gain_percent: u32, + pub mic_gain_percent: u32, pub swap_key: String, pub swap_key_binding: bool, pub swap_key_binding_token: u64, @@ -343,7 +364,9 @@ impl Default for LauncherState { capture_bitrates_kbit: [18_000, 18_000], breakout_sizes: [BreakoutSizePreset::Source, BreakoutSizePreset::Source], devices: DeviceSelection::default(), + channels: ChannelSelection::default(), audio_gain_percent: DEFAULT_AUDIO_GAIN_PERCENT, + mic_gain_percent: DEFAULT_MIC_GAIN_PERCENT, swap_key: "pause".to_string(), swap_key_binding: false, swap_key_binding_token: 0, @@ -643,6 +666,18 @@ impl LauncherState { self.devices.speaker = normalize_selection(speaker); } + pub fn set_camera_channel_enabled(&mut self, enabled: bool) { + self.channels.camera = enabled; + } + + pub fn set_microphone_channel_enabled(&mut self, enabled: bool) { + self.channels.microphone = enabled; + } + + pub fn set_audio_channel_enabled(&mut self, enabled: bool) { + self.channels.audio = enabled; + } + pub fn set_audio_gain_percent(&mut self, percent: u32) { self.audio_gain_percent = normalize_audio_gain_percent(percent); } @@ -659,6 +694,22 @@ impl LauncherState { format_audio_gain_percent(self.audio_gain_percent) } + pub fn set_mic_gain_percent(&mut self, percent: u32) { + self.mic_gain_percent = normalize_mic_gain_percent(percent); + } + + pub fn mic_gain_multiplier(&self) -> f64 { + self.mic_gain_percent as f64 / 100.0 + } + + pub fn mic_gain_env_value(&self) -> String { + format!("{:.3}", self.mic_gain_multiplier()) + } + + pub fn mic_gain_label(&self) -> String { + format_mic_gain_percent(self.mic_gain_percent) + } + pub fn select_keyboard(&mut self, keyboard: Option) { self.devices.keyboard = normalize_selection(keyboard); } @@ -668,7 +719,9 @@ impl LauncherState { } pub fn apply_catalog_defaults(&mut self, catalog: &DeviceCatalog) { - let _ = catalog; + keep_or_select_first(&mut self.devices.camera, &catalog.cameras); + keep_or_select_first(&mut self.devices.microphone, &catalog.microphones); + keep_or_select_first(&mut self.devices.speaker, &catalog.speakers); } pub fn set_swap_key(&mut self, swap_key: impl Into) { @@ -725,7 +778,7 @@ impl LauncherState { pub fn status_line(&self) -> String { format!( - "server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} mic={} speaker={} audio_gain={} kbd={} mouse={} swap={}", + "server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} mic={} speaker={} channels=cam:{}/mic:{}/audio:{} audio_gain={} mic_gain={} kbd={} mouse={} swap={}", self.server_available, match self.routing { InputRouting::Local => "local", @@ -747,10 +800,14 @@ impl LauncherState { self.displays[1].label(), self.feed_source_preset(0).as_id(), self.feed_source_preset(1).as_id(), - self.devices.camera.as_deref().unwrap_or("auto"), - self.devices.microphone.as_deref().unwrap_or("auto"), - self.devices.speaker.as_deref().unwrap_or("auto"), + media_status_label(self.channels.camera, self.devices.camera.as_deref()), + media_status_label(self.channels.microphone, self.devices.microphone.as_deref()), + media_status_label(self.channels.audio, self.devices.speaker.as_deref()), + self.channels.camera, + self.channels.microphone, + self.channels.audio, self.audio_gain_label(), + self.mic_gain_label(), self.devices.keyboard.as_deref().unwrap_or("all"), self.devices.mouse.as_deref().unwrap_or("all"), self.swap_key, @@ -766,6 +823,14 @@ pub fn format_audio_gain_percent(percent: u32) -> String { format!("{}%", normalize_audio_gain_percent(percent)) } +pub fn normalize_mic_gain_percent(percent: u32) -> u32 { + percent.min(MAX_MIC_GAIN_PERCENT) +} + +pub fn format_mic_gain_percent(percent: u32) -> String { + format!("{}%", normalize_mic_gain_percent(percent)) +} + fn breakout_size_choice( physical_limit: PreviewSourceSize, display_fill: PreviewSourceSize, @@ -972,6 +1037,20 @@ fn normalize_selection(value: Option) -> Option { }) } +fn keep_or_select_first(selection: &mut Option, values: &[String]) { + if selection.is_none() { + *selection = values.first().cloned(); + } +} + +fn media_status_label(enabled: bool, selected: Option<&str>) -> &str { + if !enabled { + "off" + } else { + selected.unwrap_or("none") + } +} + fn normalize_swap_key(value: String) -> String { let trimmed = value.trim(); if trimmed.is_empty() { @@ -1011,9 +1090,15 @@ mod tests { assert!(state.devices.speaker.is_none()); assert!(state.devices.keyboard.is_none()); assert!(state.devices.mouse.is_none()); + assert!(!state.channels.camera); + assert!(!state.channels.microphone); + assert!(state.channels.audio); 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.mic_gain_percent, DEFAULT_MIC_GAIN_PERCENT); + assert_eq!(state.mic_gain_env_value(), "1.000"); + assert_eq!(state.mic_gain_label(), "100%"); assert_eq!(state.capture_power.unit, "relay.service"); assert_eq!(state.capture_power.mode, "auto"); } @@ -1088,7 +1173,7 @@ mod tests { } #[test] - fn catalog_defaults_do_not_auto_stage_media_devices() { + fn catalog_defaults_stage_real_media_devices_without_enabling_channels() { let mut state = LauncherState::new(); state.select_camera(Some("/dev/video-special".to_string())); @@ -1103,14 +1188,17 @@ mod tests { state.apply_catalog_defaults(&catalog); assert_eq!(state.devices.camera.as_deref(), Some("/dev/video-special")); - assert!(state.devices.microphone.is_none()); - assert!(state.devices.speaker.is_none()); + assert_eq!(state.devices.microphone.as_deref(), Some("alsa_input.usb")); + assert_eq!(state.devices.speaker.as_deref(), Some("alsa_output.usb")); + assert!(!state.channels.camera); + assert!(!state.channels.microphone); + assert!(state.channels.audio); let mut fresh = LauncherState::new(); fresh.apply_catalog_defaults(&catalog); - assert!(fresh.devices.camera.is_none()); - assert!(fresh.devices.microphone.is_none()); - assert!(fresh.devices.speaker.is_none()); + assert_eq!(fresh.devices.camera.as_deref(), Some("/dev/video0")); + assert_eq!(fresh.devices.microphone.as_deref(), Some("alsa_input.usb")); + assert_eq!(fresh.devices.speaker.as_deref(), Some("alsa_output.usb")); } #[test] @@ -1125,6 +1213,16 @@ mod tests { 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"); + + state.set_mic_gain_percent(325); + assert_eq!(state.mic_gain_percent, 325); + assert_eq!(state.mic_gain_label(), "325%"); + assert_eq!(state.mic_gain_env_value(), "3.250"); + + state.set_mic_gain_percent(10_000); + assert_eq!(state.mic_gain_percent, MAX_MIC_GAIN_PERCENT); + assert_eq!(state.mic_gain_label(), "400%"); + assert_eq!(state.mic_gain_env_value(), "4.000"); } #[test] @@ -1148,6 +1246,9 @@ mod tests { state.select_camera(Some("/dev/video0".to_string())); state.select_microphone(Some("alsa_input.usb".to_string())); state.select_speaker(Some("alsa_output.usb".to_string())); + state.set_camera_channel_enabled(true); + state.set_microphone_channel_enabled(true); + state.set_audio_channel_enabled(true); state.select_keyboard(Some("/dev/input/event-kbd".to_string())); state.select_mouse(Some("/dev/input/event-mouse".to_string())); state.set_preview_source_profile(1920, 1080, 30); diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index 0323a05..b7cc86a 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -12,18 +12,20 @@ use { super::state::{ BreakoutSizePreset, CapturePowerStatus, CaptureSizePreset, DisplaySurface, FeedSourcePreset, InputRouting, LauncherState, MAX_AUDIO_GAIN_PERCENT, + MAX_MIC_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, 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, + input_state_path, input_toggle_control_path, mic_gain_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, write_mic_gain_request, }, crate::handshake::{HandshakeProbe, probe}, crate::output::display::enumerate_monitors, @@ -212,6 +214,50 @@ fn apply_audio_gain_change( true } +#[cfg(not(coverage))] +/// Apply a microphone uplink gain slider update without unwinding through GTK callbacks. +fn apply_mic_gain_change( + scale: >k::Scale, + state: &Rc>, + widgets: &super::ui_components::LauncherWidgets, + child_proc: &Rc>>, +) -> bool { + let percent = scale + .value() + .round() + .clamp(0.0, MAX_MIC_GAIN_PERCENT as f64) as u32; + let label = { + let Ok(mut state) = state.try_borrow_mut() else { + return false; + }; + if state.mic_gain_percent == percent { + widgets.mic_gain_value.set_text(&state.mic_gain_label()); + return true; + } + state.set_mic_gain_percent(percent); + state.mic_gain_label() + }; + widgets.mic_gain_value.set_text(&label); + let relay_live = child_proc + .try_borrow() + .map(|child| child.is_some()) + .unwrap_or(false); + if relay_live { + let path = mic_gain_control_path(); + match write_mic_gain_request(&path, percent) { + Ok(()) => widgets.status_label.set_text(&format!("Mic gain set to {label}.")), + Err(err) => widgets.status_label.set_text(&format!( + "Mic 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!( + "Mic gain set to {label} for the next relay launch." + )); + } + true +} + #[cfg(not(coverage))] fn request_capture_power_refresh( power_tx: std::sync::mpsc::Sender, @@ -848,7 +894,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { } else if preview_was_running { widgets.status_label.set_text(&format!( "Local camera preview switched to {}.", - selected.as_deref().unwrap_or("auto") + selected.as_deref().unwrap_or("no camera") )); } refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); @@ -1135,6 +1181,81 @@ 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 mic_gain_scale = widgets.mic_gain_scale.clone(); + mic_gain_scale.connect_value_changed(move |scale| { + if !apply_mic_gain_change(scale, &state, &widgets, &child_proc) { + let scale = scale.clone(); + let state = Rc::clone(&state); + let widgets = widgets.clone(); + let child_proc = Rc::clone(&child_proc); + glib::idle_add_local_once(move || { + let _ = apply_mic_gain_change(&scale, &state, &widgets, &child_proc); + }); + } + }); + } + + { + let state = Rc::clone(&state); + let widgets = widgets.clone(); + let child_proc = Rc::clone(&child_proc); + let toggle = widgets.camera_channel_toggle.clone(); + toggle.connect_toggled(move |toggle| { + if let Ok(mut state) = state.try_borrow_mut() { + state.set_camera_channel_enabled(toggle.is_active()); + } + if let Ok(state_snapshot) = state.try_borrow().map(|state| state.clone()) { + refresh_launcher_ui( + &widgets, + &state_snapshot, + child_proc.borrow().is_some(), + ); + } + }); + } + + { + let state = Rc::clone(&state); + let widgets = widgets.clone(); + let child_proc = Rc::clone(&child_proc); + let toggle = widgets.microphone_channel_toggle.clone(); + toggle.connect_toggled(move |toggle| { + if let Ok(mut state) = state.try_borrow_mut() { + state.set_microphone_channel_enabled(toggle.is_active()); + } + if let Ok(state_snapshot) = state.try_borrow().map(|state| state.clone()) { + refresh_launcher_ui( + &widgets, + &state_snapshot, + child_proc.borrow().is_some(), + ); + } + }); + } + + { + let state = Rc::clone(&state); + let widgets = widgets.clone(); + let child_proc = Rc::clone(&child_proc); + let toggle = widgets.audio_channel_toggle.clone(); + toggle.connect_toggled(move |toggle| { + if let Ok(mut state) = state.try_borrow_mut() { + state.set_audio_channel_enabled(toggle.is_active()); + } + if let Ok(state_snapshot) = state.try_borrow().map(|state| state.clone()) { + refresh_launcher_ui( + &widgets, + &state_snapshot, + child_proc.borrow().is_some(), + ); + } + }); + } + { let state = Rc::clone(&state); let widgets = widgets.clone(); @@ -2058,10 +2179,19 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { } } PowerMessage::Refresh(Err(err)) => { + let relay_live = child_proc.borrow().is_some() + || state.borrow().remote_active; { let mut state = state.borrow_mut(); - state.set_server_available(false); - state.set_capture_power(unavailable_capture_power(err)); + if relay_live { + state.set_server_available(true); + if !state.capture_power.available { + state.set_capture_power(unavailable_capture_power(err)); + } + } else { + state.set_server_available(false); + state.set_capture_power(unavailable_capture_power(err)); + } } if let Some(preview) = preview.as_ref() { let preview_active = { diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index 1609193..251874b 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -66,11 +66,21 @@ pub struct LauncherWidgets { pub display_panes: [DisplayPaneWidgets; 2], pub server_entry: gtk::Entry, pub start_button: gtk::Button, + pub camera_combo: gtk::ComboBoxText, + pub microphone_combo: gtk::ComboBoxText, + pub speaker_combo: gtk::ComboBoxText, + pub keyboard_combo: gtk::ComboBoxText, + pub mouse_combo: gtk::ComboBoxText, + pub camera_channel_toggle: gtk::CheckButton, + pub microphone_channel_toggle: gtk::CheckButton, + pub audio_channel_toggle: gtk::CheckButton, 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 mic_gain_scale: gtk::Scale, + pub mic_gain_value: gtk::Label, pub input_toggle_button: gtk::Button, pub clipboard_button: gtk::Button, pub probe_button: gtk::Button, @@ -229,11 +239,11 @@ pub fn build_launcher_view( control_group.append(&control_stack); let camera_combo = gtk::ComboBoxText::new(); - camera_combo.append(Some("auto"), "auto"); - for camera in &catalog.cameras { - append_stage_choice(&camera_combo, camera); - } - super::ui_runtime::set_combo_active_text(&camera_combo, state.devices.camera.as_deref()); + sync_stage_device_combo( + &camera_combo, + &catalog.cameras, + state.devices.camera.as_deref(), + ); let camera_test_button = gtk::Button::with_label("Start Preview"); stabilize_button(&camera_test_button, 118); camera_test_button.set_tooltip_text(Some( @@ -241,11 +251,11 @@ pub fn build_launcher_view( )); let speaker_combo = gtk::ComboBoxText::new(); - speaker_combo.append(Some("auto"), "auto"); - for speaker in &catalog.speakers { - append_stage_choice(&speaker_combo, speaker); - } - super::ui_runtime::set_combo_active_text(&speaker_combo, state.devices.speaker.as_deref()); + sync_stage_device_combo( + &speaker_combo, + &catalog.speakers, + state.devices.speaker.as_deref(), + ); let speaker_test_button = gtk::Button::with_label("Play Tone"); stabilize_button(&speaker_test_button, 118); speaker_test_button.set_tooltip_text(Some( @@ -294,12 +304,9 @@ pub fn build_launcher_view( ); let microphone_combo = gtk::ComboBoxText::new(); - microphone_combo.append(Some("auto"), "auto"); - for microphone in &catalog.microphones { - append_stage_choice(µphone_combo, microphone); - } - super::ui_runtime::set_combo_active_text( + sync_stage_device_combo( µphone_combo, + &catalog.microphones, state.devices.microphone.as_deref(), ); let microphone_test_button = gtk::Button::with_label("Monitor Mic"); @@ -457,6 +464,37 @@ pub fn build_launcher_view( live_actions_row.append(&usb_recover_button); connection_body.append(&live_actions_row); + let channel_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + channel_row.set_hexpand(true); + let channel_heading = gtk::Label::new(Some("Streams")); + channel_heading.add_css_class("subgroup-title"); + channel_heading.set_halign(gtk::Align::Start); + channel_heading.set_width_chars(10); + channel_row.append(&channel_heading); + let channel_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8); + channel_buttons.set_hexpand(true); + channel_buttons.set_homogeneous(true); + let camera_channel_toggle = gtk::CheckButton::with_label("Webcam"); + camera_channel_toggle.set_active(state.channels.camera); + camera_channel_toggle.set_tooltip_text(Some( + "Include the local webcam uplink in the next relay session.", + )); + let microphone_channel_toggle = gtk::CheckButton::with_label("Mic"); + microphone_channel_toggle.set_active(state.channels.microphone); + microphone_channel_toggle.set_tooltip_text(Some( + "Include the local microphone uplink in the next relay session.", + )); + let audio_channel_toggle = gtk::CheckButton::with_label("Audio"); + audio_channel_toggle.set_active(state.channels.audio); + audio_channel_toggle.set_tooltip_text(Some( + "Play remote audio on this client during the next relay session.", + )); + channel_buttons.append(&camera_channel_toggle); + channel_buttons.append(µphone_channel_toggle); + channel_buttons.append(&audio_channel_toggle); + channel_row.append(&channel_buttons); + connection_body.append(&channel_row); + connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal)); let power_heading = gtk::Label::new(Some("GPIO Power")); power_heading.add_css_class("subgroup-title"); @@ -520,8 +558,37 @@ pub fn build_launcher_view( audio_gain_row.append(&audio_gain_label); audio_gain_row.append(&audio_gain_scale); audio_gain_row.append(&audio_gain_value); + let mic_gain_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + mic_gain_row.set_size_request(220, -1); + mic_gain_row.set_hexpand(true); + let mic_gain_label = gtk::Label::new(Some("Mic Gain")); + mic_gain_label.add_css_class("dim-label"); + mic_gain_label.set_halign(gtk::Align::Start); + mic_gain_label.set_width_chars(10); + let mic_gain_adjustment = gtk::Adjustment::new( + state.mic_gain_percent as f64, + 0.0, + super::state::MAX_MIC_GAIN_PERCENT as f64, + 25.0, + 100.0, + 0.0, + ); + let mic_gain_scale = gtk::Scale::new(gtk::Orientation::Horizontal, Some(&mic_gain_adjustment)); + mic_gain_scale.set_draw_value(false); + mic_gain_scale.set_hexpand(true); + mic_gain_scale.set_tooltip_text(Some( + "Boost or lower local microphone uplink gain. Changes apply live while microphone uplink is running.", + )); + let mic_gain_value = gtk::Label::new(Some(&state.mic_gain_label())); + mic_gain_value.add_css_class("dim-label"); + mic_gain_value.set_width_chars(5); + mic_gain_value.set_xalign(1.0); + mic_gain_row.append(&mic_gain_label); + mic_gain_row.append(&mic_gain_scale); + mic_gain_row.append(&mic_gain_value); power_shell.append(&power_row); power_shell.append(&audio_gain_row); + power_shell.append(&mic_gain_row); connection_body.append(&power_shell); let routing_heading = gtk::Label::new(Some("Inputs")); routing_heading.add_css_class("subgroup-title"); @@ -762,11 +829,21 @@ pub fn build_launcher_view( display_panes: [left_pane.clone(), right_pane.clone()], server_entry: server_entry.clone(), start_button: start_button.clone(), + camera_combo: camera_combo.clone(), + microphone_combo: microphone_combo.clone(), + speaker_combo: speaker_combo.clone(), + keyboard_combo: keyboard_combo.clone(), + mouse_combo: mouse_combo.clone(), + camera_channel_toggle: camera_channel_toggle.clone(), + microphone_channel_toggle: microphone_channel_toggle.clone(), + audio_channel_toggle: audio_channel_toggle.clone(), 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(), + mic_gain_scale: mic_gain_scale.clone(), + mic_gain_value: mic_gain_value.clone(), input_toggle_button: input_toggle_button.clone(), clipboard_button: clipboard_button.clone(), probe_button: probe_button.clone(), @@ -1124,11 +1201,10 @@ pub fn sync_stage_device_combo( selected: Option<&str>, ) { combo.remove_all(); - combo.append(Some("auto"), "auto"); for value in values { append_stage_choice(combo, value); } - super::ui_runtime::set_combo_active_text(combo, selected); + set_stage_combo_active_text(combo, selected); } pub fn sync_input_device_combo( @@ -1202,25 +1278,74 @@ fn append_stage_choice(combo: >k::ComboBoxText, value: &str) { combo.append(Some(value), &compact_stage_label(value)); } +fn set_stage_combo_active_text(combo: >k::ComboBoxText, selected: Option<&str>) { + if selected + .filter(|value| !value.trim().is_empty()) + .is_some_and(|value| combo.set_active_id(Some(value))) + { + return; + } + combo.set_active(Some(0)); +} + fn compact_stage_label(value: &str) -> String { let trimmed = value.trim(); if trimmed.is_empty() { - return "auto".to_string(); + return "No device".to_string(); + } + let camera = trimmed + .strip_prefix("usb-") + .unwrap_or(trimmed) + .split("-video-index") + .next() + .unwrap_or(trimmed); + if camera != trimmed { + return shorten_label(camera); + } + if let Some(rest) = trimmed + .strip_prefix("alsa_input.") + .or_else(|| trimmed.strip_prefix("alsa_output.")) + { + return shorten_label(&human_audio_node_label(rest)); + } + if let Some(rest) = trimmed + .strip_prefix("bluez_input.") + .or_else(|| trimmed.strip_prefix("bluez_output.")) + { + return shorten_label(&format!( + "Bluetooth {}", + rest.split('.').next().unwrap_or(rest).replace('_', ":") + )); } if let Some(short) = trimmed.rsplit('/').next() && short != trimmed { return shorten_label(short); } - if let Some(rest) = trimmed - .strip_prefix("alsa_input.") - .or_else(|| trimmed.strip_prefix("alsa_output.")) - { - return shorten_label(rest); - } shorten_label(trimmed) } +fn human_audio_node_label(value: &str) -> String { + let compact = value + .trim() + .strip_prefix("usb-") + .unwrap_or(value.trim()) + .replace(".analog-stereo", " analog stereo") + .replace(".mono-fallback", " mono") + .replace(".stereo-fallback", " stereo") + .replace('-', " ") + .replace('_', " "); + if compact.starts_with("pci ") || compact.starts_with("pci-") { + if compact.contains("analog stereo") { + "Built-in analog stereo".to_string() + } else { + "Built-in audio".to_string() + } + } else { + compact + } +} + fn shorten_label(value: &str) -> String { const MAX: usize = 44; let compact = value.replace('_', " "); diff --git a/client/src/launcher/ui_runtime.rs b/client/src/launcher/ui_runtime.rs index 2fc8be5..9651c9a 100644 --- a/client/src/launcher/ui_runtime.rs +++ b/client/src/launcher/ui_runtime.rs @@ -25,10 +25,12 @@ 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 MIC_GAIN_CONTROL_ENV: &str = "LESAVKA_MIC_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 const DEFAULT_MIC_GAIN_CONTROL_PATH: &str = "/tmp/lesavka-mic-gain.control"; pub type RelayChild = Child; @@ -84,6 +86,27 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi .set_value(state.audio_gain_percent as f64); } widgets.audio_gain_value.set_text(&state.audio_gain_label()); + if (widgets.mic_gain_scale.value() - state.mic_gain_percent as f64).abs() > f64::EPSILON { + widgets + .mic_gain_scale + .set_value(state.mic_gain_percent as f64); + } + widgets.mic_gain_value.set_text(&state.mic_gain_label()); + if widgets.camera_channel_toggle.is_active() != state.channels.camera { + widgets + .camera_channel_toggle + .set_active(state.channels.camera); + } + if widgets.microphone_channel_toggle.is_active() != state.channels.microphone { + widgets + .microphone_channel_toggle + .set_active(state.channels.microphone); + } + if widgets.audio_channel_toggle.is_active() != state.channels.audio { + widgets + .audio_channel_toggle + .set_active(state.channels.audio); + } widgets .start_button .set_label(if relay_live { "Disconnect" } else { "Connect" }); @@ -99,7 +122,30 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi widgets .usb_recover_button .set_sensitive(state.server_available); - widgets.device_refresh_button.set_sensitive(true); + widgets.device_refresh_button.set_sensitive(!relay_live); + widgets + .camera_combo + .set_sensitive(!relay_live && state.channels.camera); + widgets + .microphone_combo + .set_sensitive(!relay_live && state.channels.microphone); + widgets + .speaker_combo + .set_sensitive(!relay_live && state.channels.audio); + widgets.keyboard_combo.set_sensitive(!relay_live); + widgets.mouse_combo.set_sensitive(!relay_live); + widgets.camera_channel_toggle.set_sensitive(!relay_live); + widgets.microphone_channel_toggle.set_sensitive(!relay_live); + widgets.audio_channel_toggle.set_sensitive(!relay_live); + widgets + .camera_test_button + .set_sensitive(!relay_live && state.channels.camera); + widgets + .microphone_test_button + .set_sensitive(!relay_live && state.channels.microphone); + widgets + .speaker_test_button + .set_sensitive(!relay_live && state.channels.audio); widgets.input_toggle_button.set_label(match state.routing { InputRouting::Remote => "Route Local", InputRouting::Local => "Route Remote", @@ -774,6 +820,12 @@ pub fn audio_gain_control_path() -> PathBuf { .unwrap_or_else(|_| PathBuf::from(DEFAULT_AUDIO_GAIN_CONTROL_PATH)) } +pub fn mic_gain_control_path() -> PathBuf { + std::env::var(MIC_GAIN_CONTROL_ENV) + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(DEFAULT_MIC_GAIN_CONTROL_PATH)) +} + pub fn write_input_routing_request(path: &Path, routing: InputRouting) -> Result<()> { std::fs::write( path, @@ -788,6 +840,12 @@ pub fn write_audio_gain_request(path: &Path, gain_percent: u32) -> Result<()> { Ok(()) } +pub fn write_mic_gain_request(path: &Path, gain_percent: u32) -> Result<()> { + let gain = gain_percent.min(super::state::MAX_MIC_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, @@ -956,6 +1014,9 @@ pub fn spawn_client_process( 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); + let mic_gain_path = mic_gain_control_path(); + let _ = write_mic_gain_request(&mic_gain_path, state.mic_gain_percent); + command.env(MIC_GAIN_CONTROL_ENV, mic_gain_path); for (key, value) in runtime_env_vars(state) { command.env(key, value); } @@ -1499,6 +1560,15 @@ mod tests { assert!(raw.starts_with("4.250 "), "{raw}"); } + #[test] + fn write_mic_gain_request_formats_live_control_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("mic-gain.control"); + write_mic_gain_request(&path, 325).expect("write gain"); + let raw = std::fs::read_to_string(path).expect("read gain"); + assert!(raw.starts_with("3.250 "), "{raw}"); + } + #[gtk::test] #[serial] fn dock_all_displays_to_preview_closes_popouts_and_resets_surfaces() { diff --git a/scripts/ci/hygiene_gate_baseline.json b/scripts/ci/hygiene_gate_baseline.json index d00fc16..33d2d0a 100644 --- a/scripts/ci/hygiene_gate_baseline.json +++ b/scripts/ci/hygiene_gate_baseline.json @@ -42,8 +42,8 @@ }, "client/src/input/microphone.rs": { "clippy_warnings": 17, - "doc_debt": 7, - "loc": 210 + "doc_debt": 9, + "loc": 268 }, "client/src/input/mod.rs": { "clippy_warnings": 0, @@ -67,8 +67,8 @@ }, "client/src/launcher/devices.rs": { "clippy_warnings": 6, - "doc_debt": 11, - "loc": 348 + "doc_debt": 14, + "loc": 400 }, "client/src/launcher/diagnostics.rs": { "clippy_warnings": 92, @@ -77,8 +77,8 @@ }, "client/src/launcher/mod.rs": { "clippy_warnings": 8, - "doc_debt": 7, - "loc": 438 + "doc_debt": 8, + "loc": 480 }, "client/src/launcher/power.rs": { "clippy_warnings": 0, @@ -91,24 +91,24 @@ "loc": 2216 }, "client/src/launcher/state.rs": { - "clippy_warnings": 154, - "doc_debt": 54, - "loc": 1377 + "clippy_warnings": 168, + "doc_debt": 57, + "loc": 1478 }, "client/src/launcher/ui.rs": { - "clippy_warnings": 62, + "clippy_warnings": 68, "doc_debt": 23, - "loc": 2367 + "loc": 2497 }, "client/src/launcher/ui_components.rs": { - "clippy_warnings": 16, - "doc_debt": 15, - "loc": 1372 + "clippy_warnings": 22, + "doc_debt": 17, + "loc": 1497 }, "client/src/launcher/ui_runtime.rs": { - "clippy_warnings": 62, + "clippy_warnings": 70, "doc_debt": 44, - "loc": 1698 + "loc": 1768 }, "client/src/layout.rs": { "clippy_warnings": 6, @@ -273,7 +273,7 @@ "server/src/video.rs": { "clippy_warnings": 53, "doc_debt": 12, - "loc": 840 + "loc": 844 }, "server/src/video_sinks.rs": { "clippy_warnings": 78, diff --git a/scripts/ci/quality_gate_baseline.json b/scripts/ci/quality_gate_baseline.json index cbe5286..bd78253 100644 --- a/scripts/ci/quality_gate_baseline.json +++ b/scripts/ci/quality_gate_baseline.json @@ -33,8 +33,8 @@ "loc": 196 }, "client/src/input/microphone.rs": { - "line_percent": 89.81, - "loc": 210 + "line_percent": 96.71, + "loc": 268 }, "client/src/input/mouse.rs": { "line_percent": 97.32, @@ -45,24 +45,24 @@ "loc": 178 }, "client/src/launcher/devices.rs": { - "line_percent": 95.7, - "loc": 348 + "line_percent": 95.93, + "loc": 400 }, "client/src/launcher/diagnostics.rs": { "line_percent": 84.3, "loc": 1021 }, "client/src/launcher/mod.rs": { - "line_percent": 82.32, - "loc": 438 + "line_percent": 84.2, + "loc": 480 }, "client/src/launcher/state.rs": { - "line_percent": 84.01, - "loc": 1377 + "line_percent": 85.06, + "loc": 1478 }, "client/src/launcher/ui.rs": { "line_percent": 100.0, - "loc": 2367 + "loc": 2497 }, "client/src/layout.rs": { "line_percent": 97.73, @@ -169,8 +169,8 @@ "loc": 241 }, "server/src/video.rs": { - "line_percent": 96.55, - "loc": 840 + "line_percent": 96.6, + "loc": 844 }, "server/src/video_sinks.rs": { "line_percent": 100.0, diff --git a/server/src/video.rs b/server/src/video.rs index 0654d20..3d59e60 100644 --- a/server/src/video.rs +++ b/server/src/video.rs @@ -333,23 +333,27 @@ pub async fn eye_ball_with_request( } let _ = gst::init(); - let pipeline = gst::Pipeline::new(); let (tx, rx) = tokio::sync::mpsc::channel(64); - for seq in 0..8 { - let _ = tx.try_send(Ok(VideoPacket { - id: id.min(1), - pts: seq * 16_666, - data: vec![0, 0, 0, 1, 0x65, 0x88, 0x84], - seq: seq + 1, - effective_fps: 60, - server_encoder_label: "coverage-testsrc".to_string(), - ..Default::default() - })); - } + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + for seq in 0..8 { + let _ = tx + .send(Ok(VideoPacket { + id: id.min(1), + pts: seq * 16_666, + data: vec![0, 0, 0, 1, 0x65, 0x88, 0x84], + seq: seq + 1, + effective_fps: 60, + server_encoder_label: "coverage-testsrc".to_string(), + ..Default::default() + })) + .await; + } + }); Ok(VideoStream { - _pipeline: pipeline, + _pipeline: gst::Pipeline::new(), #[cfg(not(coverage))] _bus_watch: None, inner: ReceiverStream::new(rx), diff --git a/testing/tests/client_launcher_layout_contract.rs b/testing/tests/client_launcher_layout_contract.rs index 749feb6..7c83930 100644 --- a/testing/tests/client_launcher_layout_contract.rs +++ b/testing/tests/client_launcher_layout_contract.rs @@ -120,14 +120,26 @@ fn relay_controls_keep_connect_inline_with_server_entry() { fn remote_audio_gain_control_stays_in_the_operations_rail() { assert!(!UI_SRC.contains("Remote Audio")); assert!(UI_SRC.contains("let power_shell = gtk::Box::new(gtk::Orientation::Vertical, 6);")); + assert!(UI_SRC.contains("let channel_heading = gtk::Label::new(Some(\"Streams\"));")); + assert!(UI_SRC.contains("gtk::CheckButton::with_label(\"Webcam\")")); + assert!(UI_SRC.contains("gtk::CheckButton::with_label(\"Mic\")")); + assert!(UI_SRC.contains("gtk::CheckButton::with_label(\"Audio\")")); + assert!(!UI_SRC.contains("camera_combo.append(Some(\"auto\")")); + assert!(!UI_SRC.contains("speaker_combo.append(Some(\"auto\")")); + assert!(!UI_SRC.contains("microphone_combo.append(Some(\"auto\")")); assert!(UI_SRC.contains("let power_heading = gtk::Label::new(Some(\"GPIO Power\"));")); assert!(UI_SRC.contains("power_row.append(&power_heading);")); assert!(UI_SRC.contains("power_buttons.set_homogeneous(true);")); 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("let mic_gain_scale =")); + assert!(UI_SRC.contains("mic_gain_scale.set_draw_value(false);")); + assert!(UI_SRC.contains("mic_gain_value.set_width_chars(5);")); assert!(UI_SRC.contains("audio_gain_row.set_size_request(220, -1);")); + assert!(UI_SRC.contains("mic_gain_row.set_size_request(220, -1);")); assert!(UI_SRC.contains("power_shell.append(&audio_gain_row);")); + assert!(UI_SRC.contains("power_shell.append(&mic_gain_row);")); assert_eq!( UI_SRC .matches("connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));") @@ -141,6 +153,10 @@ fn remote_audio_gain_control_stays_in_the_operations_rail() { ); assert!( source_index("power_shell.append(&audio_gain_row);") + < source_index("power_shell.append(&mic_gain_row);") + ); + assert!( + source_index("power_shell.append(&mic_gain_row);") < source_index("let routing_heading = gtk::Label::new(Some(\"Inputs\"));") ); assert!(UI_SRC.contains("routing_buttons.set_homogeneous(true);")); diff --git a/testing/tests/client_launcher_runtime_contract.rs b/testing/tests/client_launcher_runtime_contract.rs index f35479e..7311839 100644 --- a/testing/tests/client_launcher_runtime_contract.rs +++ b/testing/tests/client_launcher_runtime_contract.rs @@ -30,6 +30,26 @@ fn relay_child_starts_safe_parent_watchdog_on_boot() { #[test] fn relay_address_entry_is_locked_while_relay_is_live() { assert!(UI_RUNTIME_SRC.contains("widgets.server_entry.set_sensitive(!relay_live);")); + assert!( + UI_RUNTIME_SRC.contains( + ".camera_combo\n .set_sensitive(!relay_live && state.channels.camera);" + ) + ); + assert!(UI_RUNTIME_SRC.contains( + ".microphone_combo\n .set_sensitive(!relay_live && state.channels.microphone);" + )); + assert!( + UI_RUNTIME_SRC.contains( + ".speaker_combo\n .set_sensitive(!relay_live && state.channels.audio);" + ) + ); + assert!(UI_RUNTIME_SRC.contains("widgets.keyboard_combo.set_sensitive(!relay_live);")); + assert!(UI_RUNTIME_SRC.contains("widgets.mouse_combo.set_sensitive(!relay_live);")); + assert!(UI_RUNTIME_SRC.contains("widgets.camera_channel_toggle.set_sensitive(!relay_live);")); + assert!( + UI_RUNTIME_SRC.contains("widgets.microphone_channel_toggle.set_sensitive(!relay_live);") + ); + assert!(UI_RUNTIME_SRC.contains("widgets.audio_channel_toggle.set_sensitive(!relay_live);")); assert!(UI_RUNTIME_SRC.contains("\"Connect\"")); assert!(UI_RUNTIME_SRC.contains("\"Disconnect\"")); } @@ -37,8 +57,18 @@ fn relay_address_entry_is_locked_while_relay_is_live() { #[test] fn audio_gain_slider_callback_never_panics_on_refresh_reentry() { assert!(UI_SRC.contains("fn apply_audio_gain_change(")); + assert!(UI_SRC.contains("fn apply_mic_gain_change(")); assert!(UI_SRC.contains("state.try_borrow_mut()")); assert!(UI_SRC.contains("return false;")); assert!(UI_SRC.contains("glib::idle_add_local_once")); assert!(!UI_SRC.contains("let mut state = state.borrow_mut();\n if state.audio_gain_percent == percent")); } + +#[test] +fn live_power_probe_failures_do_not_flip_relay_state_red() { + assert!(UI_SRC.contains("PowerMessage::Refresh(Err(err))")); + assert!(UI_SRC.contains("let relay_live = child_proc.borrow().is_some()")); + assert!(UI_SRC.contains("if relay_live")); + assert!(UI_SRC.contains("state.set_server_available(true);")); + assert!(UI_SRC.contains("if !state.capture_power.available")); +} diff --git a/testing/tests/client_microphone_include_contract.rs b/testing/tests/client_microphone_include_contract.rs index e2a016c..b993582 100644 --- a/testing/tests/client_microphone_include_contract.rs +++ b/testing/tests/client_microphone_include_contract.rs @@ -24,9 +24,9 @@ mod microphone_include_contract { fs::set_permissions(path, perms).expect("chmod"); } - fn with_fake_pactl(script_body: &str, f: impl FnOnce()) { + fn with_fake_command(name: &str, script_body: &str, f: impl FnOnce()) { let dir = tempdir().expect("tempdir"); - write_executable(dir.path(), "pactl", script_body); + write_executable(dir.path(), name, script_body); let prior = std::env::var("PATH").unwrap_or_default(); let merged = if prior.is_empty() { dir.path().display().to_string() @@ -36,6 +36,14 @@ mod microphone_include_contract { with_var("PATH", Some(merged), f); } + fn with_fake_pactl(script_body: &str, f: impl FnOnce()) { + with_fake_command("pactl", script_body, f); + } + + fn with_fake_pw_dump(script_body: &str, f: impl FnOnce()) { + with_fake_command("pw-dump", script_body, f); + } + #[test] #[serial] fn pulse_source_by_substr_matches_expected_device_name() { @@ -88,6 +96,140 @@ exit 0 }); } + #[test] + fn pipewire_source_desc_formats_selected_and_default_sources() { + let selected = MicrophoneCapture::pipewire_source_desc(Some("alsa input/Desk Mic")); + assert!( + selected.contains("pipewiresrc target-object='alsa input/Desk Mic'"), + "expected shell-escaped PipeWire target: {selected}" + ); + assert_eq!( + MicrophoneCapture::pipewire_source_desc(Some(" ")), + "pipewiresrc do-timestamp=true" + ); + assert_eq!( + MicrophoneCapture::pipewire_source_desc(None), + "pipewiresrc do-timestamp=true" + ); + } + + #[test] + #[serial] + fn pipewire_source_by_substr_prefers_audio_sources_and_skips_monitors() { + let script = r#"#!/usr/bin/env sh +cat <<'JSON' +[ + {"info":{"props":{"media.class":"Audio/Sink","node.name":"alsa_output.usb-headphones"}}}, + {"info":{"props":{"media.class":"Audio/Source","node.name":"alsa_input.usb-DeskMic.monitor"}}}, + {"info":{"props":{"media.class":"Audio/Source","node.name":"alsa_input.usb-DeskMic"}}}, + {"info":{"props":{"media.class":"Audio/Source","node.nick":"Fallback Nick Mic"}}} +] +JSON +"#; + with_fake_pw_dump(script, || { + assert_eq!( + MicrophoneCapture::pipewire_source_by_substr("DeskMic").as_deref(), + Some("alsa_input.usb-DeskMic") + ); + assert_eq!( + MicrophoneCapture::pipewire_source_by_substr("Fallback Nick").as_deref(), + Some("Fallback Nick Mic") + ); + assert!(MicrophoneCapture::pipewire_source_by_substr("missing").is_none()); + }); + } + + #[test] + fn default_source_desc_selects_a_valid_gstreamer_source_description() { + gst::init().ok(); + let desc = MicrophoneCapture::default_source_desc(); + assert!( + desc == "pipewiresrc do-timestamp=true" || desc == "pulsesrc do-timestamp=true", + "default source should stay a simple PipeWire/Pulse source: {desc}" + ); + } + + #[test] + #[serial] + fn mic_gain_env_defaults_and_clamps_for_uplink_gain() { + with_var("LESAVKA_MIC_GAIN", None::<&str>, || { + assert_eq!(mic_gain_from_env(), 1.0); + }); + with_var("LESAVKA_MIC_GAIN", Some("2.75"), || { + assert_eq!(mic_gain_from_env(), 2.75); + }); + with_var("LESAVKA_MIC_GAIN", Some("99"), || { + assert_eq!(mic_gain_from_env(), 4.0); + }); + with_var("LESAVKA_MIC_GAIN", Some("-1"), || { + assert_eq!(mic_gain_from_env(), 0.0); + }); + with_var("LESAVKA_MIC_GAIN", Some("bad"), || { + assert_eq!(mic_gain_from_env(), 1.0); + }); + } + + #[test] + fn mic_gain_control_reads_first_token_and_clamps() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("mic-gain.control"); + fs::write(&path, "3.250 nonce\n").expect("write gain"); + assert_eq!(read_mic_gain_control(&path), Some(3.25)); + + fs::write(&path, "20.0 nonce\n").expect("write clamped gain"); + assert_eq!(read_mic_gain_control(&path), Some(4.0)); + + fs::write(&path, "bad nonce\n").expect("write invalid gain"); + assert_eq!(read_mic_gain_control(&path), None); + } + + #[test] + #[serial] + fn mic_gain_control_returns_without_env() { + gst::init().ok(); + let volume = gst::ElementFactory::make("volume") + .build() + .expect("volume element"); + volume.set_property("volume", 1.75_f64); + + with_var("LESAVKA_MIC_GAIN_CONTROL", None::<&str>, || { + maybe_spawn_mic_gain_control(volume.clone()); + }); + + assert_eq!(volume.property::("volume"), 1.75); + } + + #[test] + #[serial] + fn mic_gain_control_updates_volume_element_live() { + gst::init().ok(); + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("mic-gain.control"); + fs::write(&path, "2.500 nonce\n").expect("write gain"); + let volume = gst::ElementFactory::make("volume") + .build() + .expect("volume element"); + + with_var( + "LESAVKA_MIC_GAIN_CONTROL", + Some(path.to_string_lossy().to_string()), + || { + maybe_spawn_mic_gain_control(volume.clone()); + for _ in 0..20 { + if (volume.property::("volume") - 2.5).abs() < 0.001 { + return; + } + std::thread::sleep(std::time::Duration::from_millis(25)); + } + }, + ); + + assert!( + (volume.property::("volume") - 2.5).abs() < 0.001, + "live mic gain control should update the GStreamer volume" + ); + } + #[test] fn pull_returns_none_for_empty_appsink() { gst::init().ok(); diff --git a/testing/tests/server_main_rpc_contract.rs b/testing/tests/server_main_rpc_contract.rs index b3c6aa0..8d138ff 100644 --- a/testing/tests/server_main_rpc_contract.rs +++ b/testing/tests/server_main_rpc_contract.rs @@ -167,6 +167,10 @@ mod server_main_rpc { .expect("packet"); assert_eq!(packet.id, 0); assert!(!packet.data.is_empty()); + drop(stream); + rt.block_on(async { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + }); }, ); } @@ -212,6 +216,9 @@ mod server_main_rpc { .await .expect("right item") .expect("right packet"); + drop(left); + drop(right); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; let hub_count = handler.eye_hub_count().await; (left_packet, right_packet, hub_count) }); diff --git a/testing/tests/server_main_usb_recovery_contract.rs b/testing/tests/server_main_usb_recovery_contract.rs index 70f55dc..67731fe 100644 --- a/testing/tests/server_main_usb_recovery_contract.rs +++ b/testing/tests/server_main_usb_recovery_contract.rs @@ -277,14 +277,33 @@ printf 'configured\n' > "$LESAVKA_GADGET_SYSFS_ROOT/class/udc/fake-ctrl.usb/stat data: vec![9, 8, 7], ..Default::default() }; - let hub = EyeHub::spawn(stream::iter(vec![Ok(packet.clone())]), lease); + let (packet_tx, packet_rx) = tokio::sync::mpsc::channel(1); + let hub = EyeHub::spawn(ReceiverStream::new(packet_rx), lease); hub.subscribers .fetch_add(1, std::sync::atomic::Ordering::AcqRel); let mut rx = hub.tx.subscribe(); - let observed = rx.recv().await.expect("hub packet"); + packet_tx + .send(Ok(packet.clone())) + .await + .expect("send synthetic packet"); + let observed = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()) + .await + .expect("hub packet timeout") + .expect("hub packet"); assert_eq!(observed.id, packet.id); assert_eq!(observed.pts, packet.pts); assert_eq!(observed.data, packet.data); + drop(packet_tx); + for _ in 0..20 { + if !hub.running.load(std::sync::atomic::Ordering::Relaxed) { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert!( + !hub.running.load(std::sync::atomic::Ordering::Relaxed), + "hub should stop after the synthetic packet stream closes" + ); }); }); }