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:
+29
-1
@@ -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,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}"#;
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user