fix(audio): add remote gain control
This commit is contained in:
parent
14613a319e
commit
a2e9496071
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.11.43"
|
||||
version = "0.11.44"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -355,6 +355,10 @@ impl InputAggregator {
|
||||
let quick_toggle_now = self.quick_toggle_active();
|
||||
self.observe_quick_toggle(quick_toggle_now);
|
||||
|
||||
if self.remote_failsafe_expired() {
|
||||
self.begin_local_release();
|
||||
}
|
||||
|
||||
if self.pending_release || self.pending_kill {
|
||||
let chord_released = if self.pending_keys.is_empty() {
|
||||
!self
|
||||
@ -368,18 +372,9 @@ impl InputAggregator {
|
||||
};
|
||||
|
||||
if chord_released {
|
||||
for k in &mut self.keyboards {
|
||||
k.set_grab(false);
|
||||
k.reset_state();
|
||||
}
|
||||
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 {
|
||||
let pending_kill = self.pending_kill;
|
||||
self.finish_local_release(!pending_kill);
|
||||
if pending_kill {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
@ -140,6 +140,7 @@ pub struct SnapshotReport {
|
||||
pub selected_camera: Option<String>,
|
||||
pub selected_microphone: Option<String>,
|
||||
pub selected_speaker: Option<String>,
|
||||
pub audio_gain_label: String,
|
||||
pub selected_keyboard: Option<String>,
|
||||
pub selected_mouse: Option<String>,
|
||||
pub status: String,
|
||||
@ -354,6 +355,7 @@ impl SnapshotReport {
|
||||
selected_camera: state.devices.camera.clone(),
|
||||
selected_microphone: state.devices.microphone.clone(),
|
||||
selected_speaker: state.devices.speaker.clone(),
|
||||
audio_gain_label: state.audio_gain_label(),
|
||||
selected_keyboard: state.devices.keyboard.clone(),
|
||||
selected_mouse: state.devices.mouse.clone(),
|
||||
status: state.status_line(),
|
||||
@ -465,6 +467,7 @@ impl SnapshotReport {
|
||||
" speaker: {}",
|
||||
self.selected_speaker.as_deref().unwrap_or("auto")
|
||||
);
|
||||
let _ = writeln!(text, " audio gain: {}", self.audio_gain_label);
|
||||
let _ = writeln!(
|
||||
text,
|
||||
" keyboard: {}",
|
||||
@ -875,6 +878,7 @@ mod tests {
|
||||
Some("alsa_input.usb")
|
||||
);
|
||||
assert_eq!(report.selected_speaker.as_deref(), Some("alsa_output.usb"));
|
||||
assert_eq!(report.audio_gain_label, "200%");
|
||||
assert_eq!(
|
||||
report.selected_keyboard.as_deref(),
|
||||
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())
|
||||
.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) {
|
||||
envs.insert("LESAVKA_DISABLE_VIDEO_RENDER".to_string(), "1".to_string());
|
||||
}
|
||||
@ -174,6 +178,7 @@ mod tests {
|
||||
envs.get("LESAVKA_CLIPBOARD_DELAY_MS"),
|
||||
Some(&"18".to_string())
|
||||
);
|
||||
assert_eq!(envs.get("LESAVKA_AUDIO_GAIN"), Some(&"2.000".to_string()));
|
||||
assert_eq!(
|
||||
envs.get(REMOTE_INPUT_FAILSAFE_SECONDS_ENV),
|
||||
Some(&DEFAULT_REMOTE_INPUT_FAILSAFE_SECONDS.to_string())
|
||||
@ -273,6 +278,15 @@ mod tests {
|
||||
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]
|
||||
fn runtime_env_vars_disable_uplink_media_when_unstaged() {
|
||||
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,
|
||||
};
|
||||
|
||||
pub const DEFAULT_AUDIO_GAIN_PERCENT: u32 = 200;
|
||||
pub const MAX_AUDIO_GAIN_PERCENT: u32 = 800;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum InputRouting {
|
||||
Local,
|
||||
@ -314,6 +317,7 @@ pub struct LauncherState {
|
||||
pub capture_bitrates_kbit: [u32; 2],
|
||||
pub breakout_sizes: [BreakoutSizePreset; 2],
|
||||
pub devices: DeviceSelection,
|
||||
pub audio_gain_percent: u32,
|
||||
pub swap_key: String,
|
||||
pub swap_key_binding: bool,
|
||||
pub swap_key_binding_token: u64,
|
||||
@ -339,6 +343,7 @@ impl Default for LauncherState {
|
||||
capture_bitrates_kbit: [18_000, 18_000],
|
||||
breakout_sizes: [BreakoutSizePreset::Source, BreakoutSizePreset::Source],
|
||||
devices: DeviceSelection::default(),
|
||||
audio_gain_percent: DEFAULT_AUDIO_GAIN_PERCENT,
|
||||
swap_key: "pause".to_string(),
|
||||
swap_key_binding: false,
|
||||
swap_key_binding_token: 0,
|
||||
@ -638,6 +643,22 @@ impl LauncherState {
|
||||
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>) {
|
||||
self.devices.keyboard = normalize_selection(keyboard);
|
||||
}
|
||||
@ -704,7 +725,7 @@ impl LauncherState {
|
||||
|
||||
pub fn status_line(&self) -> String {
|
||||
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,
|
||||
match self.routing {
|
||||
InputRouting::Local => "local",
|
||||
@ -729,6 +750,7 @@ impl LauncherState {
|
||||
self.devices.camera.as_deref().unwrap_or("auto"),
|
||||
self.devices.microphone.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.mouse.as_deref().unwrap_or("all"),
|
||||
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(
|
||||
physical_limit: PreviewSourceSize,
|
||||
display_fill: PreviewSourceSize,
|
||||
@ -981,6 +1011,9 @@ mod tests {
|
||||
assert!(state.devices.speaker.is_none());
|
||||
assert!(state.devices.keyboard.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.mode, "auto");
|
||||
}
|
||||
@ -1080,6 +1113,20 @@ mod tests {
|
||||
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]
|
||||
fn start_and_stop_remote_only_report_changes_once() {
|
||||
let mut state = LauncherState::new();
|
||||
@ -1117,6 +1164,7 @@ mod tests {
|
||||
assert!(status.contains("camera=/dev/video0"));
|
||||
assert!(status.contains("mic=alsa_input.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("mouse=/dev/input/event-mouse"));
|
||||
}
|
||||
|
||||
@ -11,19 +11,19 @@ use {
|
||||
super::power::{fetch_capture_power, reset_usb_gadget, set_capture_power_mode},
|
||||
super::state::{
|
||||
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_runtime::{
|
||||
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,
|
||||
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_input_routing_request,
|
||||
write_input_toggle_key_request,
|
||||
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,
|
||||
},
|
||||
crate::handshake::{HandshakeProbe, probe},
|
||||
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 widgets = widgets.clone();
|
||||
|
||||
@ -68,6 +68,8 @@ pub struct LauncherWidgets {
|
||||
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 input_toggle_button: gtk::Button,
|
||||
pub clipboard_button: gtk::Button,
|
||||
pub probe_button: gtk::Button,
|
||||
@ -481,6 +483,39 @@ pub fn build_launcher_view(
|
||||
power_row.append(&power_off_button);
|
||||
power_shell.append(&power_row);
|
||||
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"));
|
||||
routing_heading.add_css_class("subgroup-title");
|
||||
routing_heading.set_halign(gtk::Align::Start);
|
||||
@ -713,6 +748,8 @@ pub fn build_launcher_view(
|
||||
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(),
|
||||
input_toggle_button: input_toggle_button.clone(),
|
||||
clipboard_button: clipboard_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_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 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 type RelayChild = Child;
|
||||
|
||||
@ -76,6 +78,12 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
|
||||
widgets
|
||||
.power_detail
|
||||
.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 {
|
||||
"Disconnect Relay"
|
||||
} else {
|
||||
@ -761,6 +769,12 @@ pub fn input_toggle_control_path() -> PathBuf {
|
||||
.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<()> {
|
||||
std::fs::write(
|
||||
path,
|
||||
@ -769,6 +783,12 @@ pub fn write_input_routing_request(path: &Path, routing: InputRouting) -> Result
|
||||
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<()> {
|
||||
std::fs::write(
|
||||
path,
|
||||
@ -927,6 +947,9 @@ pub fn spawn_client_process(
|
||||
command.env(TOGGLE_KEY_CONTROL_ENV, input_toggle_control_path);
|
||||
command.env("LESAVKA_DISABLE_VIDEO_RENDER", "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) {
|
||||
command.env(key, value);
|
||||
}
|
||||
@ -1461,6 +1484,15 @@ mod tests {
|
||||
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]
|
||||
#[serial]
|
||||
fn dock_all_displays_to_preview_closes_popouts_and_resets_surfaces() {
|
||||
|
||||
@ -5,11 +5,22 @@ use gst::MessageView::*;
|
||||
use gst::prelude::*;
|
||||
use gstreamer as gst;
|
||||
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 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 {
|
||||
pipeline: gst::Pipeline,
|
||||
src: gst_app::AppSrc,
|
||||
@ -29,31 +40,10 @@ impl AudioOut {
|
||||
let tee_dump = std::env::var("LESAVKA_TAP_AUDIO")
|
||||
.ok()
|
||||
.as_deref()
|
||||
.map(|v| v == "1")
|
||||
.unwrap_or(false);
|
||||
let mut pipe = 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 ! \
|
||||
level name=remote_audio_level interval=1000000000 message=true ! \
|
||||
queue max-size-time=400000000 max-size-bytes=0 max-size-buffers=0 ! {}",
|
||||
sink,
|
||||
);
|
||||
.is_some_and(|v| v == "1");
|
||||
let gain = audio_gain_from_env();
|
||||
let pipe = audio_output_pipeline_desc(&sink, gain, 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)");
|
||||
}
|
||||
let pipeline: gst::Pipeline = gst::parse::launch(&pipe)?
|
||||
@ -65,16 +55,20 @@ impl AudioOut {
|
||||
.expect("no src element")
|
||||
.downcast::<gst_app::AppSrc>()
|
||||
.expect("src not an AppSrc");
|
||||
let volume = pipeline
|
||||
.by_name("remote_audio_gain")
|
||||
.expect("remote_audio_gain");
|
||||
|
||||
src.set_caps(Some(
|
||||
&gst::Caps::builder("audio/mpeg")
|
||||
.field("mpegversion", &4i32) // AAC
|
||||
.field("stream-format", &"adts") // ADTS frames
|
||||
.field("rate", &48_000i32) // 48 kHz
|
||||
.field("channels", &2i32) // stereo
|
||||
.field("mpegversion", 4i32) // AAC
|
||||
.field("stream-format", "adts") // ADTS frames
|
||||
.field("rate", 48_000i32) // 48 kHz
|
||||
.field("channels", 2i32) // stereo
|
||||
.build(),
|
||||
));
|
||||
src.set_format(gst::Format::Time);
|
||||
maybe_spawn_audio_gain_control(volume);
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
{
|
||||
@ -84,13 +78,13 @@ impl AudioOut {
|
||||
match msg.view() {
|
||||
Error(e) => error!(
|
||||
"💥 gst error from {:?}: {} ({})",
|
||||
msg.src().map(|s| s.path_string()),
|
||||
msg.src().map(gst::prelude::GstObjectExt::path_string),
|
||||
e.error(),
|
||||
e.debug().unwrap_or_default()
|
||||
),
|
||||
Warning(w) => warn!(
|
||||
"⚠️ gst warning from {:?}: {} ({})",
|
||||
msg.src().map(|s| s.path_string()),
|
||||
msg.src().map(gst::prelude::GstObjectExt::path_string),
|
||||
w.error(),
|
||||
w.debug().unwrap_or_default()
|
||||
),
|
||||
@ -104,12 +98,14 @@ impl AudioOut {
|
||||
}
|
||||
}
|
||||
StateChanged(s) if s.current() == gst::State::Playing => {
|
||||
if msg.src().map(|s| s.is::<gst::Pipeline>()).unwrap_or(false) {
|
||||
info!("🔊 audio pipeline ▶️ (sink='{}')", sink);
|
||||
if msg.src().is_some_and(|s| s.is::<gst::Pipeline>()) {
|
||||
info!("🔊 audio pipeline ▶️ (sink='{sink}' gain={gain:.2}x)");
|
||||
} else {
|
||||
debug!(
|
||||
"🔊 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 {
|
||||
let buf = gst::Buffer::from_slice(pkt.data);
|
||||
if let Ok(mut timeline) = timeline.lock() {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.11.43"
|
||||
version = "0.11.44"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.11.43"
|
||||
version = "0.11.44"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -614,10 +614,32 @@ mod tests {
|
||||
|
||||
#[cfg(all(test, not(coverage)))]
|
||||
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 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]
|
||||
fn remote_usb_audio_reports_not_attached_gadget() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
|
||||
@ -363,7 +363,6 @@ fn audio_init_retry_policy() -> (u32, u64) {
|
||||
(attempts, delay_ms)
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
fn preferred_uac_device_candidates(preferred: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
@ -386,7 +385,6 @@ fn preferred_uac_device_candidates(preferred: &str) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
fn push_audio_candidate_family(
|
||||
out: &mut Vec<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) {
|
||||
let trimmed = candidate.trim();
|
||||
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> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
@ -438,7 +434,6 @@ fn detect_uac_card_candidates() -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
fn parse_uac_named_card_candidates(cards: &str) -> Vec<String> {
|
||||
cards
|
||||
.lines()
|
||||
@ -459,7 +454,6 @@ fn parse_uac_named_card_candidates(cards: &str) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
fn parse_uac_numeric_card_ids(cards: &str) -> BTreeSet<String> {
|
||||
cards
|
||||
.lines()
|
||||
@ -480,7 +474,6 @@ fn parse_uac_numeric_card_ids(cards: &str) -> BTreeSet<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
fn parse_uac_pcm_candidates(pcm: &str, numeric_card_ids: &BTreeSet<String>) -> Vec<String> {
|
||||
pcm.lines()
|
||||
.filter_map(|line| {
|
||||
|
||||
@ -25,6 +25,7 @@ gstreamer-video = { version = "0.23", features = ["v1_22"] }
|
||||
gtk = { version = "0.8", package = "gtk4", features = ["v4_6"] }
|
||||
winit = "0.30"
|
||||
serial_test = { workspace = true }
|
||||
serde_json = "1.0"
|
||||
shell-escape = "0.1"
|
||||
temp-env = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@ -39,7 +39,7 @@ mod camera_include_contract {
|
||||
),
|
||||
"unexpected encoder: {enc}"
|
||||
);
|
||||
let (enc, key_prop, key_val) = CameraCapture::choose_encoder();
|
||||
let (enc, key_prop) = CameraCapture::choose_encoder();
|
||||
assert!(
|
||||
matches!(
|
||||
enc,
|
||||
@ -47,8 +47,9 @@ mod camera_include_contract {
|
||||
),
|
||||
"unexpected encoder: {enc}"
|
||||
);
|
||||
if let Some(key_prop) = key_prop {
|
||||
assert!(!key_prop.is_empty());
|
||||
assert!(!key_val.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -82,3 +82,12 @@ fn operations_column_fills_height_and_splits_extra_space_between_logs() {
|
||||
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]
|
||||
#[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
|
||||
if [ "$1" = "list" ] && [ "$2" = "short" ] && [ "$3" = "sources" ]; then
|
||||
echo "0 alsa_input.pci.monitor module-alsa-card.c s16le 2ch 48000Hz RUNNING"
|
||||
@ -66,20 +66,25 @@ fi
|
||||
exit 0
|
||||
"#;
|
||||
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!(
|
||||
arg.contains("device=alsa_input.usb-DeskMic_5678-00.analog-stereo"),
|
||||
"expected escaped non-monitor source argument"
|
||||
desc.contains("pulsesrc device=alsa_input.usb-DeskMic_5678-00.analog-stereo"),
|
||||
"expected escaped non-monitor source argument: {desc}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[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"), || {
|
||||
let arg = MicrophoneCapture::default_source_arg();
|
||||
assert!(arg.is_empty());
|
||||
assert!(MicrophoneCapture::pulse_source_by_substr("anything").is_none());
|
||||
assert_eq!(
|
||||
MicrophoneCapture::pulse_source_desc(None),
|
||||
"pulsesrc do-timestamp=true"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -135,6 +135,7 @@ exit 0
|
||||
#[serial]
|
||||
fn audio_out_new_and_push_are_stable_with_sink_override() {
|
||||
with_var("LESAVKA_AUDIO_SINK", Some("fakesink sync=false"), || {
|
||||
with_var("LESAVKA_AUDIO_GAIN", Some("2.5"), || {
|
||||
with_var("LESAVKA_TAP_AUDIO", Some("1"), || match AudioOut::new() {
|
||||
Ok(out) => {
|
||||
out.push(AudioPacket {
|
||||
@ -149,6 +150,7 @@ exit 0
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -185,4 +187,48 @@ exit 0
|
||||
assert_eq!(buffer.pts(), None);
|
||||
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");
|
||||
}
|
||||
|
||||
#[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]
|
||||
#[serial]
|
||||
fn recover_enumeration_reports_clear_failure_when_helper_leaves_udc_unattached() {
|
||||
|
||||
@ -42,20 +42,19 @@ mod server_main_rpc {
|
||||
.expect("open ms"),
|
||||
);
|
||||
|
||||
(
|
||||
dir,
|
||||
Handler {
|
||||
let handler = with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), || Handler {
|
||||
kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))),
|
||||
ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))),
|
||||
gadget: UsbGadget::new("lesavka"),
|
||||
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
||||
capture_power: CapturePowerManager::new(),
|
||||
eye_hubs: std::sync::Arc::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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user