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;
|
||||
#[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);
|
||||
|
||||
@ -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()));
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -12,18 +12,20 @@ use {
|
||||
super::state::{
|
||||
BreakoutSizePreset, CapturePowerStatus, CaptureSizePreset, DisplaySurface,
|
||||
FeedSourcePreset, InputRouting, LauncherState, MAX_AUDIO_GAIN_PERCENT,
|
||||
MAX_MIC_GAIN_PERCENT,
|
||||
},
|
||||
super::ui_components::{build_launcher_view, sync_input_device_combo, sync_stage_device_combo},
|
||||
super::ui_runtime::{
|
||||
RelayChild, append_session_log, apply_popout_window_size, attach_child_log_streams,
|
||||
audio_gain_control_path, capture_swap_key, copy_plain_text, copy_session_log,
|
||||
dock_all_displays_to_preview, dock_display_to_preview, input_control_path,
|
||||
input_state_path, input_toggle_control_path, next_input_routing, open_diagnostics_popout,
|
||||
open_popout_window, open_session_log_popout, path_marker, present_popout_windows,
|
||||
read_input_routing_state, reap_exited_child, refresh_launcher_ui, refresh_test_buttons,
|
||||
routing_name, selected_combo_value, selected_server_addr, shutdown_launcher_runtime,
|
||||
spawn_client_process, stop_child_process, toggle_key_label, update_test_action_result,
|
||||
write_audio_gain_request, write_input_routing_request, write_input_toggle_key_request,
|
||||
input_state_path, input_toggle_control_path, mic_gain_control_path, next_input_routing,
|
||||
open_diagnostics_popout, open_popout_window, open_session_log_popout, path_marker,
|
||||
present_popout_windows, read_input_routing_state, reap_exited_child, refresh_launcher_ui,
|
||||
refresh_test_buttons, routing_name, selected_combo_value, selected_server_addr,
|
||||
shutdown_launcher_runtime, spawn_client_process, stop_child_process, toggle_key_label,
|
||||
update_test_action_result, write_audio_gain_request, write_input_routing_request,
|
||||
write_input_toggle_key_request, write_mic_gain_request,
|
||||
},
|
||||
crate::handshake::{HandshakeProbe, probe},
|
||||
crate::output::display::enumerate_monitors,
|
||||
@ -212,6 +214,50 @@ fn apply_audio_gain_change(
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
/// Apply a microphone uplink gain slider update without unwinding through GTK callbacks.
|
||||
fn apply_mic_gain_change(
|
||||
scale: >k::Scale,
|
||||
state: &Rc<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 = {
|
||||
|
||||
@ -66,11 +66,21 @@ pub struct LauncherWidgets {
|
||||
pub display_panes: [DisplayPaneWidgets; 2],
|
||||
pub server_entry: gtk::Entry,
|
||||
pub start_button: gtk::Button,
|
||||
pub camera_combo: gtk::ComboBoxText,
|
||||
pub microphone_combo: gtk::ComboBoxText,
|
||||
pub speaker_combo: gtk::ComboBoxText,
|
||||
pub keyboard_combo: gtk::ComboBoxText,
|
||||
pub mouse_combo: gtk::ComboBoxText,
|
||||
pub camera_channel_toggle: gtk::CheckButton,
|
||||
pub microphone_channel_toggle: gtk::CheckButton,
|
||||
pub audio_channel_toggle: gtk::CheckButton,
|
||||
pub power_auto_button: gtk::Button,
|
||||
pub power_on_button: gtk::Button,
|
||||
pub power_off_button: gtk::Button,
|
||||
pub audio_gain_scale: gtk::Scale,
|
||||
pub audio_gain_value: gtk::Label,
|
||||
pub mic_gain_scale: gtk::Scale,
|
||||
pub mic_gain_value: gtk::Label,
|
||||
pub input_toggle_button: gtk::Button,
|
||||
pub clipboard_button: gtk::Button,
|
||||
pub probe_button: gtk::Button,
|
||||
@ -229,11 +239,11 @@ pub fn build_launcher_view(
|
||||
control_group.append(&control_stack);
|
||||
|
||||
let camera_combo = gtk::ComboBoxText::new();
|
||||
camera_combo.append(Some("auto"), "auto");
|
||||
for camera in &catalog.cameras {
|
||||
append_stage_choice(&camera_combo, camera);
|
||||
}
|
||||
super::ui_runtime::set_combo_active_text(&camera_combo, state.devices.camera.as_deref());
|
||||
sync_stage_device_combo(
|
||||
&camera_combo,
|
||||
&catalog.cameras,
|
||||
state.devices.camera.as_deref(),
|
||||
);
|
||||
let camera_test_button = gtk::Button::with_label("Start Preview");
|
||||
stabilize_button(&camera_test_button, 118);
|
||||
camera_test_button.set_tooltip_text(Some(
|
||||
@ -241,11 +251,11 @@ pub fn build_launcher_view(
|
||||
));
|
||||
|
||||
let speaker_combo = gtk::ComboBoxText::new();
|
||||
speaker_combo.append(Some("auto"), "auto");
|
||||
for speaker in &catalog.speakers {
|
||||
append_stage_choice(&speaker_combo, speaker);
|
||||
}
|
||||
super::ui_runtime::set_combo_active_text(&speaker_combo, state.devices.speaker.as_deref());
|
||||
sync_stage_device_combo(
|
||||
&speaker_combo,
|
||||
&catalog.speakers,
|
||||
state.devices.speaker.as_deref(),
|
||||
);
|
||||
let speaker_test_button = gtk::Button::with_label("Play Tone");
|
||||
stabilize_button(&speaker_test_button, 118);
|
||||
speaker_test_button.set_tooltip_text(Some(
|
||||
@ -294,12 +304,9 @@ pub fn build_launcher_view(
|
||||
);
|
||||
|
||||
let microphone_combo = gtk::ComboBoxText::new();
|
||||
microphone_combo.append(Some("auto"), "auto");
|
||||
for microphone in &catalog.microphones {
|
||||
append_stage_choice(µphone_combo, microphone);
|
||||
}
|
||||
super::ui_runtime::set_combo_active_text(
|
||||
sync_stage_device_combo(
|
||||
µphone_combo,
|
||||
&catalog.microphones,
|
||||
state.devices.microphone.as_deref(),
|
||||
);
|
||||
let microphone_test_button = gtk::Button::with_label("Monitor Mic");
|
||||
@ -457,6 +464,37 @@ pub fn build_launcher_view(
|
||||
live_actions_row.append(&usb_recover_button);
|
||||
connection_body.append(&live_actions_row);
|
||||
|
||||
let channel_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
channel_row.set_hexpand(true);
|
||||
let channel_heading = gtk::Label::new(Some("Streams"));
|
||||
channel_heading.add_css_class("subgroup-title");
|
||||
channel_heading.set_halign(gtk::Align::Start);
|
||||
channel_heading.set_width_chars(10);
|
||||
channel_row.append(&channel_heading);
|
||||
let channel_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
channel_buttons.set_hexpand(true);
|
||||
channel_buttons.set_homogeneous(true);
|
||||
let camera_channel_toggle = gtk::CheckButton::with_label("Webcam");
|
||||
camera_channel_toggle.set_active(state.channels.camera);
|
||||
camera_channel_toggle.set_tooltip_text(Some(
|
||||
"Include the local webcam uplink in the next relay session.",
|
||||
));
|
||||
let microphone_channel_toggle = gtk::CheckButton::with_label("Mic");
|
||||
microphone_channel_toggle.set_active(state.channels.microphone);
|
||||
microphone_channel_toggle.set_tooltip_text(Some(
|
||||
"Include the local microphone uplink in the next relay session.",
|
||||
));
|
||||
let audio_channel_toggle = gtk::CheckButton::with_label("Audio");
|
||||
audio_channel_toggle.set_active(state.channels.audio);
|
||||
audio_channel_toggle.set_tooltip_text(Some(
|
||||
"Play remote audio on this client during the next relay session.",
|
||||
));
|
||||
channel_buttons.append(&camera_channel_toggle);
|
||||
channel_buttons.append(µphone_channel_toggle);
|
||||
channel_buttons.append(&audio_channel_toggle);
|
||||
channel_row.append(&channel_buttons);
|
||||
connection_body.append(&channel_row);
|
||||
|
||||
connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));
|
||||
let power_heading = gtk::Label::new(Some("GPIO Power"));
|
||||
power_heading.add_css_class("subgroup-title");
|
||||
@ -520,8 +558,37 @@ pub fn build_launcher_view(
|
||||
audio_gain_row.append(&audio_gain_label);
|
||||
audio_gain_row.append(&audio_gain_scale);
|
||||
audio_gain_row.append(&audio_gain_value);
|
||||
let mic_gain_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
mic_gain_row.set_size_request(220, -1);
|
||||
mic_gain_row.set_hexpand(true);
|
||||
let mic_gain_label = gtk::Label::new(Some("Mic Gain"));
|
||||
mic_gain_label.add_css_class("dim-label");
|
||||
mic_gain_label.set_halign(gtk::Align::Start);
|
||||
mic_gain_label.set_width_chars(10);
|
||||
let mic_gain_adjustment = gtk::Adjustment::new(
|
||||
state.mic_gain_percent as f64,
|
||||
0.0,
|
||||
super::state::MAX_MIC_GAIN_PERCENT as f64,
|
||||
25.0,
|
||||
100.0,
|
||||
0.0,
|
||||
);
|
||||
let mic_gain_scale = gtk::Scale::new(gtk::Orientation::Horizontal, Some(&mic_gain_adjustment));
|
||||
mic_gain_scale.set_draw_value(false);
|
||||
mic_gain_scale.set_hexpand(true);
|
||||
mic_gain_scale.set_tooltip_text(Some(
|
||||
"Boost or lower local microphone uplink gain. Changes apply live while microphone uplink is running.",
|
||||
));
|
||||
let mic_gain_value = gtk::Label::new(Some(&state.mic_gain_label()));
|
||||
mic_gain_value.add_css_class("dim-label");
|
||||
mic_gain_value.set_width_chars(5);
|
||||
mic_gain_value.set_xalign(1.0);
|
||||
mic_gain_row.append(&mic_gain_label);
|
||||
mic_gain_row.append(&mic_gain_scale);
|
||||
mic_gain_row.append(&mic_gain_value);
|
||||
power_shell.append(&power_row);
|
||||
power_shell.append(&audio_gain_row);
|
||||
power_shell.append(&mic_gain_row);
|
||||
connection_body.append(&power_shell);
|
||||
let routing_heading = gtk::Label::new(Some("Inputs"));
|
||||
routing_heading.add_css_class("subgroup-title");
|
||||
@ -762,11 +829,21 @@ pub fn build_launcher_view(
|
||||
display_panes: [left_pane.clone(), right_pane.clone()],
|
||||
server_entry: server_entry.clone(),
|
||||
start_button: start_button.clone(),
|
||||
camera_combo: camera_combo.clone(),
|
||||
microphone_combo: microphone_combo.clone(),
|
||||
speaker_combo: speaker_combo.clone(),
|
||||
keyboard_combo: keyboard_combo.clone(),
|
||||
mouse_combo: mouse_combo.clone(),
|
||||
camera_channel_toggle: camera_channel_toggle.clone(),
|
||||
microphone_channel_toggle: microphone_channel_toggle.clone(),
|
||||
audio_channel_toggle: audio_channel_toggle.clone(),
|
||||
power_auto_button: power_auto_button.clone(),
|
||||
power_on_button: power_on_button.clone(),
|
||||
power_off_button: power_off_button.clone(),
|
||||
audio_gain_scale: audio_gain_scale.clone(),
|
||||
audio_gain_value: audio_gain_value.clone(),
|
||||
mic_gain_scale: mic_gain_scale.clone(),
|
||||
mic_gain_value: mic_gain_value.clone(),
|
||||
input_toggle_button: input_toggle_button.clone(),
|
||||
clipboard_button: clipboard_button.clone(),
|
||||
probe_button: probe_button.clone(),
|
||||
@ -1124,11 +1201,10 @@ pub fn sync_stage_device_combo(
|
||||
selected: Option<&str>,
|
||||
) {
|
||||
combo.remove_all();
|
||||
combo.append(Some("auto"), "auto");
|
||||
for value in values {
|
||||
append_stage_choice(combo, value);
|
||||
}
|
||||
super::ui_runtime::set_combo_active_text(combo, selected);
|
||||
set_stage_combo_active_text(combo, selected);
|
||||
}
|
||||
|
||||
pub fn sync_input_device_combo(
|
||||
@ -1202,25 +1278,74 @@ fn append_stage_choice(combo: >k::ComboBoxText, value: &str) {
|
||||
combo.append(Some(value), &compact_stage_label(value));
|
||||
}
|
||||
|
||||
fn set_stage_combo_active_text(combo: >k::ComboBoxText, selected: Option<&str>) {
|
||||
if selected
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.is_some_and(|value| combo.set_active_id(Some(value)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
combo.set_active(Some(0));
|
||||
}
|
||||
|
||||
fn compact_stage_label(value: &str) -> String {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return "auto".to_string();
|
||||
return "No device".to_string();
|
||||
}
|
||||
let camera = trimmed
|
||||
.strip_prefix("usb-")
|
||||
.unwrap_or(trimmed)
|
||||
.split("-video-index")
|
||||
.next()
|
||||
.unwrap_or(trimmed);
|
||||
if camera != trimmed {
|
||||
return shorten_label(camera);
|
||||
}
|
||||
if let Some(rest) = trimmed
|
||||
.strip_prefix("alsa_input.")
|
||||
.or_else(|| trimmed.strip_prefix("alsa_output."))
|
||||
{
|
||||
return shorten_label(&human_audio_node_label(rest));
|
||||
}
|
||||
if let Some(rest) = trimmed
|
||||
.strip_prefix("bluez_input.")
|
||||
.or_else(|| trimmed.strip_prefix("bluez_output."))
|
||||
{
|
||||
return shorten_label(&format!(
|
||||
"Bluetooth {}",
|
||||
rest.split('.').next().unwrap_or(rest).replace('_', ":")
|
||||
));
|
||||
}
|
||||
if let Some(short) = trimmed.rsplit('/').next()
|
||||
&& short != trimmed
|
||||
{
|
||||
return shorten_label(short);
|
||||
}
|
||||
if let Some(rest) = trimmed
|
||||
.strip_prefix("alsa_input.")
|
||||
.or_else(|| trimmed.strip_prefix("alsa_output."))
|
||||
{
|
||||
return shorten_label(rest);
|
||||
}
|
||||
shorten_label(trimmed)
|
||||
}
|
||||
|
||||
fn human_audio_node_label(value: &str) -> String {
|
||||
let compact = value
|
||||
.trim()
|
||||
.strip_prefix("usb-")
|
||||
.unwrap_or(value.trim())
|
||||
.replace(".analog-stereo", " analog stereo")
|
||||
.replace(".mono-fallback", " mono")
|
||||
.replace(".stereo-fallback", " stereo")
|
||||
.replace('-', " ")
|
||||
.replace('_', " ");
|
||||
if compact.starts_with("pci ") || compact.starts_with("pci-") {
|
||||
if compact.contains("analog stereo") {
|
||||
"Built-in analog stereo".to_string()
|
||||
} else {
|
||||
"Built-in audio".to_string()
|
||||
}
|
||||
} else {
|
||||
compact
|
||||
}
|
||||
}
|
||||
|
||||
fn shorten_label(value: &str) -> String {
|
||||
const MAX: usize = 44;
|
||||
let compact = value.replace('_', " ");
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -120,14 +120,26 @@ fn relay_controls_keep_connect_inline_with_server_entry() {
|
||||
fn remote_audio_gain_control_stays_in_the_operations_rail() {
|
||||
assert!(!UI_SRC.contains("Remote Audio"));
|
||||
assert!(UI_SRC.contains("let power_shell = gtk::Box::new(gtk::Orientation::Vertical, 6);"));
|
||||
assert!(UI_SRC.contains("let channel_heading = gtk::Label::new(Some(\"Streams\"));"));
|
||||
assert!(UI_SRC.contains("gtk::CheckButton::with_label(\"Webcam\")"));
|
||||
assert!(UI_SRC.contains("gtk::CheckButton::with_label(\"Mic\")"));
|
||||
assert!(UI_SRC.contains("gtk::CheckButton::with_label(\"Audio\")"));
|
||||
assert!(!UI_SRC.contains("camera_combo.append(Some(\"auto\")"));
|
||||
assert!(!UI_SRC.contains("speaker_combo.append(Some(\"auto\")"));
|
||||
assert!(!UI_SRC.contains("microphone_combo.append(Some(\"auto\")"));
|
||||
assert!(UI_SRC.contains("let power_heading = gtk::Label::new(Some(\"GPIO Power\"));"));
|
||||
assert!(UI_SRC.contains("power_row.append(&power_heading);"));
|
||||
assert!(UI_SRC.contains("power_buttons.set_homogeneous(true);"));
|
||||
assert!(UI_SRC.contains("let audio_gain_scale ="));
|
||||
assert!(UI_SRC.contains("audio_gain_scale.set_draw_value(false);"));
|
||||
assert!(UI_SRC.contains("audio_gain_value.set_width_chars(5);"));
|
||||
assert!(UI_SRC.contains("let mic_gain_scale ="));
|
||||
assert!(UI_SRC.contains("mic_gain_scale.set_draw_value(false);"));
|
||||
assert!(UI_SRC.contains("mic_gain_value.set_width_chars(5);"));
|
||||
assert!(UI_SRC.contains("audio_gain_row.set_size_request(220, -1);"));
|
||||
assert!(UI_SRC.contains("mic_gain_row.set_size_request(220, -1);"));
|
||||
assert!(UI_SRC.contains("power_shell.append(&audio_gain_row);"));
|
||||
assert!(UI_SRC.contains("power_shell.append(&mic_gain_row);"));
|
||||
assert_eq!(
|
||||
UI_SRC
|
||||
.matches("connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));")
|
||||
@ -141,6 +153,10 @@ fn remote_audio_gain_control_stays_in_the_operations_rail() {
|
||||
);
|
||||
assert!(
|
||||
source_index("power_shell.append(&audio_gain_row);")
|
||||
< source_index("power_shell.append(&mic_gain_row);")
|
||||
);
|
||||
assert!(
|
||||
source_index("power_shell.append(&mic_gain_row);")
|
||||
< source_index("let routing_heading = gtk::Label::new(Some(\"Inputs\"));")
|
||||
);
|
||||
assert!(UI_SRC.contains("routing_buttons.set_homogeneous(true);"));
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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)
|
||||
});
|
||||
|
||||
@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user