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