fix(launcher): gate media channels

This commit is contained in:
Brad Stein 2026-04-22 00:56:03 -03:00
parent e33ff7e42d
commit d0e98f42a5
15 changed files with 894 additions and 98 deletions

View File

@ -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<f64> {
let value = raw.split_ascii_whitespace().next()?.parse::<f64>().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<f64> {
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);

View File

@ -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<String> {
set.into_iter().collect()
}
fn dedupe_camera_devices(names: impl IntoIterator<Item = String>) -> Vec<String> {
let mut by_physical_device: BTreeMap<String, String> = 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<String> {
let mut set = BTreeSet::new();
for sink in discover_pactl_devices("sinks") {
@ -80,7 +115,7 @@ fn discover_camera_devices(override_dir: Option<String>) -> Vec<String> {
set.insert(name.to_string_lossy().to_string());
}
}
set.into_iter().collect()
dedupe_camera_devices(set)
}
fn discover_pactl_devices(kind: &str) -> Vec<String> {
@ -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()));

View File

@ -149,21 +149,30 @@ pub fn runtime_env_vars(state: &LauncherState) -> BTreeMap<String, String> {
"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]

View File

@ -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<String>,
}
#[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<String>) {
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<String>) {
@ -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<String>) -> Option<String> {
})
}
fn keep_or_select_first(selection: &mut Option<String>, 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);

View File

@ -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: &gtk::Scale,
state: &Rc<RefCell<LauncherState>>,
widgets: &super::ui_components::LauncherWidgets,
child_proc: &Rc<RefCell<Option<RelayChild>>>,
) -> 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<PowerMessage>,
@ -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 = {

View File

@ -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(&microphone_combo, microphone);
}
super::ui_runtime::set_combo_active_text(
sync_stage_device_combo(
&microphone_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(&microphone_channel_toggle);
channel_buttons.append(&audio_channel_toggle);
channel_row.append(&channel_buttons);
connection_body.append(&channel_row);
connection_body.append(&gtk::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: &gtk::ComboBoxText, value: &str) {
combo.append(Some(value), &compact_stage_label(value));
}
fn set_stage_combo_active_text(combo: &gtk::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('_', " ");

View File

@ -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() {

View File

@ -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,

View File

@ -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,

View File

@ -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),

View File

@ -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(&gtk::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);"));

View File

@ -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"));
}

View File

@ -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::<f64>("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::<f64>("volume") - 2.5).abs() < 0.001 {
return;
}
std::thread::sleep(std::time::Duration::from_millis(25));
}
},
);
assert!(
(volume.property::<f64>("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();

View File

@ -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)
});

View File

@ -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"
);
});
});
}