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![
+40
View File
@@ -40,11 +40,21 @@ fn default_true() -> bool {
true
}
fn default_volume() -> f32 {
1.0
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AppConfig {
pub input_device: String,
pub output_device: String,
pub noise_gate_threshold: f32,
/// App-internal capture gain applied to the mic before encode (1.0 = unity).
#[serde(default = "default_volume")]
pub input_volume: f32,
/// App-internal playback gain applied to the mixed output (1.0 = unity).
#[serde(default = "default_volume")]
pub output_volume: f32,
#[serde(default)]
pub network_mode: NetworkMode,
/// Route audio through PipeWire's echo-cancel module (AEC + noise suppression).
@@ -77,6 +87,8 @@ impl Default for AppConfig {
input_device: "".to_string(),
output_device: "".to_string(),
noise_gate_threshold: 0.01,
input_volume: 1.0,
output_volume: 1.0,
network_mode: NetworkMode::default(),
echo_cancellation_enabled: false,
notifications_enabled: true,
@@ -142,6 +154,9 @@ mod tests {
assert_eq!(deserialized.network_mode, NetworkMode::RelayNoDiscovery);
assert!(!deserialized.echo_cancellation_enabled);
assert!(deserialized.notifications_enabled);
// Configs predating the volume sliders must load at unity gain.
assert_eq!(deserialized.input_volume, 1.0);
assert_eq!(deserialized.output_volume, 1.0);
assert!(deserialized.custom_sound_self_join.is_none());
assert!(deserialized.custom_sound_peer_join.is_none());
assert!(deserialized.custom_sound_peer_leave.is_none());
@@ -152,6 +167,31 @@ mod tests {
assert!(deserialized.custom_sound_reconnect_failed.is_none());
}
#[test]
fn test_input_output_volume_fields() {
// Default impl is unity gain.
let def = AppConfig::default();
assert_eq!(def.input_volume, 1.0);
assert_eq!(def.output_volume, 1.0);
// Missing in JSON → unity (serde default).
let missing = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
let cfg_missing: AppConfig = serde_json::from_str(missing).unwrap();
assert_eq!(cfg_missing.input_volume, 1.0);
assert_eq!(cfg_missing.output_volume, 1.0);
// Explicit non-unity values are preserved across a round-trip.
let cfg = AppConfig {
input_volume: 1.5,
output_volume: 0.25,
..AppConfig::default()
};
let round_tripped: AppConfig =
serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
assert_eq!(round_tripped.input_volume, 1.5);
assert_eq!(round_tripped.output_volume, 0.25);
}
#[test]
fn test_notifications_enabled_specifically() {
let missing_notifications = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
+4
View File
@@ -12,6 +12,10 @@ pub enum CoreCommand {
SetPttActive(bool),
SetPeerVolume(EndpointId, f32),
SetNoiseGateThreshold(f32),
/// App-internal capture gain (mic), applied before the gate/encode. 1.0 = unity.
SetInputVolume(f32),
/// App-internal playback gain on the mixed output. 1.0 = unity.
SetOutputVolume(f32),
/// Start/stop a standalone capture-only stream that reports the raw mic
/// level via [`UiEvent::MicLevel`], for gate calibration outside a call.
/// Ignored while a room session is active (the in-call meter covers that).
+31 -4
View File
@@ -175,9 +175,16 @@ struct MicMonitor {
/// Drains a capture channel, reporting the raw (un-gated) mic level to the UI.
/// Returns when the channel closes (i.e. the backend's capture stream stopped).
fn run_mic_monitor(rx: std::sync::mpsc::Receiver<Vec<i16>>, ui_tx: mpsc::Sender<UiEvent>) {
fn run_mic_monitor(
rx: std::sync::mpsc::Receiver<Vec<i16>>,
ui_tx: mpsc::Sender<UiEvent>,
input_gain: Arc<std::sync::atomic::AtomicU32>,
) {
let mut meter = MicLevelMeter::new();
while let Ok(pcm) = rx.recv() {
while let Ok(mut pcm) = rx.recv() {
// Mirror the in-call path: apply the input gain before metering so the
// test meter reflects the gained signal (and the input slider moves it).
apply_volume(&mut pcm, f32::from_bits(input_gain.load(Ordering::Relaxed)));
if let Some(peak) = meter.push(&pcm) {
// Drop on a full channel — a stale meter reading is harmless.
let _ = ui_tx.try_send(UiEvent::MicLevel(peak));
@@ -369,6 +376,9 @@ async fn run_core_loop(
let ptt_mode = Arc::new(AtomicBool::new(false));
let ptt_active = Arc::new(AtomicBool::new(false));
let noise_gate_threshold = Arc::new(std::sync::atomic::AtomicU32::new(0.01f32.to_bits()));
// App-internal capture/playback gains (f32 bits), live-read by the audio loops.
let input_gain = Arc::new(std::sync::atomic::AtomicU32::new(1.0f32.to_bits()));
let output_gain = Arc::new(std::sync::atomic::AtomicU32::new(1.0f32.to_bits()));
let peer_volumes = Arc::new(Mutex::new(HashMap::<EndpointId, f32>::new()));
let mut current_name = "Anonymous".to_string();
let mut network_mode = NetworkMode::default();
@@ -541,6 +551,7 @@ async fn run_core_loop(
let ptt_mode_clone = ptt_mode.clone();
let ptt_active_clone = ptt_active.clone();
let noise_gate_threshold_clone = noise_gate_threshold.clone();
let input_gain_clone = input_gain.clone();
let transport_clone = transport.clone();
let ui_tx_capture = ui_tx.clone();
@@ -565,6 +576,10 @@ async fn run_core_loop(
let mut mic_meter = MicLevelMeter::new();
while let Ok(mut pcm) = capture_rx.recv() {
// Apply the input gain first so the meter, gate, and what we
// transmit all reflect the same (gained) signal.
apply_volume(&mut pcm, f32::from_bits(input_gain_clone.load(Ordering::Relaxed)));
if let Some(peak) = mic_meter.push(&pcm) {
let _ = ui_tx_capture.try_send(UiEvent::MicLevel(peak));
}
@@ -645,6 +660,7 @@ async fn run_core_loop(
let jitter_mixer = jitter.clone();
let is_deafened_clone = is_deafened.clone();
let peer_volumes_mixer = peer_volumes.clone();
let output_gain_mixer = output_gain.clone();
let ui_tx_mixer = ui_tx.clone();
let ring_fill_mixer = ring_fill.clone();
let mixer_task = tokio::spawn(async move {
@@ -693,7 +709,9 @@ async fn run_core_loop(
}
}
let mixed = mix_frames(&peer_frames, FRAME_SAMPLES);
let mut mixed = mix_frames(&peer_frames, FRAME_SAMPLES);
// Master output gain on the mixed signal (post per-peer volume).
apply_volume(&mut mixed, f32::from_bits(output_gain_mixer.load(Ordering::Relaxed)));
let frame_to_send = if is_deafened_clone.load(Ordering::Relaxed) {
vec![0i16; FRAME_SAMPLES]
@@ -871,6 +889,14 @@ async fn run_core_loop(
noise_gate_threshold.store(threshold.to_bits(), Ordering::Relaxed);
}
CoreCommand::SetInputVolume(vol) => {
input_gain.store(vol.to_bits(), Ordering::Relaxed);
}
CoreCommand::SetOutputVolume(vol) => {
output_gain.store(vol.to_bits(), Ordering::Relaxed);
}
CoreCommand::SetMicMonitor { enabled, input_device } => {
// During a call the in-call capture thread already reports the
// mic level, and it owns the backend's capture stream — leave it be.
@@ -883,7 +909,8 @@ async fn run_core_loop(
match audio_backend.start_capture(tx, input_device) {
Ok(()) => {
let ui = ui_tx.clone();
let thread = std::thread::spawn(move || run_mic_monitor(rx, ui));
let gain = input_gain.clone();
let thread = std::thread::spawn(move || run_mic_monitor(rx, ui, gain));
mic_monitor = Some(MicMonitor { thread });
}
Err(e) => {