From 529367ee0ce1394ed01dc4c8a227957edda80c7b Mon Sep 17 00:00:00 2001 From: Mollusk Date: Wed, 27 May 2026 16:11:37 -0400 Subject: [PATCH] feat: Add Mic Sensitivity Noise Gate slider and core audio gating --- src/app/mod.rs | 12 +++++++ src/config.rs | 13 ++++++- src/core/messages.rs | 1 + src/core/mod.rs | 21 ++++++++++++ update_app_noisegate.py | 65 +++++++++++++++++++++++++++++++++++ update_core.py | 76 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 update_app_noisegate.py create mode 100644 update_core.py diff --git a/src/app/mod.rs b/src/app/mod.rs index d2b8f65..504fc33 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -39,6 +39,7 @@ pub enum AppMessage { PeerVolumeChanged(EndpointId, f32), InputDeviceSelected(AudioDevice), OutputDeviceSelected(AudioDevice), + NoiseGateChanged(f32), EventOccurred(Event), NavigateToSettings, NavigateBack, @@ -89,6 +90,7 @@ impl Default for AppState { let _ = UI_RX.set(Mutex::new(Some(ui_rx))); let config = AppConfig::load(); + let _ = controller.send(CoreCommand::SetNoiseGateThreshold(config.noise_gate_threshold)); let all_devices = enumerate_audio_devices(); let input_devices: Vec<_> = all_devices.iter().filter(|d| d.is_input).cloned().collect(); let output_devices: Vec<_> = all_devices.iter().filter(|d| !d.is_input).cloned().collect(); @@ -251,6 +253,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.config.save(); state.selected_output = Some(dev); } + AppMessage::NoiseGateChanged(val) => { + state.config.noise_gate_threshold = val; + state.config.save(); + let _ = state.controller.send(CoreCommand::SetNoiseGateThreshold(val)); + } AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyPressed { key, .. })) => { if state.is_setting_hotkey { state.ptt_hotkey = key.clone(); @@ -384,6 +391,11 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { ).width(iced::Length::Fixed(160.0)) ].spacing(4), ].spacing(10), + vertical_space(20.0), + column![ + text(format!("Mic Sensitivity (Noise Gate): {:.1}%", state.config.noise_gate_threshold * 100.0)).size(14).color(color_subtext), + slider(0.0..=0.1, state.config.noise_gate_threshold, AppMessage::NoiseGateChanged) + ].spacing(10).width(iced::Length::Fixed(320.0)), vertical_space(30.0), button( text("Back") diff --git a/src/config.rs b/src/config.rs index c135051..a763ec0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,10 +2,21 @@ use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppConfig { pub input_device: String, pub output_device: String, + pub noise_gate_threshold: f32, +} + +impl Default for AppConfig { + fn default() -> Self { + Self { + input_device: "".to_string(), + output_device: "".to_string(), + noise_gate_threshold: 0.01, + } + } } impl AppConfig { diff --git a/src/core/messages.rs b/src/core/messages.rs index f7182e8..1d8e02b 100644 --- a/src/core/messages.rs +++ b/src/core/messages.rs @@ -10,6 +10,7 @@ pub enum CoreCommand { SetPttMode(bool), SetPttActive(bool), SetPeerVolume(EndpointId, f32), + SetNoiseGateThreshold(f32), } #[derive(Debug, Clone)] diff --git a/src/core/mod.rs b/src/core/mod.rs index f494b7c..b1f6c45 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -95,6 +95,7 @@ async fn run_core_loop( let is_deafened = Arc::new(AtomicBool::new(false)); 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())); let peer_volumes = Arc::new(Mutex::new(HashMap::::new())); let mut current_name = "Anonymous".to_string(); @@ -194,6 +195,7 @@ async fn run_core_loop( let is_muted_clone = is_muted.clone(); let ptt_mode_clone = ptt_mode.clone(); let ptt_active_clone = ptt_active.clone(); + let noise_gate_threshold_clone = noise_gate_threshold.clone(); let transport_clone = transport.clone(); let room_state_clone = room_state.clone(); let tokio_handle = tokio::runtime::Handle::current(); @@ -215,6 +217,21 @@ async fn run_core_loop( if ptt_mode_clone.load(Ordering::Relaxed) && !ptt_active_clone.load(Ordering::Relaxed) { continue; } + + let ng_bits = noise_gate_threshold_clone.load(Ordering::Relaxed); + let ng_thresh = f32::from_bits(ng_bits); + if ng_thresh > 0.0001 { + let mut sum_sq = 0.0f32; + for &sample in &pcm { + let normalized = (sample as f32) / 32768.0; + sum_sq += normalized * normalized; + } + let rms = (sum_sq / pcm.len() as f32).sqrt(); + if rms < ng_thresh { + continue; + } + } + if let Ok(encoded) = encoder.encode(&pcm) { let bytes = bytes::Bytes::from(encoded); let active = room_state_clone.active_peers(); @@ -428,6 +445,10 @@ async fn run_core_loop( let mut guard = peer_volumes.lock().await; guard.insert(peer_id, vol); } + + CoreCommand::SetNoiseGateThreshold(threshold) => { + noise_gate_threshold.store(threshold.to_bits(), Ordering::Relaxed); + } } } diff --git a/update_app_noisegate.py b/update_app_noisegate.py new file mode 100644 index 0000000..4976858 --- /dev/null +++ b/update_app_noisegate.py @@ -0,0 +1,65 @@ +import re + +with open("src/app/mod.rs", "r") as f: + text = f.read() + +# 1. Add to AppMessage +text = text.replace( + " OutputDeviceSelected(AudioDevice),\n EventOccurred(Event),", + " OutputDeviceSelected(AudioDevice),\n NoiseGateChanged(f32),\n EventOccurred(Event)," +) + +# 2. Add to AppState (already has config, so we can just read from config.noise_gate_threshold, but state needs it too if we don't want to use state.config everywhere) +# Let's just use state.config.noise_gate_threshold + +# 3. Add to update() match +update_old = """ AppMessage::OutputDeviceSelected(dev) => { + state.config.output_device = dev.name.clone(); + state.config.save(); + state.selected_output = Some(dev); + }""" +update_new = """ AppMessage::OutputDeviceSelected(dev) => { + state.config.output_device = dev.name.clone(); + state.config.save(); + state.selected_output = Some(dev); + } + AppMessage::NoiseGateChanged(val) => { + state.config.noise_gate_threshold = val; + state.config.save(); + let _ = state.controller.send(CoreCommand::SetNoiseGateThreshold(val)); + }""" +text = text.replace(update_old, update_new) + +# 4. We also need to send the initial noise gate value to the controller on Join/Create, +# but wait! CoreController receives it async. Maybe we send it right after controller is created? +# Or just in AppState::default() we can't send because it's async? No, controller.send() is async? Actually it's non-blocking channel send. +# Let's just add it to Join message or send it when RoomJoined occurs. +# Wait, let's just send it when creating the controller? No, `state.controller.send()` returns Result, it's non-blocking. +default_old = """ let controller = Arc::new(CoreController::new(ui_tx)); + let _ = UI_RX.set(Mutex::new(Some(ui_rx))); + + let config = AppConfig::load();""" +default_new = """ let controller = Arc::new(CoreController::new(ui_tx)); + let _ = UI_RX.set(Mutex::new(Some(ui_rx))); + + let config = AppConfig::load(); + let _ = controller.send(CoreCommand::SetNoiseGateThreshold(config.noise_gate_threshold));""" +text = text.replace(default_old, default_new) + +# 5. Add slider to view_settings +settings_old = """ ].spacing(10), + vertical_space(30.0), + button(""" + +settings_new = """ ].spacing(10), + vertical_space(20.0), + column![ + text(&format!("Mic Sensitivity (Noise Gate): {:.1}%", state.config.noise_gate_threshold * 100.0)).size(14).color(color_subtext), + slider(0.0..=0.1, state.config.noise_gate_threshold, AppMessage::NoiseGateChanged) + ].spacing(10).width(iced::Length::Fixed(320.0)), + vertical_space(30.0), + button(""" +text = text.replace(settings_old, settings_new) + +with open("src/app/mod.rs", "w") as f: + f.write(text) diff --git a/update_core.py b/update_core.py new file mode 100644 index 0000000..86129de --- /dev/null +++ b/update_core.py @@ -0,0 +1,76 @@ +import re + +with open("src/core/mod.rs", "r") as f: + text = f.read() + +# 1. Add noise_gate_threshold to state +text = text.replace( + "let ptt_active = Arc::new(AtomicBool::new(false));", + "let ptt_active = Arc::new(AtomicBool::new(false));\n let noise_gate_threshold = Arc::new(std::sync::atomic::AtomicU32::new(0.01f32.to_bits()));" +) + +# 2. Add noise_gate_threshold to capture thread closure +text = text.replace( + "let ptt_active_clone = ptt_active.clone();", + "let ptt_active_clone = ptt_active.clone();\n let noise_gate_threshold_clone = noise_gate_threshold.clone();" +) + +# 3. Add noise gate logic to capture loop +old_capture_loop = """ while let Ok(pcm) = capture_rx.recv() { + if is_muted_clone.load(Ordering::Relaxed) { + continue; + } + if ptt_mode_clone.load(Ordering::Relaxed) && !ptt_active_clone.load(Ordering::Relaxed) { + continue; + } + if let Ok(encoded) = encoder.encode(&pcm) {""" + +new_capture_loop = """ while let Ok(pcm) = capture_rx.recv() { + if is_muted_clone.load(Ordering::Relaxed) { + continue; + } + if ptt_mode_clone.load(Ordering::Relaxed) && !ptt_active_clone.load(Ordering::Relaxed) { + continue; + } + + let ng_bits = noise_gate_threshold_clone.load(Ordering::Relaxed); + let ng_thresh = f32::from_bits(ng_bits); + if ng_thresh > 0.0001 { + let mut sum_sq = 0.0f32; + for &sample in &pcm { + let normalized = (sample as f32) / 32768.0; + sum_sq += normalized * normalized; + } + let rms = (sum_sq / pcm.len() as f32).sqrt(); + if rms < ng_thresh { + continue; + } + } + + if let Ok(encoded) = encoder.encode(&pcm) {""" +text = text.replace(old_capture_loop, new_capture_loop) + +# 4. Handle CoreCommand::SetNoiseGateThreshold +old_match_end = """ CoreCommand::SetPeerVolume(peer_id, vol) => { + let mut guard = peer_volumes.lock().await; + guard.insert(peer_id, vol); + } + } + } +""" + +new_match_end = """ CoreCommand::SetPeerVolume(peer_id, vol) => { + let mut guard = peer_volumes.lock().await; + guard.insert(peer_id, vol); + } + + CoreCommand::SetNoiseGateThreshold(threshold) => { + noise_gate_threshold.store(threshold.to_bits(), Ordering::Relaxed); + } + } + } +""" +text = text.replace(old_match_end, new_match_end) + +with open("src/core/mod.rs", "w") as f: + f.write(text)