fix(client): harden audio gain control

This commit is contained in:
Brad Stein 2026-04-21 22:15:47 -03:00
parent 3b685415ed
commit e33ff7e42d
9 changed files with 146 additions and 73 deletions

View File

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

View File

@ -166,6 +166,52 @@ fn network_spread_ms(samples: &VecDeque<(Instant, f32)>) -> f32 {
deviations[deviations.len() / 2]
}
#[cfg(not(coverage))]
/// Apply a remote-audio gain slider update without unwinding through GTK callbacks.
fn apply_audio_gain_change(
scale: &gtk::Scale,
state: &Rc<RefCell<LauncherState>>,
widgets: &super::ui_components::LauncherWidgets,
child_proc: &Rc<RefCell<Option<RelayChild>>>,
) -> bool {
let percent = scale
.value()
.round()
.clamp(0.0, MAX_AUDIO_GAIN_PERCENT as f64) as u32;
let label = {
let Ok(mut state) = state.try_borrow_mut() else {
return false;
};
if state.audio_gain_percent == percent {
widgets.audio_gain_value.set_text(&state.audio_gain_label());
return true;
}
state.set_audio_gain_percent(percent);
state.audio_gain_label()
};
widgets.audio_gain_value.set_text(&label);
let relay_live = child_proc
.try_borrow()
.map(|child| child.is_some())
.unwrap_or(false);
if relay_live {
let path = 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."
));
}
true
}
#[cfg(not(coverage))]
fn request_capture_power_refresh(
power_tx: std::sync::mpsc::Sender<PowerMessage>,
@ -1077,34 +1123,14 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
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."
));
if !apply_audio_gain_change(scale, &state, &widgets, &child_proc) {
let scale = scale.clone();
let state = Rc::clone(&state);
let widgets = widgets.clone();
let child_proc = Rc::clone(&child_proc);
glib::idle_add_local_once(move || {
let _ = apply_audio_gain_change(&scale, &state, &widgets, &child_proc);
});
}
});
}

View File

@ -119,7 +119,7 @@ const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 158;
const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 280;
const EYE_PREVIEW_MIN_HEIGHT: i32 = 258;
const EYE_PREVIEW_MIN_WIDTH: i32 = 460;
const SIDE_LOG_HEIGHT: i32 = 124;
const SIDE_LOG_MIN_HEIGHT: i32 = 124;
pub fn build_launcher_view(
app: &gtk::Application,
@ -427,7 +427,8 @@ pub fn build_launcher_view(
relay_row.append(&server_entry);
let start_button = gtk::Button::with_label("Connect");
start_button.add_css_class("suggested-action");
stabilize_button(&start_button, 92);
start_button.set_hexpand(false);
stabilize_button(&start_button, 108);
relay_row.append(&start_button);
connection_body.append(&relay_row);
@ -457,7 +458,7 @@ pub fn build_launcher_view(
connection_body.append(&live_actions_row);
connection_body.append(&gtk::Separator::new(gtk::Orientation::Horizontal));
let power_heading = gtk::Label::new(Some("Power"));
let power_heading = gtk::Label::new(Some("GPIO Power"));
power_heading.add_css_class("subgroup-title");
power_heading.set_halign(gtk::Align::Start);
@ -465,31 +466,38 @@ pub fn build_launcher_view(
power_shell.set_halign(gtk::Align::Fill);
let power_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
power_row.set_hexpand(true);
power_heading.set_width_chars(5);
power_heading.set_width_chars(10);
power_row.append(&power_heading);
let power_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);
power_buttons.set_hexpand(true);
power_buttons.set_homogeneous(true);
let power_on_button = gtk::Button::with_label("On");
power_on_button.set_hexpand(true);
stabilize_button(&power_on_button, 52);
power_on_button.add_css_class("pill-toggle");
let power_auto_button = gtk::Button::with_label("Auto");
power_auto_button.set_hexpand(true);
stabilize_button(&power_auto_button, 52);
power_auto_button.add_css_class("pill-toggle");
let power_off_button = gtk::Button::with_label("Off");
power_off_button.set_hexpand(true);
stabilize_button(&power_off_button, 52);
power_off_button.add_css_class("pill-toggle");
let power_detail = gtk::Label::new(Some("Capture power status is loading..."));
power_detail.add_css_class("dim-label");
power_detail.set_wrap(true);
power_detail.set_xalign(0.0);
power_row.append(&power_on_button);
power_row.append(&power_auto_button);
power_row.append(&power_off_button);
power_buttons.append(&power_on_button);
power_buttons.append(&power_auto_button);
power_buttons.append(&power_off_button);
power_row.append(&power_buttons);
let audio_gain_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
audio_gain_row.set_size_request(220, -1);
audio_gain_row.set_hexpand(true);
let audio_gain_label = gtk::Label::new(Some("Audio"));
audio_gain_label.add_css_class("dim-label");
audio_gain_label.set_halign(gtk::Align::Start);
audio_gain_label.set_width_chars(5);
audio_gain_label.set_width_chars(10);
let audio_gain_adjustment = gtk::Adjustment::new(
state.audio_gain_percent as f64,
0.0,
@ -515,15 +523,18 @@ pub fn build_launcher_view(
power_shell.append(&power_row);
power_shell.append(&audio_gain_row);
connection_body.append(&power_shell);
let routing_heading = gtk::Label::new(Some("Input"));
let routing_heading = gtk::Label::new(Some("Inputs"));
routing_heading.add_css_class("subgroup-title");
routing_heading.set_halign(gtk::Align::Start);
connection_body.append(&gtk::Separator::new(gtk::Orientation::Horizontal));
let routing_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
routing_row.set_hexpand(true);
routing_heading.set_width_chars(5);
routing_heading.set_width_chars(10);
routing_row.append(&routing_heading);
let routing_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);
routing_buttons.set_hexpand(true);
routing_buttons.set_homogeneous(true);
let input_toggle_button = gtk::Button::with_label("Route");
input_toggle_button.set_hexpand(true);
stabilize_button(&input_toggle_button, 106);
@ -531,13 +542,18 @@ pub fn build_launcher_view(
"Change live keyboard and mouse ownership between this machine and the remote target.",
));
let swap_key_button = gtk::Button::with_label("Set Swap Key");
swap_key_button.set_hexpand(true);
stabilize_button(&swap_key_button, 106);
routing_row.append(&input_toggle_button);
routing_row.append(&swap_key_button);
routing_buttons.append(&input_toggle_button);
routing_buttons.append(&swap_key_button);
routing_row.append(&routing_buttons);
connection_body.append(&routing_row);
operations.append(&connection_panel);
let (diagnostics_panel, diagnostics_body) = build_panel("Diagnostics");
diagnostics_panel.set_vexpand(true);
diagnostics_panel.set_valign(gtk::Align::Fill);
diagnostics_body.set_vexpand(true);
let diagnostics_toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 8);
diagnostics_toolbar.set_homogeneous(true);
let diagnostics_copy_button = gtk::Button::with_label("Copy Report");
@ -562,9 +578,8 @@ pub fn build_launcher_view(
diagnostics_shell.append(&diagnostics_label);
let diagnostics_scroll = gtk::ScrolledWindow::builder()
.hexpand(true)
.vexpand(false)
.min_content_height(SIDE_LOG_HEIGHT)
.max_content_height(SIDE_LOG_HEIGHT)
.vexpand(true)
.min_content_height(SIDE_LOG_MIN_HEIGHT)
.child(&diagnostics_shell)
.build();
diagnostics_body.append(&diagnostics_toolbar);
@ -572,7 +587,9 @@ pub fn build_launcher_view(
operations.append(&diagnostics_panel);
let (console_panel, console_body) = build_panel("Session Console");
console_panel.set_vexpand(false);
console_panel.set_vexpand(true);
console_panel.set_valign(gtk::Align::Fill);
console_body.set_vexpand(true);
let console_toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 8);
console_toolbar.set_homogeneous(true);
let console_copy_button = gtk::Button::with_label("Copy Log");
@ -602,9 +619,8 @@ pub fn build_launcher_view(
session_log_view.set_wrap_mode(gtk::WrapMode::WordChar);
let log_scroll = gtk::ScrolledWindow::builder()
.hexpand(true)
.vexpand(false)
.min_content_height(SIDE_LOG_HEIGHT)
.max_content_height(SIDE_LOG_HEIGHT)
.vexpand(true)
.min_content_height(SIDE_LOG_MIN_HEIGHT)
.child(&session_log_view)
.build();
console_body.append(&console_toolbar);
@ -884,6 +900,10 @@ pub fn install_css(window: &gtk::ApplicationWindow) {
label.status-line {
opacity: 0.9;
}
label.eye-inline-status {
font-size: 0.86rem;
opacity: 0.82;
}
textview.status-log,
label.status-log {
font-family: monospace;
@ -1280,15 +1300,6 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
stack.set_visible_child_name("preview");
root.append(&stack);
let stream_status = gtk::Label::new(Some("Connect relay to preview."));
stream_status.add_css_class("status-line");
stream_status.set_halign(gtk::Align::Start);
stream_status.set_hexpand(true);
stream_status.set_ellipsize(pango::EllipsizeMode::End);
stream_status.set_single_line_mode(true);
stream_status.set_max_width_chars(24);
stream_status.set_tooltip_text(Some("Connect relay to preview."));
root.append(&stream_status);
let feed_source_combo = gtk::ComboBoxText::new();
feed_source_combo.set_tooltip_text(Some(
"Choose which physical eye feed appears in this pane. Off disables the pane; the opposite-eye option mirrors the other physical feed while preserving a separate stream load for realistic validation.",
@ -1310,6 +1321,17 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
let action_button = gtk::Button::with_label("Break Out");
stabilize_button(&action_button, 104);
action_button.set_halign(gtk::Align::End);
let stream_status = gtk::Label::new(Some("Connect relay to preview."));
stream_status.add_css_class("status-line");
stream_status.add_css_class("eye-inline-status");
stream_status.set_halign(gtk::Align::Fill);
stream_status.set_valign(gtk::Align::Center);
stream_status.set_hexpand(true);
stream_status.set_ellipsize(pango::EllipsizeMode::End);
stream_status.set_single_line_mode(true);
stream_status.set_width_chars(12);
stream_status.set_max_width_chars(18);
stream_status.set_tooltip_text(Some("Connect relay to preview."));
let footer_shell = gtk::Box::new(gtk::Orientation::Vertical, 6);
let controls_grid = gtk::Grid::new();
controls_grid.set_column_spacing(8);
@ -1322,9 +1344,10 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
capture_row.set_hexpand(true);
breakout_row.set_hexpand(true);
controls_grid.attach(&feed_row, 0, 0, 1, 1);
controls_grid.attach(&capture_row, 1, 0, 1, 1);
controls_grid.attach(&capture_row, 1, 0, 2, 1);
controls_grid.attach(&breakout_row, 0, 1, 1, 1);
controls_grid.attach(&action_button, 1, 1, 1, 1);
controls_grid.attach(&stream_status, 1, 1, 1, 1);
controls_grid.attach(&action_button, 2, 1, 1, 1);
footer_shell.append(&controls_grid);
root.append(&footer_shell);

View File

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

View File

@ -98,12 +98,12 @@
"client/src/launcher/ui.rs": {
"clippy_warnings": 62,
"doc_debt": 23,
"loc": 2341
"loc": 2367
},
"client/src/launcher/ui_components.rs": {
"clippy_warnings": 16,
"doc_debt": 15,
"loc": 1349
"loc": 1372
},
"client/src/launcher/ui_runtime.rs": {
"clippy_warnings": 62,

View File

@ -62,7 +62,7 @@
},
"client/src/launcher/ui.rs": {
"line_percent": 100.0,
"loc": 2341
"loc": 2367
},
"client/src/layout.rs": {
"line_percent": 97.73,

View File

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

View File

@ -44,6 +44,16 @@ fn eye_panes_keep_the_locked_larger_preview_footprint() {
|| UI_SRC.contains("capture_label.set_halign(gtk::Align::End)")
);
assert!(UI_SRC.contains("capture_label.set_ellipsize(pango::EllipsizeMode::Start);"));
assert!(!UI_SRC.contains("root.append(&stream_status);"));
assert!(UI_SRC.contains("stream_status.add_css_class(\"eye-inline-status\");"));
assert!(
source_index("controls_grid.attach(&breakout_row, 0, 1, 1, 1);")
< source_index("controls_grid.attach(&stream_status, 1, 1, 1, 1);")
);
assert!(
source_index("controls_grid.attach(&stream_status, 1, 1, 1, 1);")
< source_index("controls_grid.attach(&action_button, 2, 1, 1, 1);")
);
}
#[test]
@ -74,20 +84,21 @@ fn device_testing_keeps_webcam_and_mic_playback_as_equal_bottom_columns() {
#[test]
fn operations_column_fills_height_and_splits_extra_space_between_logs() {
assert_eq!(const_i32("SIDE_LOG_HEIGHT"), 124);
assert_eq!(const_i32("SIDE_LOG_MIN_HEIGHT"), 124);
assert!(UI_SRC.contains("operations.set_vexpand(true);"));
assert!(UI_SRC.contains("operations.set_valign(gtk::Align::Fill);"));
assert!(UI_SRC.contains("diagnostics_panel.set_vexpand(true);"));
assert!(UI_SRC.contains("console_panel.set_vexpand(true);"));
assert_eq!(
UI_SRC
.matches(".min_content_height(SIDE_LOG_HEIGHT)")
.matches(".min_content_height(SIDE_LOG_MIN_HEIGHT)")
.count(),
2
);
assert_eq!(
UI_SRC
.matches(".max_content_height(SIDE_LOG_HEIGHT)")
.count(),
2
UI_SRC.matches(".max_content_height(SIDE_LOG").count(),
0,
"the docked logs must be allowed to split extra right-rail height"
);
}
@ -97,6 +108,7 @@ fn relay_controls_keep_connect_inline_with_server_entry() {
assert!(UI_SRC.contains("let relay_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);"));
assert!(UI_SRC.contains("relay_row.append(&server_entry);"));
assert!(UI_SRC.contains("let start_button = gtk::Button::with_label(\"Connect\");"));
assert!(UI_SRC.contains("stabilize_button(&start_button, 108);"));
assert!(UI_SRC.contains("relay_row.append(&start_button);"));
assert!(
source_index("relay_row.append(&server_entry);")
@ -108,8 +120,9 @@ fn relay_controls_keep_connect_inline_with_server_entry() {
fn remote_audio_gain_control_stays_in_the_operations_rail() {
assert!(!UI_SRC.contains("Remote Audio"));
assert!(UI_SRC.contains("let power_shell = gtk::Box::new(gtk::Orientation::Vertical, 6);"));
assert!(UI_SRC.contains("let power_heading = gtk::Label::new(Some(\"Power\"));"));
assert!(UI_SRC.contains("let power_heading = gtk::Label::new(Some(\"GPIO Power\"));"));
assert!(UI_SRC.contains("power_row.append(&power_heading);"));
assert!(UI_SRC.contains("power_buttons.set_homogeneous(true);"));
assert!(UI_SRC.contains("let audio_gain_scale ="));
assert!(UI_SRC.contains("audio_gain_scale.set_draw_value(false);"));
assert!(UI_SRC.contains("audio_gain_value.set_width_chars(5);"));
@ -123,11 +136,12 @@ fn remote_audio_gain_control_stays_in_the_operations_rail() {
"the operations rail should not gain extra vertical sections that stretch the lower layout"
);
assert!(
source_index("let power_heading = gtk::Label::new(Some(\"Power\"));")
source_index("let power_heading = gtk::Label::new(Some(\"GPIO Power\"));")
< source_index("let audio_gain_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);")
);
assert!(
source_index("power_shell.append(&audio_gain_row);")
< source_index("let routing_heading = gtk::Label::new(Some(\"Input\"));")
< source_index("let routing_heading = gtk::Label::new(Some(\"Inputs\"));")
);
assert!(UI_SRC.contains("routing_buttons.set_homogeneous(true);"));
}

View File

@ -6,6 +6,7 @@
//! video, and input streams must not keep running as leaked child processes.
const UI_RUNTIME_SRC: &str = include_str!("../../client/src/launcher/ui_runtime.rs");
const UI_SRC: &str = include_str!("../../client/src/launcher/ui.rs");
const LAUNCHER_MOD_SRC: &str = include_str!("../../client/src/launcher/mod.rs");
const MAIN_SRC: &str = include_str!("../../client/src/main.rs");
@ -32,3 +33,12 @@ fn relay_address_entry_is_locked_while_relay_is_live() {
assert!(UI_RUNTIME_SRC.contains("\"Connect\""));
assert!(UI_RUNTIME_SRC.contains("\"Disconnect\""));
}
#[test]
fn audio_gain_slider_callback_never_panics_on_refresh_reentry() {
assert!(UI_SRC.contains("fn apply_audio_gain_change("));
assert!(UI_SRC.contains("state.try_borrow_mut()"));
assert!(UI_SRC.contains("return false;"));
assert!(UI_SRC.contains("glib::idle_add_local_once"));
assert!(!UI_SRC.contains("let mut state = state.borrow_mut();\n if state.audio_gain_percent == percent"));
}