From 5af25bff5e38fe59dc6f65dc2e9e818ce701e14a Mon Sep 17 00:00:00 2001 From: Mollusk Date: Tue, 2 Jun 2026 16:21:39 -0400 Subject: [PATCH] feat(audio): input/output volume sliders in Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/mod.rs | 30 +++++++++++++++++++++++++++++- src/config.rs | 40 ++++++++++++++++++++++++++++++++++++++++ src/core/messages.rs | 4 ++++ src/core/mod.rs | 35 +++++++++++++++++++++++++++++++---- 4 files changed, 104 insertions(+), 5 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 257a5bf..dd94466 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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 { 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![ diff --git a/src/config.rs b/src/config.rs index c91eba5..44a6e19 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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}"#; diff --git a/src/core/messages.rs b/src/core/messages.rs index 7497f38..91e2260 100644 --- a/src/core/messages.rs +++ b/src/core/messages.rs @@ -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). diff --git a/src/core/mod.rs b/src/core/mod.rs index d41898d..03cab21 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -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>, ui_tx: mpsc::Sender) { +fn run_mic_monitor( + rx: std::sync::mpsc::Receiver>, + ui_tx: mpsc::Sender, + input_gain: Arc, +) { 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::::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) => {