fix(audio): add remote gain control

This commit is contained in:
Brad Stein 2026-04-21 20:19:47 -03:00
parent 14613a319e
commit a2e9496071
20 changed files with 470 additions and 103 deletions

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package]
name = "lesavka_client"
version = "0.11.43"
version = "0.11.44"
edition = "2024"
[dependencies]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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) // 48kHz
.field("channels", &2i32) // stereo
.field("mpegversion", 4i32) // AAC
.field("stream-format", "adts") // ADTS frames
.field("rate", 48_000i32) // 48kHz
.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() {

View File

@ -1,6 +1,6 @@
[package]
name = "lesavka_common"
version = "0.11.43"
version = "0.11.44"
edition = "2024"
build = "build.rs"

View File

@ -10,7 +10,7 @@ bench = false
[package]
name = "lesavka_server"
version = "0.11.43"
version = "0.11.44"
edition = "2024"
autobins = false

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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