fix(launcher): gate media channels
This commit is contained in:
parent
e33ff7e42d
commit
d0e98f42a5
@ -7,10 +7,14 @@ use lesavka_common::lesavka::AudioPacket;
|
|||||||
use shell_escape::unix::escape;
|
use shell_escape::unix::escape;
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::{path::Path as StdPath, thread, time::Duration};
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
use tracing::{error, info, trace};
|
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 {
|
pub struct MicrophoneCapture {
|
||||||
#[allow(dead_code)] // kept alive to hold PLAYING state
|
#[allow(dead_code)] // kept alive to hold PLAYING state
|
||||||
pipeline: gst::Pipeline,
|
pipeline: gst::Pipeline,
|
||||||
@ -44,17 +48,24 @@ impl MicrophoneCapture {
|
|||||||
// AAC → ADTS frames
|
// AAC → ADTS frames
|
||||||
"aacparse ! capsfilter caps=audio/mpeg,stream-format=adts,rate=48000,channels=2"
|
"aacparse ! capsfilter caps=audio/mpeg,stream-format=adts,rate=48000,channels=2"
|
||||||
};
|
};
|
||||||
|
let gain = mic_gain_from_env();
|
||||||
let desc = format!(
|
let desc = format!(
|
||||||
"{source_desc} ! \
|
"{source_desc} ! \
|
||||||
audio/x-raw,format=S16LE,channels=2,rate=48000 ! \
|
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} ! \
|
{parser} ! \
|
||||||
queue max-size-buffers=100 leaky=downstream ! \
|
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 pipeline: gst::Pipeline = gst::parse::launch(&desc)?.downcast().expect("pipeline");
|
||||||
let sink: gst_app::AppSink = pipeline.by_name("asink").unwrap().downcast().unwrap();
|
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))]
|
#[cfg(not(coverage))]
|
||||||
{
|
{
|
||||||
@ -89,6 +100,7 @@ impl MicrophoneCapture {
|
|||||||
pipeline
|
pipeline
|
||||||
.set_state(gst::State::Playing)
|
.set_state(gst::State::Playing)
|
||||||
.context("start mic pipeline")?;
|
.context("start mic pipeline")?;
|
||||||
|
maybe_spawn_mic_gain_control(volume);
|
||||||
|
|
||||||
Ok(Self { pipeline, sink })
|
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 {
|
impl Drop for MicrophoneCapture {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
let _ = self.pipeline.set_state(gst::State::Null);
|
let _ = self.pipeline.set_state(gst::State::Null);
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
use std::collections::BTreeSet;
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
use evdev::{AbsoluteAxisCode, Device, EventType, KeyCode, RelativeAxisCode};
|
use evdev::{AbsoluteAxisCode, Device, EventType, KeyCode, RelativeAxisCode};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@ -56,6 +56,41 @@ fn discover_microphone_devices() -> Vec<String> {
|
|||||||
set.into_iter().collect()
|
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> {
|
fn discover_speaker_devices() -> Vec<String> {
|
||||||
let mut set = BTreeSet::new();
|
let mut set = BTreeSet::new();
|
||||||
for sink in discover_pactl_devices("sinks") {
|
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.insert(name.to_string_lossy().to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
set.into_iter().collect()
|
dedupe_camera_devices(set)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn discover_pactl_devices(kind: &str) -> Vec<String> {
|
fn discover_pactl_devices(kind: &str) -> Vec<String> {
|
||||||
@ -268,6 +303,23 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(tmp);
|
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]
|
#[test]
|
||||||
fn camera_discovery_returns_empty_when_directory_missing() {
|
fn camera_discovery_returns_empty_when_directory_missing() {
|
||||||
let devices = discover_camera_devices(Some("/tmp/does-not-exist-lesavka".to_string()));
|
let devices = discover_camera_devices(Some("/tmp/does-not-exist-lesavka".to_string()));
|
||||||
|
|||||||
@ -149,21 +149,30 @@ pub fn runtime_env_vars(state: &LauncherState) -> BTreeMap<String, String> {
|
|||||||
"LESAVKA_AUDIO_GAIN".to_string(),
|
"LESAVKA_AUDIO_GAIN".to_string(),
|
||||||
state.audio_gain_env_value(),
|
state.audio_gain_env_value(),
|
||||||
);
|
);
|
||||||
|
envs.insert("LESAVKA_MIC_GAIN".to_string(), state.mic_gain_env_value());
|
||||||
if matches!(state.view_mode, ViewMode::Unified) {
|
if matches!(state.view_mode, ViewMode::Unified) {
|
||||||
envs.insert("LESAVKA_DISABLE_VIDEO_RENDER".to_string(), "1".to_string());
|
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());
|
envs.insert("LESAVKA_CAM_SOURCE".to_string(), camera.clone());
|
||||||
} else {
|
} else {
|
||||||
envs.insert("LESAVKA_CAM_DISABLE".to_string(), "1".to_string());
|
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());
|
envs.insert("LESAVKA_MIC_SOURCE".to_string(), microphone.clone());
|
||||||
} else {
|
} else {
|
||||||
envs.insert("LESAVKA_MIC_DISABLE".to_string(), "1".to_string());
|
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());
|
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() {
|
if let Some(keyboard) = state.devices.keyboard.as_ref() {
|
||||||
envs.insert("LESAVKA_KEYBOARD_DEVICE".to_string(), keyboard.clone());
|
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_camera(Some("/dev/video0".to_string()));
|
||||||
state.select_microphone(Some("alsa_input.test".to_string()));
|
state.select_microphone(Some("alsa_input.test".to_string()));
|
||||||
state.select_speaker(Some("alsa_output.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_keyboard(Some("/dev/input/event10".to_string()));
|
||||||
state.select_mouse(Some("/dev/input/event11".to_string()));
|
state.select_mouse(Some("/dev/input/event11".to_string()));
|
||||||
|
|
||||||
@ -262,6 +274,7 @@ mod tests {
|
|||||||
Some(&"18".to_string())
|
Some(&"18".to_string())
|
||||||
);
|
);
|
||||||
assert_eq!(envs.get("LESAVKA_AUDIO_GAIN"), Some(&"2.000".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!(
|
assert_eq!(
|
||||||
envs.get(REMOTE_INPUT_FAILSAFE_SECONDS_ENV),
|
envs.get(REMOTE_INPUT_FAILSAFE_SECONDS_ENV),
|
||||||
Some(&DEFAULT_REMOTE_INPUT_FAILSAFE_SECONDS.to_string())
|
Some(&DEFAULT_REMOTE_INPUT_FAILSAFE_SECONDS.to_string())
|
||||||
@ -351,13 +364,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
let mut state = LauncherState::new();
|
||||||
state.select_microphone(Some("auto".to_string()));
|
state.select_microphone(Some("auto".to_string()));
|
||||||
state.select_speaker(Some("auto".to_string()));
|
state.select_speaker(Some("auto".to_string()));
|
||||||
|
state.set_microphone_channel_enabled(true);
|
||||||
|
|
||||||
let envs = runtime_env_vars(&state);
|
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!(!envs.contains_key("LESAVKA_MIC_SOURCE"));
|
||||||
|
assert_eq!(envs.get("LESAVKA_AUDIO_DISABLE"), Some(&"1".to_string()));
|
||||||
assert!(!envs.contains_key("LESAVKA_AUDIO_SINK"));
|
assert!(!envs.contains_key("LESAVKA_AUDIO_SINK"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -365,9 +381,35 @@ mod tests {
|
|||||||
fn runtime_env_vars_emit_selected_audio_gain() {
|
fn runtime_env_vars_emit_selected_audio_gain() {
|
||||||
let mut state = LauncherState::new();
|
let mut state = LauncherState::new();
|
||||||
state.set_audio_gain_percent(425);
|
state.set_audio_gain_percent(425);
|
||||||
|
state.set_mic_gain_percent(275);
|
||||||
|
|
||||||
let envs = runtime_env_vars(&state);
|
let envs = runtime_env_vars(&state);
|
||||||
assert_eq!(envs.get("LESAVKA_AUDIO_GAIN"), Some(&"4.250".to_string()));
|
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]
|
#[test]
|
||||||
|
|||||||
@ -7,6 +7,8 @@ use lesavka_common::eye_source::{
|
|||||||
|
|
||||||
pub const DEFAULT_AUDIO_GAIN_PERCENT: u32 = 200;
|
pub const DEFAULT_AUDIO_GAIN_PERCENT: u32 = 200;
|
||||||
pub const MAX_AUDIO_GAIN_PERCENT: u32 = 800;
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum InputRouting {
|
pub enum InputRouting {
|
||||||
@ -301,6 +303,23 @@ pub struct DeviceSelection {
|
|||||||
pub mouse: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct LauncherState {
|
pub struct LauncherState {
|
||||||
pub server_available: bool,
|
pub server_available: bool,
|
||||||
@ -317,7 +336,9 @@ pub struct LauncherState {
|
|||||||
pub capture_bitrates_kbit: [u32; 2],
|
pub capture_bitrates_kbit: [u32; 2],
|
||||||
pub breakout_sizes: [BreakoutSizePreset; 2],
|
pub breakout_sizes: [BreakoutSizePreset; 2],
|
||||||
pub devices: DeviceSelection,
|
pub devices: DeviceSelection,
|
||||||
|
pub channels: ChannelSelection,
|
||||||
pub audio_gain_percent: u32,
|
pub audio_gain_percent: u32,
|
||||||
|
pub mic_gain_percent: u32,
|
||||||
pub swap_key: String,
|
pub swap_key: String,
|
||||||
pub swap_key_binding: bool,
|
pub swap_key_binding: bool,
|
||||||
pub swap_key_binding_token: u64,
|
pub swap_key_binding_token: u64,
|
||||||
@ -343,7 +364,9 @@ impl Default for LauncherState {
|
|||||||
capture_bitrates_kbit: [18_000, 18_000],
|
capture_bitrates_kbit: [18_000, 18_000],
|
||||||
breakout_sizes: [BreakoutSizePreset::Source, BreakoutSizePreset::Source],
|
breakout_sizes: [BreakoutSizePreset::Source, BreakoutSizePreset::Source],
|
||||||
devices: DeviceSelection::default(),
|
devices: DeviceSelection::default(),
|
||||||
|
channels: ChannelSelection::default(),
|
||||||
audio_gain_percent: DEFAULT_AUDIO_GAIN_PERCENT,
|
audio_gain_percent: DEFAULT_AUDIO_GAIN_PERCENT,
|
||||||
|
mic_gain_percent: DEFAULT_MIC_GAIN_PERCENT,
|
||||||
swap_key: "pause".to_string(),
|
swap_key: "pause".to_string(),
|
||||||
swap_key_binding: false,
|
swap_key_binding: false,
|
||||||
swap_key_binding_token: 0,
|
swap_key_binding_token: 0,
|
||||||
@ -643,6 +666,18 @@ impl LauncherState {
|
|||||||
self.devices.speaker = normalize_selection(speaker);
|
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) {
|
pub fn set_audio_gain_percent(&mut self, percent: u32) {
|
||||||
self.audio_gain_percent = normalize_audio_gain_percent(percent);
|
self.audio_gain_percent = normalize_audio_gain_percent(percent);
|
||||||
}
|
}
|
||||||
@ -659,6 +694,22 @@ impl LauncherState {
|
|||||||
format_audio_gain_percent(self.audio_gain_percent)
|
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>) {
|
pub fn select_keyboard(&mut self, keyboard: Option<String>) {
|
||||||
self.devices.keyboard = normalize_selection(keyboard);
|
self.devices.keyboard = normalize_selection(keyboard);
|
||||||
}
|
}
|
||||||
@ -668,7 +719,9 @@ impl LauncherState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_catalog_defaults(&mut self, catalog: &DeviceCatalog) {
|
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>) {
|
pub fn set_swap_key(&mut self, swap_key: impl Into<String>) {
|
||||||
@ -725,7 +778,7 @@ impl LauncherState {
|
|||||||
|
|
||||||
pub fn status_line(&self) -> String {
|
pub fn status_line(&self) -> String {
|
||||||
format!(
|
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,
|
self.server_available,
|
||||||
match self.routing {
|
match self.routing {
|
||||||
InputRouting::Local => "local",
|
InputRouting::Local => "local",
|
||||||
@ -747,10 +800,14 @@ impl LauncherState {
|
|||||||
self.displays[1].label(),
|
self.displays[1].label(),
|
||||||
self.feed_source_preset(0).as_id(),
|
self.feed_source_preset(0).as_id(),
|
||||||
self.feed_source_preset(1).as_id(),
|
self.feed_source_preset(1).as_id(),
|
||||||
self.devices.camera.as_deref().unwrap_or("auto"),
|
media_status_label(self.channels.camera, self.devices.camera.as_deref()),
|
||||||
self.devices.microphone.as_deref().unwrap_or("auto"),
|
media_status_label(self.channels.microphone, self.devices.microphone.as_deref()),
|
||||||
self.devices.speaker.as_deref().unwrap_or("auto"),
|
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.audio_gain_label(),
|
||||||
|
self.mic_gain_label(),
|
||||||
self.devices.keyboard.as_deref().unwrap_or("all"),
|
self.devices.keyboard.as_deref().unwrap_or("all"),
|
||||||
self.devices.mouse.as_deref().unwrap_or("all"),
|
self.devices.mouse.as_deref().unwrap_or("all"),
|
||||||
self.swap_key,
|
self.swap_key,
|
||||||
@ -766,6 +823,14 @@ pub fn format_audio_gain_percent(percent: u32) -> String {
|
|||||||
format!("{}%", normalize_audio_gain_percent(percent))
|
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(
|
fn breakout_size_choice(
|
||||||
physical_limit: PreviewSourceSize,
|
physical_limit: PreviewSourceSize,
|
||||||
display_fill: 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 {
|
fn normalize_swap_key(value: String) -> String {
|
||||||
let trimmed = value.trim();
|
let trimmed = value.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
@ -1011,9 +1090,15 @@ mod tests {
|
|||||||
assert!(state.devices.speaker.is_none());
|
assert!(state.devices.speaker.is_none());
|
||||||
assert!(state.devices.keyboard.is_none());
|
assert!(state.devices.keyboard.is_none());
|
||||||
assert!(state.devices.mouse.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_percent, DEFAULT_AUDIO_GAIN_PERCENT);
|
||||||
assert_eq!(state.audio_gain_env_value(), "2.000");
|
assert_eq!(state.audio_gain_env_value(), "2.000");
|
||||||
assert_eq!(state.audio_gain_label(), "200%");
|
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.unit, "relay.service");
|
||||||
assert_eq!(state.capture_power.mode, "auto");
|
assert_eq!(state.capture_power.mode, "auto");
|
||||||
}
|
}
|
||||||
@ -1088,7 +1173,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
let mut state = LauncherState::new();
|
||||||
state.select_camera(Some("/dev/video-special".to_string()));
|
state.select_camera(Some("/dev/video-special".to_string()));
|
||||||
|
|
||||||
@ -1103,14 +1188,17 @@ mod tests {
|
|||||||
state.apply_catalog_defaults(&catalog);
|
state.apply_catalog_defaults(&catalog);
|
||||||
|
|
||||||
assert_eq!(state.devices.camera.as_deref(), Some("/dev/video-special"));
|
assert_eq!(state.devices.camera.as_deref(), Some("/dev/video-special"));
|
||||||
assert!(state.devices.microphone.is_none());
|
assert_eq!(state.devices.microphone.as_deref(), Some("alsa_input.usb"));
|
||||||
assert!(state.devices.speaker.is_none());
|
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();
|
let mut fresh = LauncherState::new();
|
||||||
fresh.apply_catalog_defaults(&catalog);
|
fresh.apply_catalog_defaults(&catalog);
|
||||||
assert!(fresh.devices.camera.is_none());
|
assert_eq!(fresh.devices.camera.as_deref(), Some("/dev/video0"));
|
||||||
assert!(fresh.devices.microphone.is_none());
|
assert_eq!(fresh.devices.microphone.as_deref(), Some("alsa_input.usb"));
|
||||||
assert!(fresh.devices.speaker.is_none());
|
assert_eq!(fresh.devices.speaker.as_deref(), Some("alsa_output.usb"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1125,6 +1213,16 @@ mod tests {
|
|||||||
assert_eq!(state.audio_gain_percent, MAX_AUDIO_GAIN_PERCENT);
|
assert_eq!(state.audio_gain_percent, MAX_AUDIO_GAIN_PERCENT);
|
||||||
assert_eq!(state.audio_gain_label(), "800%");
|
assert_eq!(state.audio_gain_label(), "800%");
|
||||||
assert_eq!(state.audio_gain_env_value(), "8.000");
|
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]
|
#[test]
|
||||||
@ -1148,6 +1246,9 @@ mod tests {
|
|||||||
state.select_camera(Some("/dev/video0".to_string()));
|
state.select_camera(Some("/dev/video0".to_string()));
|
||||||
state.select_microphone(Some("alsa_input.usb".to_string()));
|
state.select_microphone(Some("alsa_input.usb".to_string()));
|
||||||
state.select_speaker(Some("alsa_output.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_keyboard(Some("/dev/input/event-kbd".to_string()));
|
||||||
state.select_mouse(Some("/dev/input/event-mouse".to_string()));
|
state.select_mouse(Some("/dev/input/event-mouse".to_string()));
|
||||||
state.set_preview_source_profile(1920, 1080, 30);
|
state.set_preview_source_profile(1920, 1080, 30);
|
||||||
|
|||||||
@ -12,18 +12,20 @@ use {
|
|||||||
super::state::{
|
super::state::{
|
||||||
BreakoutSizePreset, CapturePowerStatus, CaptureSizePreset, DisplaySurface,
|
BreakoutSizePreset, CapturePowerStatus, CaptureSizePreset, DisplaySurface,
|
||||||
FeedSourcePreset, InputRouting, LauncherState, MAX_AUDIO_GAIN_PERCENT,
|
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_components::{build_launcher_view, sync_input_device_combo, sync_stage_device_combo},
|
||||||
super::ui_runtime::{
|
super::ui_runtime::{
|
||||||
RelayChild, append_session_log, apply_popout_window_size, attach_child_log_streams,
|
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,
|
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,
|
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,
|
input_state_path, input_toggle_control_path, mic_gain_control_path, next_input_routing,
|
||||||
open_popout_window, open_session_log_popout, path_marker, present_popout_windows,
|
open_diagnostics_popout, open_popout_window, open_session_log_popout, path_marker,
|
||||||
read_input_routing_state, reap_exited_child, refresh_launcher_ui, refresh_test_buttons,
|
present_popout_windows, read_input_routing_state, reap_exited_child, refresh_launcher_ui,
|
||||||
routing_name, selected_combo_value, selected_server_addr, shutdown_launcher_runtime,
|
refresh_test_buttons, routing_name, selected_combo_value, selected_server_addr,
|
||||||
spawn_client_process, stop_child_process, toggle_key_label, update_test_action_result,
|
shutdown_launcher_runtime, spawn_client_process, stop_child_process, toggle_key_label,
|
||||||
write_audio_gain_request, write_input_routing_request, write_input_toggle_key_request,
|
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::handshake::{HandshakeProbe, probe},
|
||||||
crate::output::display::enumerate_monitors,
|
crate::output::display::enumerate_monitors,
|
||||||
@ -212,6 +214,50 @@ fn apply_audio_gain_change(
|
|||||||
true
|
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<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))]
|
#[cfg(not(coverage))]
|
||||||
fn request_capture_power_refresh(
|
fn request_capture_power_refresh(
|
||||||
power_tx: std::sync::mpsc::Sender<PowerMessage>,
|
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 {
|
} else if preview_was_running {
|
||||||
widgets.status_label.set_text(&format!(
|
widgets.status_label.set_text(&format!(
|
||||||
"Local camera preview switched to {}.",
|
"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());
|
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 state = Rc::clone(&state);
|
||||||
let widgets = widgets.clone();
|
let widgets = widgets.clone();
|
||||||
@ -2058,10 +2179,19 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
PowerMessage::Refresh(Err(err)) => {
|
PowerMessage::Refresh(Err(err)) => {
|
||||||
|
let relay_live = child_proc.borrow().is_some()
|
||||||
|
|| state.borrow().remote_active;
|
||||||
{
|
{
|
||||||
let mut state = state.borrow_mut();
|
let mut state = state.borrow_mut();
|
||||||
state.set_server_available(false);
|
if relay_live {
|
||||||
state.set_capture_power(unavailable_capture_power(err));
|
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() {
|
if let Some(preview) = preview.as_ref() {
|
||||||
let preview_active = {
|
let preview_active = {
|
||||||
|
|||||||
@ -66,11 +66,21 @@ pub struct LauncherWidgets {
|
|||||||
pub display_panes: [DisplayPaneWidgets; 2],
|
pub display_panes: [DisplayPaneWidgets; 2],
|
||||||
pub server_entry: gtk::Entry,
|
pub server_entry: gtk::Entry,
|
||||||
pub start_button: gtk::Button,
|
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_auto_button: gtk::Button,
|
||||||
pub power_on_button: gtk::Button,
|
pub power_on_button: gtk::Button,
|
||||||
pub power_off_button: gtk::Button,
|
pub power_off_button: gtk::Button,
|
||||||
pub audio_gain_scale: gtk::Scale,
|
pub audio_gain_scale: gtk::Scale,
|
||||||
pub audio_gain_value: gtk::Label,
|
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 input_toggle_button: gtk::Button,
|
||||||
pub clipboard_button: gtk::Button,
|
pub clipboard_button: gtk::Button,
|
||||||
pub probe_button: gtk::Button,
|
pub probe_button: gtk::Button,
|
||||||
@ -229,11 +239,11 @@ pub fn build_launcher_view(
|
|||||||
control_group.append(&control_stack);
|
control_group.append(&control_stack);
|
||||||
|
|
||||||
let camera_combo = gtk::ComboBoxText::new();
|
let camera_combo = gtk::ComboBoxText::new();
|
||||||
camera_combo.append(Some("auto"), "auto");
|
sync_stage_device_combo(
|
||||||
for camera in &catalog.cameras {
|
&camera_combo,
|
||||||
append_stage_choice(&camera_combo, camera);
|
&catalog.cameras,
|
||||||
}
|
state.devices.camera.as_deref(),
|
||||||
super::ui_runtime::set_combo_active_text(&camera_combo, state.devices.camera.as_deref());
|
);
|
||||||
let camera_test_button = gtk::Button::with_label("Start Preview");
|
let camera_test_button = gtk::Button::with_label("Start Preview");
|
||||||
stabilize_button(&camera_test_button, 118);
|
stabilize_button(&camera_test_button, 118);
|
||||||
camera_test_button.set_tooltip_text(Some(
|
camera_test_button.set_tooltip_text(Some(
|
||||||
@ -241,11 +251,11 @@ pub fn build_launcher_view(
|
|||||||
));
|
));
|
||||||
|
|
||||||
let speaker_combo = gtk::ComboBoxText::new();
|
let speaker_combo = gtk::ComboBoxText::new();
|
||||||
speaker_combo.append(Some("auto"), "auto");
|
sync_stage_device_combo(
|
||||||
for speaker in &catalog.speakers {
|
&speaker_combo,
|
||||||
append_stage_choice(&speaker_combo, speaker);
|
&catalog.speakers,
|
||||||
}
|
state.devices.speaker.as_deref(),
|
||||||
super::ui_runtime::set_combo_active_text(&speaker_combo, state.devices.speaker.as_deref());
|
);
|
||||||
let speaker_test_button = gtk::Button::with_label("Play Tone");
|
let speaker_test_button = gtk::Button::with_label("Play Tone");
|
||||||
stabilize_button(&speaker_test_button, 118);
|
stabilize_button(&speaker_test_button, 118);
|
||||||
speaker_test_button.set_tooltip_text(Some(
|
speaker_test_button.set_tooltip_text(Some(
|
||||||
@ -294,12 +304,9 @@ pub fn build_launcher_view(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let microphone_combo = gtk::ComboBoxText::new();
|
let microphone_combo = gtk::ComboBoxText::new();
|
||||||
microphone_combo.append(Some("auto"), "auto");
|
sync_stage_device_combo(
|
||||||
for microphone in &catalog.microphones {
|
|
||||||
append_stage_choice(µphone_combo, microphone);
|
|
||||||
}
|
|
||||||
super::ui_runtime::set_combo_active_text(
|
|
||||||
µphone_combo,
|
µphone_combo,
|
||||||
|
&catalog.microphones,
|
||||||
state.devices.microphone.as_deref(),
|
state.devices.microphone.as_deref(),
|
||||||
);
|
);
|
||||||
let microphone_test_button = gtk::Button::with_label("Monitor Mic");
|
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);
|
live_actions_row.append(&usb_recover_button);
|
||||||
connection_body.append(&live_actions_row);
|
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));
|
connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));
|
||||||
let power_heading = gtk::Label::new(Some("GPIO Power"));
|
let power_heading = gtk::Label::new(Some("GPIO Power"));
|
||||||
power_heading.add_css_class("subgroup-title");
|
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_label);
|
||||||
audio_gain_row.append(&audio_gain_scale);
|
audio_gain_row.append(&audio_gain_scale);
|
||||||
audio_gain_row.append(&audio_gain_value);
|
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(&power_row);
|
||||||
power_shell.append(&audio_gain_row);
|
power_shell.append(&audio_gain_row);
|
||||||
|
power_shell.append(&mic_gain_row);
|
||||||
connection_body.append(&power_shell);
|
connection_body.append(&power_shell);
|
||||||
let routing_heading = gtk::Label::new(Some("Inputs"));
|
let routing_heading = gtk::Label::new(Some("Inputs"));
|
||||||
routing_heading.add_css_class("subgroup-title");
|
routing_heading.add_css_class("subgroup-title");
|
||||||
@ -762,11 +829,21 @@ pub fn build_launcher_view(
|
|||||||
display_panes: [left_pane.clone(), right_pane.clone()],
|
display_panes: [left_pane.clone(), right_pane.clone()],
|
||||||
server_entry: server_entry.clone(),
|
server_entry: server_entry.clone(),
|
||||||
start_button: start_button.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_auto_button: power_auto_button.clone(),
|
||||||
power_on_button: power_on_button.clone(),
|
power_on_button: power_on_button.clone(),
|
||||||
power_off_button: power_off_button.clone(),
|
power_off_button: power_off_button.clone(),
|
||||||
audio_gain_scale: audio_gain_scale.clone(),
|
audio_gain_scale: audio_gain_scale.clone(),
|
||||||
audio_gain_value: audio_gain_value.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(),
|
input_toggle_button: input_toggle_button.clone(),
|
||||||
clipboard_button: clipboard_button.clone(),
|
clipboard_button: clipboard_button.clone(),
|
||||||
probe_button: probe_button.clone(),
|
probe_button: probe_button.clone(),
|
||||||
@ -1124,11 +1201,10 @@ pub fn sync_stage_device_combo(
|
|||||||
selected: Option<&str>,
|
selected: Option<&str>,
|
||||||
) {
|
) {
|
||||||
combo.remove_all();
|
combo.remove_all();
|
||||||
combo.append(Some("auto"), "auto");
|
|
||||||
for value in values {
|
for value in values {
|
||||||
append_stage_choice(combo, value);
|
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(
|
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));
|
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 {
|
fn compact_stage_label(value: &str) -> String {
|
||||||
let trimmed = value.trim();
|
let trimmed = value.trim();
|
||||||
if trimmed.is_empty() {
|
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()
|
if let Some(short) = trimmed.rsplit('/').next()
|
||||||
&& short != trimmed
|
&& short != trimmed
|
||||||
{
|
{
|
||||||
return shorten_label(short);
|
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)
|
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 {
|
fn shorten_label(value: &str) -> String {
|
||||||
const MAX: usize = 44;
|
const MAX: usize = 44;
|
||||||
let compact = value.replace('_', " ");
|
let compact = value.replace('_', " ");
|
||||||
|
|||||||
@ -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 INPUT_STATE_ENV: &str = "LESAVKA_LAUNCHER_INPUT_STATE";
|
||||||
pub const TOGGLE_KEY_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_TOGGLE_KEY_CONTROL";
|
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 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_CONTROL_PATH: &str = "/tmp/lesavka-launcher-input.control";
|
||||||
pub const DEFAULT_INPUT_STATE_PATH: &str = "/tmp/lesavka-launcher-input.state";
|
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_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_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;
|
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);
|
.set_value(state.audio_gain_percent as f64);
|
||||||
}
|
}
|
||||||
widgets.audio_gain_value.set_text(&state.audio_gain_label());
|
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
|
widgets
|
||||||
.start_button
|
.start_button
|
||||||
.set_label(if relay_live { "Disconnect" } else { "Connect" });
|
.set_label(if relay_live { "Disconnect" } else { "Connect" });
|
||||||
@ -99,7 +122,30 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
|
|||||||
widgets
|
widgets
|
||||||
.usb_recover_button
|
.usb_recover_button
|
||||||
.set_sensitive(state.server_available);
|
.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 {
|
widgets.input_toggle_button.set_label(match state.routing {
|
||||||
InputRouting::Remote => "Route Local",
|
InputRouting::Remote => "Route Local",
|
||||||
InputRouting::Local => "Route Remote",
|
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))
|
.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<()> {
|
pub fn write_input_routing_request(path: &Path, routing: InputRouting) -> Result<()> {
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
path,
|
path,
|
||||||
@ -788,6 +840,12 @@ pub fn write_audio_gain_request(path: &Path, gain_percent: u32) -> Result<()> {
|
|||||||
Ok(())
|
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<()> {
|
pub fn write_input_toggle_key_request(path: &Path, swap_key: &str) -> Result<()> {
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
path,
|
path,
|
||||||
@ -956,6 +1014,9 @@ pub fn spawn_client_process(
|
|||||||
let audio_gain_path = audio_gain_control_path();
|
let audio_gain_path = audio_gain_control_path();
|
||||||
let _ = write_audio_gain_request(&audio_gain_path, state.audio_gain_percent);
|
let _ = write_audio_gain_request(&audio_gain_path, state.audio_gain_percent);
|
||||||
command.env(AUDIO_GAIN_CONTROL_ENV, audio_gain_path);
|
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) {
|
for (key, value) in runtime_env_vars(state) {
|
||||||
command.env(key, value);
|
command.env(key, value);
|
||||||
}
|
}
|
||||||
@ -1499,6 +1560,15 @@ mod tests {
|
|||||||
assert!(raw.starts_with("4.250 "), "{raw}");
|
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]
|
#[gtk::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn dock_all_displays_to_preview_closes_popouts_and_resets_surfaces() {
|
fn dock_all_displays_to_preview_closes_popouts_and_resets_surfaces() {
|
||||||
|
|||||||
@ -42,8 +42,8 @@
|
|||||||
},
|
},
|
||||||
"client/src/input/microphone.rs": {
|
"client/src/input/microphone.rs": {
|
||||||
"clippy_warnings": 17,
|
"clippy_warnings": 17,
|
||||||
"doc_debt": 7,
|
"doc_debt": 9,
|
||||||
"loc": 210
|
"loc": 268
|
||||||
},
|
},
|
||||||
"client/src/input/mod.rs": {
|
"client/src/input/mod.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -67,8 +67,8 @@
|
|||||||
},
|
},
|
||||||
"client/src/launcher/devices.rs": {
|
"client/src/launcher/devices.rs": {
|
||||||
"clippy_warnings": 6,
|
"clippy_warnings": 6,
|
||||||
"doc_debt": 11,
|
"doc_debt": 14,
|
||||||
"loc": 348
|
"loc": 400
|
||||||
},
|
},
|
||||||
"client/src/launcher/diagnostics.rs": {
|
"client/src/launcher/diagnostics.rs": {
|
||||||
"clippy_warnings": 92,
|
"clippy_warnings": 92,
|
||||||
@ -77,8 +77,8 @@
|
|||||||
},
|
},
|
||||||
"client/src/launcher/mod.rs": {
|
"client/src/launcher/mod.rs": {
|
||||||
"clippy_warnings": 8,
|
"clippy_warnings": 8,
|
||||||
"doc_debt": 7,
|
"doc_debt": 8,
|
||||||
"loc": 438
|
"loc": 480
|
||||||
},
|
},
|
||||||
"client/src/launcher/power.rs": {
|
"client/src/launcher/power.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -91,24 +91,24 @@
|
|||||||
"loc": 2216
|
"loc": 2216
|
||||||
},
|
},
|
||||||
"client/src/launcher/state.rs": {
|
"client/src/launcher/state.rs": {
|
||||||
"clippy_warnings": 154,
|
"clippy_warnings": 168,
|
||||||
"doc_debt": 54,
|
"doc_debt": 57,
|
||||||
"loc": 1377
|
"loc": 1478
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui.rs": {
|
"client/src/launcher/ui.rs": {
|
||||||
"clippy_warnings": 62,
|
"clippy_warnings": 68,
|
||||||
"doc_debt": 23,
|
"doc_debt": 23,
|
||||||
"loc": 2367
|
"loc": 2497
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui_components.rs": {
|
"client/src/launcher/ui_components.rs": {
|
||||||
"clippy_warnings": 16,
|
"clippy_warnings": 22,
|
||||||
"doc_debt": 15,
|
"doc_debt": 17,
|
||||||
"loc": 1372
|
"loc": 1497
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui_runtime.rs": {
|
"client/src/launcher/ui_runtime.rs": {
|
||||||
"clippy_warnings": 62,
|
"clippy_warnings": 70,
|
||||||
"doc_debt": 44,
|
"doc_debt": 44,
|
||||||
"loc": 1698
|
"loc": 1768
|
||||||
},
|
},
|
||||||
"client/src/layout.rs": {
|
"client/src/layout.rs": {
|
||||||
"clippy_warnings": 6,
|
"clippy_warnings": 6,
|
||||||
@ -273,7 +273,7 @@
|
|||||||
"server/src/video.rs": {
|
"server/src/video.rs": {
|
||||||
"clippy_warnings": 53,
|
"clippy_warnings": 53,
|
||||||
"doc_debt": 12,
|
"doc_debt": 12,
|
||||||
"loc": 840
|
"loc": 844
|
||||||
},
|
},
|
||||||
"server/src/video_sinks.rs": {
|
"server/src/video_sinks.rs": {
|
||||||
"clippy_warnings": 78,
|
"clippy_warnings": 78,
|
||||||
|
|||||||
@ -33,8 +33,8 @@
|
|||||||
"loc": 196
|
"loc": 196
|
||||||
},
|
},
|
||||||
"client/src/input/microphone.rs": {
|
"client/src/input/microphone.rs": {
|
||||||
"line_percent": 89.81,
|
"line_percent": 96.71,
|
||||||
"loc": 210
|
"loc": 268
|
||||||
},
|
},
|
||||||
"client/src/input/mouse.rs": {
|
"client/src/input/mouse.rs": {
|
||||||
"line_percent": 97.32,
|
"line_percent": 97.32,
|
||||||
@ -45,24 +45,24 @@
|
|||||||
"loc": 178
|
"loc": 178
|
||||||
},
|
},
|
||||||
"client/src/launcher/devices.rs": {
|
"client/src/launcher/devices.rs": {
|
||||||
"line_percent": 95.7,
|
"line_percent": 95.93,
|
||||||
"loc": 348
|
"loc": 400
|
||||||
},
|
},
|
||||||
"client/src/launcher/diagnostics.rs": {
|
"client/src/launcher/diagnostics.rs": {
|
||||||
"line_percent": 84.3,
|
"line_percent": 84.3,
|
||||||
"loc": 1021
|
"loc": 1021
|
||||||
},
|
},
|
||||||
"client/src/launcher/mod.rs": {
|
"client/src/launcher/mod.rs": {
|
||||||
"line_percent": 82.32,
|
"line_percent": 84.2,
|
||||||
"loc": 438
|
"loc": 480
|
||||||
},
|
},
|
||||||
"client/src/launcher/state.rs": {
|
"client/src/launcher/state.rs": {
|
||||||
"line_percent": 84.01,
|
"line_percent": 85.06,
|
||||||
"loc": 1377
|
"loc": 1478
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui.rs": {
|
"client/src/launcher/ui.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 2367
|
"loc": 2497
|
||||||
},
|
},
|
||||||
"client/src/layout.rs": {
|
"client/src/layout.rs": {
|
||||||
"line_percent": 97.73,
|
"line_percent": 97.73,
|
||||||
@ -169,8 +169,8 @@
|
|||||||
"loc": 241
|
"loc": 241
|
||||||
},
|
},
|
||||||
"server/src/video.rs": {
|
"server/src/video.rs": {
|
||||||
"line_percent": 96.55,
|
"line_percent": 96.6,
|
||||||
"loc": 840
|
"loc": 844
|
||||||
},
|
},
|
||||||
"server/src/video_sinks.rs": {
|
"server/src/video_sinks.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
|
|||||||
@ -333,23 +333,27 @@ pub async fn eye_ball_with_request(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _ = gst::init();
|
let _ = gst::init();
|
||||||
let pipeline = gst::Pipeline::new();
|
|
||||||
let (tx, rx) = tokio::sync::mpsc::channel(64);
|
let (tx, rx) = tokio::sync::mpsc::channel(64);
|
||||||
|
|
||||||
for seq in 0..8 {
|
tokio::spawn(async move {
|
||||||
let _ = tx.try_send(Ok(VideoPacket {
|
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
||||||
id: id.min(1),
|
for seq in 0..8 {
|
||||||
pts: seq * 16_666,
|
let _ = tx
|
||||||
data: vec![0, 0, 0, 1, 0x65, 0x88, 0x84],
|
.send(Ok(VideoPacket {
|
||||||
seq: seq + 1,
|
id: id.min(1),
|
||||||
effective_fps: 60,
|
pts: seq * 16_666,
|
||||||
server_encoder_label: "coverage-testsrc".to_string(),
|
data: vec![0, 0, 0, 1, 0x65, 0x88, 0x84],
|
||||||
..Default::default()
|
seq: seq + 1,
|
||||||
}));
|
effective_fps: 60,
|
||||||
}
|
server_encoder_label: "coverage-testsrc".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Ok(VideoStream {
|
Ok(VideoStream {
|
||||||
_pipeline: pipeline,
|
_pipeline: gst::Pipeline::new(),
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
_bus_watch: None,
|
_bus_watch: None,
|
||||||
inner: ReceiverStream::new(rx),
|
inner: ReceiverStream::new(rx),
|
||||||
|
|||||||
@ -120,14 +120,26 @@ fn relay_controls_keep_connect_inline_with_server_entry() {
|
|||||||
fn remote_audio_gain_control_stays_in_the_operations_rail() {
|
fn remote_audio_gain_control_stays_in_the_operations_rail() {
|
||||||
assert!(!UI_SRC.contains("Remote Audio"));
|
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 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("let power_heading = gtk::Label::new(Some(\"GPIO Power\"));"));
|
||||||
assert!(UI_SRC.contains("power_row.append(&power_heading);"));
|
assert!(UI_SRC.contains("power_row.append(&power_heading);"));
|
||||||
assert!(UI_SRC.contains("power_buttons.set_homogeneous(true);"));
|
assert!(UI_SRC.contains("power_buttons.set_homogeneous(true);"));
|
||||||
assert!(UI_SRC.contains("let audio_gain_scale ="));
|
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_scale.set_draw_value(false);"));
|
||||||
assert!(UI_SRC.contains("audio_gain_value.set_width_chars(5);"));
|
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("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(&audio_gain_row);"));
|
||||||
|
assert!(UI_SRC.contains("power_shell.append(&mic_gain_row);"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
UI_SRC
|
UI_SRC
|
||||||
.matches("connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));")
|
.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!(
|
assert!(
|
||||||
source_index("power_shell.append(&audio_gain_row);")
|
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\"));")
|
< source_index("let routing_heading = gtk::Label::new(Some(\"Inputs\"));")
|
||||||
);
|
);
|
||||||
assert!(UI_SRC.contains("routing_buttons.set_homogeneous(true);"));
|
assert!(UI_SRC.contains("routing_buttons.set_homogeneous(true);"));
|
||||||
|
|||||||
@ -30,6 +30,26 @@ fn relay_child_starts_safe_parent_watchdog_on_boot() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn relay_address_entry_is_locked_while_relay_is_live() {
|
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("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("\"Connect\""));
|
||||||
assert!(UI_RUNTIME_SRC.contains("\"Disconnect\""));
|
assert!(UI_RUNTIME_SRC.contains("\"Disconnect\""));
|
||||||
}
|
}
|
||||||
@ -37,8 +57,18 @@ fn relay_address_entry_is_locked_while_relay_is_live() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn audio_gain_slider_callback_never_panics_on_refresh_reentry() {
|
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_audio_gain_change("));
|
||||||
|
assert!(UI_SRC.contains("fn apply_mic_gain_change("));
|
||||||
assert!(UI_SRC.contains("state.try_borrow_mut()"));
|
assert!(UI_SRC.contains("state.try_borrow_mut()"));
|
||||||
assert!(UI_SRC.contains("return false;"));
|
assert!(UI_SRC.contains("return false;"));
|
||||||
assert!(UI_SRC.contains("glib::idle_add_local_once"));
|
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"));
|
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"));
|
||||||
|
}
|
||||||
|
|||||||
@ -24,9 +24,9 @@ mod microphone_include_contract {
|
|||||||
fs::set_permissions(path, perms).expect("chmod");
|
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");
|
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 prior = std::env::var("PATH").unwrap_or_default();
|
||||||
let merged = if prior.is_empty() {
|
let merged = if prior.is_empty() {
|
||||||
dir.path().display().to_string()
|
dir.path().display().to_string()
|
||||||
@ -36,6 +36,14 @@ mod microphone_include_contract {
|
|||||||
with_var("PATH", Some(merged), f);
|
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]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn pulse_source_by_substr_matches_expected_device_name() {
|
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]
|
#[test]
|
||||||
fn pull_returns_none_for_empty_appsink() {
|
fn pull_returns_none_for_empty_appsink() {
|
||||||
gst::init().ok();
|
gst::init().ok();
|
||||||
|
|||||||
@ -167,6 +167,10 @@ mod server_main_rpc {
|
|||||||
.expect("packet");
|
.expect("packet");
|
||||||
assert_eq!(packet.id, 0);
|
assert_eq!(packet.id, 0);
|
||||||
assert!(!packet.data.is_empty());
|
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
|
.await
|
||||||
.expect("right item")
|
.expect("right item")
|
||||||
.expect("right packet");
|
.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;
|
let hub_count = handler.eye_hub_count().await;
|
||||||
(left_packet, right_packet, hub_count)
|
(left_packet, right_packet, hub_count)
|
||||||
});
|
});
|
||||||
|
|||||||
@ -277,14 +277,33 @@ printf 'configured\n' > "$LESAVKA_GADGET_SYSFS_ROOT/class/udc/fake-ctrl.usb/stat
|
|||||||
data: vec![9, 8, 7],
|
data: vec![9, 8, 7],
|
||||||
..Default::default()
|
..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
|
hub.subscribers
|
||||||
.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
|
.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
|
||||||
let mut rx = hub.tx.subscribe();
|
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.id, packet.id);
|
||||||
assert_eq!(observed.pts, packet.pts);
|
assert_eq!(observed.pts, packet.pts);
|
||||||
assert_eq!(observed.data, packet.data);
|
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"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user