feat(audio): input/output volume sliders in Settings

Adds Discord-style app-internal gain controls under each device picker:
input volume scales the captured mic (applied before the meter/gate/encode,
so it also moves the mic meter), output volume scales the mixed playback
(on top of per-peer volumes). PeerSpeak-only — no system/other-app effect.

Both persist in config (input_volume/output_volume, serde default 1.0 for
backward compat) and read live by the audio loops via f32-bit atomics, so
they take effect mid-call. Sliders apply live on drag and save on release.
The standalone mic-test monitor applies the same input gain so the test
meter reflects it. Reuses the existing apply_volume helper (unity fast-path
+ i16 saturation).

Tests: config backward-compat + round-trip for the new fields (gain math
itself is covered by the existing apply_volume tests). 65 lib tests, clippy
clean. Field-verified: input slider moves the mic-test meter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 16:21:39 -04:00
co-authored by Claude Opus 4.8
parent cb2776cc1d
commit 5af25bff5e
4 changed files with 104 additions and 5 deletions
+29 -1
View File
@@ -43,6 +43,12 @@ pub enum AppMessage {
PeerVolumeChanged(EndpointId, f32),
InputDeviceSelected(AudioDevice),
OutputDeviceSelected(AudioDevice),
/// Live input-gain drag (applies immediately, persisted on release).
InputVolumeChanged(f32),
/// Live output-gain drag (applies immediately, persisted on release).
OutputVolumeChanged(f32),
/// Persist the current config to disk (slider release).
PersistConfig,
NoiseGateChanged(f32),
/// Live value while dragging the gate handle on the meter — updates the gate
/// immediately but does not persist (saved once on release via NoiseGateChanged).
@@ -129,6 +135,8 @@ impl Default for AppState {
let config = AppConfig::load();
notify::set_enabled(config.notifications_enabled);
let _ = controller.send(CoreCommand::SetNoiseGateThreshold(config.noise_gate_threshold));
let _ = controller.send(CoreCommand::SetInputVolume(config.input_volume));
let _ = controller.send(CoreCommand::SetOutputVolume(config.output_volume));
let _ = controller.send(CoreCommand::SetNetworkMode(config.network_mode));
let all_devices = enumerate_audio_devices();
let input_devices: Vec<_> = all_devices.iter().filter(|d| d.is_input).cloned().collect();
@@ -372,6 +380,18 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.config.save();
state.selected_output = Some(dev);
}
AppMessage::InputVolumeChanged(vol) => {
// Live apply; disk write deferred to release (PersistConfig).
state.config.input_volume = vol;
let _ = state.controller.send(CoreCommand::SetInputVolume(vol));
}
AppMessage::OutputVolumeChanged(vol) => {
state.config.output_volume = vol;
let _ = state.controller.send(CoreCommand::SetOutputVolume(vol));
}
AppMessage::PersistConfig => {
state.config.save();
}
AppMessage::NoiseGateChanged(val) => {
state.config.noise_gate_threshold = val;
state.config.save();
@@ -641,6 +661,10 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
state.selected_input.as_ref(),
AppMessage::InputDeviceSelected,
).width(iced::Length::Fill),
text(format!("Input Volume (mic): {:.0}%", state.config.input_volume * 100.0)).size(11).color(color_subtext),
slider(0.0..=2.0, state.config.input_volume, AppMessage::InputVolumeChanged)
.step(0.05)
.on_release(AppMessage::PersistConfig),
].spacing(4).width(iced::Length::Fill),
column![
text("Output Device").size(12).color(Color::from_rgb8(180, 180, 180)),
@@ -649,8 +673,12 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
state.selected_output.as_ref(),
AppMessage::OutputDeviceSelected,
).width(iced::Length::Fill),
text(format!("Output Volume: {:.0}%", state.config.output_volume * 100.0)).size(11).color(color_subtext),
slider(0.0..=2.0, state.config.output_volume, AppMessage::OutputVolumeChanged)
.step(0.05)
.on_release(AppMessage::PersistConfig),
].spacing(4).width(iced::Length::Fill),
].spacing(20).width(iced::Length::Fill),
].spacing(20).align_y(iced::alignment::Vertical::Top).width(iced::Length::Fill),
vertical_space(12.0),
row![
column![