diff --git a/src/app/mod.rs b/src/app/mod.rs index 8b4b6e1..5bc2143 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -208,6 +208,7 @@ pub enum AppMessage { ClearHotkey(HotkeyAction), PeerVolumeChanged(EndpointId, f32), PeerPanChanged(EndpointId, f32), + PeerGateChanged(EndpointId, f32), PeerEqChanged(EndpointId, EqBand, f32), /// Toggle local mute of a peer (silence them just for us). TogglePeerMute(EndpointId), @@ -478,6 +479,11 @@ impl Default for AppState { let _ = controller.send(CoreCommand::SetPeerVolume(id, *volume)); } } + for (peer, threshold) in &config.peer_gate { + if let Ok(id) = peer.parse::() { + let _ = controller.send(CoreCommand::SetPeerGate(id, *threshold)); + } + } let pixelpass_available = crate::screenshare::is_available(config.pixelpass_path.as_deref()); let all_devices = enumerate_audio_devices(); @@ -761,6 +767,20 @@ fn set_peer_volume_config(config: &mut AppConfig, id: EndpointId, volume: f32) - volume } +/// Store the per-peer listener noise-gate threshold, clamped to the slider +/// range. `0.0` means the gate is off, so an at-zero entry is removed rather +/// than stored. Returns the clamped value. +fn set_peer_gate_config(config: &mut AppConfig, id: EndpointId, threshold: f32) -> f32 { + let threshold = threshold.clamp(0.0, METER_MAX); + let key = id.to_string(); + if threshold <= 0.0 { + config.peer_gate.remove(&key); + } else { + config.peer_gate.insert(key, threshold); + } + threshold +} + fn peer_eq_settings(config: &AppConfig, id: &EndpointId) -> EqSettings { config .peer_eq @@ -1062,6 +1082,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { let pan = set_peer_pan_config(&mut state.config, id, pan); let _ = state.controller.send(CoreCommand::SetPeerPan(id, pan)); } + AppMessage::PeerGateChanged(id, threshold) => { + let threshold = set_peer_gate_config(&mut state.config, id, threshold); + let _ = state.controller.send(CoreCommand::SetPeerGate(id, threshold)); + } AppMessage::PeerEqChanged(id, band, gain_db) => { let settings = set_peer_eq_config(&mut state.config, id, band, gain_db); let _ = state.controller.send(CoreCommand::SetPeerEq(id, settings)); @@ -3395,6 +3419,28 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .align_y(iced::alignment::Vertical::Center), ); + // Peer noise gate: suppress this peer's background noise on our end. + // Threshold is normalized RMS on the same 0..METER_MAX scale as the + // mic gate; 0 = off. + let current_gate = state.config.peer_gate.get(&peer_key).copied().unwrap_or(0.0); + let gate_label = if current_gate <= 0.0 { + "Off".to_string() + } else { + format!("{:.0}%", (current_gate / METER_MAX * 100.0).clamp(0.0, 100.0)) + }; + card_content = card_content.push( + row![ + text("Gate:").size(12).color(color_subtext), + container(text(gate_label).size(11).color(color_subtext)) + .width(iced::Length::Fixed(58.0)), + slider(0.0..=METER_MAX, current_gate, move |v| AppMessage::PeerGateChanged(peer_id_clone, v)) + .step(0.001) + .on_release(AppMessage::PersistConfig), + ] + .spacing(8) + .align_y(iced::alignment::Vertical::Center), + ); + let eq = peer_eq_settings(&state.config, peer_id); let eq_row = |label: &'static str, band: EqBand, value: f32| -> Element<'_, AppMessage> { row![ @@ -4924,10 +4970,28 @@ impl Program for Icon { mod tests { use super::{ format_duration, initial_window_position, reconnect_attempt_chime, reconnected_chime, - set_peer_volume_config, AppConfig, GateMeter, METER_MAX, + set_peer_gate_config, set_peer_volume_config, AppConfig, GateMeter, METER_MAX, }; use iroh::SecretKey; + #[test] + fn peer_gate_persists_when_on_and_clears_when_off() { + let mut config = AppConfig::default(); + let id = SecretKey::generate().public(); + + // A positive threshold is stored, clamped to the slider's METER_MAX ceiling. + let stored = set_peer_gate_config(&mut config, id, 0.05); + assert_eq!(stored, 0.05); + assert_eq!(config.peer_gate.get(&id.to_string()).copied(), Some(0.05)); + assert_eq!(set_peer_gate_config(&mut config, id, 99.0), METER_MAX); + + // Zero (or negative) means "gate off" — the entry is removed so the + // config doesn't carry a disabled gate. + let stored = set_peer_gate_config(&mut config, id, 0.0); + assert_eq!(stored, 0.0); + assert!(!config.peer_gate.contains_key(&id.to_string())); + } + #[test] fn peer_volume_persists_non_unity_and_clears_at_unity() { let mut config = AppConfig::default(); diff --git a/src/config.rs b/src/config.rs index 210d2b2..eb73c19 100644 --- a/src/config.rs +++ b/src/config.rs @@ -259,6 +259,11 @@ pub struct AppConfig { /// string. Local preference only; never sent to peers. Absent entry = unity. #[serde(default)] pub peer_volume: HashMap, + /// Per-peer listener-side noise-gate threshold (normalized RMS, `0.0` = off), + /// keyed by peer node id string. Local preference only; never sent to peers. + /// Absent entry = gate disabled (pass-through). + #[serde(default)] + pub peer_gate: HashMap, /// Focused app-local keyboard shortcuts. #[serde(default)] pub hotkeys: crate::hotkeys::HotkeyMap, @@ -322,6 +327,7 @@ impl Default for AppConfig { peer_eq: HashMap::new(), peer_pan: HashMap::new(), peer_volume: HashMap::new(), + peer_gate: HashMap::new(), hotkeys: crate::hotkeys::HotkeyMap::default(), window_width: default_window_width(), window_height: default_window_height(), @@ -463,6 +469,7 @@ mod tests { assert!(deserialized.peer_eq.is_empty()); assert!(deserialized.peer_pan.is_empty()); assert!(deserialized.peer_volume.is_empty()); + assert!(deserialized.peer_gate.is_empty()); assert_eq!( crate::hotkeys::format_binding( deserialized diff --git a/src/core/messages.rs b/src/core/messages.rs index 11b4b7f..134899d 100644 --- a/src/core/messages.rs +++ b/src/core/messages.rs @@ -26,6 +26,10 @@ pub enum CoreCommand { SetPeerEq(EndpointId, crate::audio::eq::EqSettings), /// Listener-side per-peer pan. Local only; never leaves this app instance. SetPeerPan(EndpointId, f32), + /// Listener-side per-peer noise gate threshold (normalized RMS, `0.0` = off). + /// Applies the same smooth gate as the mic path to a peer's incoming audio, + /// to suppress their background noise on our end. Local only. + SetPeerGate(EndpointId, f32), /// Locally mute/unmute a peer: when muted, their audio is decoded (so levels /// still show) but not mixed into our output. SetPeerMuted(EndpointId, bool), diff --git a/src/core/mod.rs b/src/core/mod.rs index 1b7d9c6..5b443b4 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -869,6 +869,8 @@ async fn run_core_loop( let peer_volumes = Arc::new(Mutex::new(HashMap::::new())); let peer_eq = Arc::new(Mutex::new(HashMap::::new())); let peer_pan = Arc::new(Mutex::new(HashMap::::new())); + // Listener-side per-peer noise-gate thresholds (normalized RMS, 0.0 = off). + let peer_gate = Arc::new(Mutex::new(HashMap::::new())); // Peers locally muted by us: decoded for level metering but not mixed. let locally_muted = Arc::new(Mutex::new(HashSet::::new())); let mut current_name = "Anonymous".to_string(); @@ -1397,6 +1399,7 @@ async fn run_core_loop( let peer_volumes_mixer = peer_volumes.clone(); let peer_eq_mixer = peer_eq.clone(); let peer_pan_mixer = peer_pan.clone(); + let peer_gate_mixer = peer_gate.clone(); let locally_muted_mixer = locally_muted.clone(); let output_gain_mixer = output_gain.clone(); let ui_tx_mixer = ui_tx.clone(); @@ -1413,6 +1416,11 @@ async fn run_core_loop( // Per-peer EQ filter state. Settings are live-cloned each // cycle; state is rebuilt only when a peer's EQ changes. let mut peer_eqs: HashMap = HashMap::new(); + // Per-peer noise-gate envelope state. The threshold is passed + // per frame (live slider), so the gate is never rebuilt — only + // created once per peer and dropped when the peer leaves. + let mut peer_noise_gates: HashMap = + HashMap::new(); // When the ring is at/above target we have nothing to do; nap // briefly and re-check. Short enough (relative to the ~60ms // target and ~21ms device quantum) that we always refill well @@ -1438,6 +1446,7 @@ async fn run_core_loop( let current_volumes = peer_volumes_mixer.lock().await.clone(); let current_eq = peer_eq_mixer.lock().await.clone(); let current_pans = peer_pan_mixer.lock().await.clone(); + let current_gates = peer_gate_mixer.lock().await.clone(); let muted_peers = locally_muted_mixer.lock().await.clone(); let mut peer_frames: Vec<(Vec, f32)> = Vec::new(); let mut peers_seen = HashSet::new(); @@ -1463,6 +1472,26 @@ async fn run_core_loop( stems.push((peer_id, frame.clone())); } + // Listener-side per-peer noise gate, applied to the + // raw decoded frame (after the clean stem tap, before + // volume/EQ) so the threshold tracks the peer's true + // signal level regardless of our volume setting. The + // gate's "should transmit" return is irrelevant here — + // we only attenuate. Threshold 0 = off; the gate is + // created lazily and dropped when disabled. + let gate_threshold = + current_gates.get(&peer_id).copied().unwrap_or(0.0); + if gate_threshold > 0.0 { + peer_noise_gates + .entry(peer_id) + .or_insert_with(|| { + crate::audio::gate::NoiseGate::new(48_000) + }) + .process(&mut frame, gate_threshold); + } else { + peer_noise_gates.remove(&peer_id); + } + let vol = current_volumes.get(&peer_id).copied().unwrap_or(1.0); apply_volume(&mut frame, vol); @@ -1507,6 +1536,8 @@ async fn run_core_loop( } } peer_eqs.retain(|id, _| peers_seen.contains(id) || current_eq.contains_key(id)); + peer_noise_gates + .retain(|id, _| peers_seen.contains(id) || current_gates.contains_key(id)); // Lossless i32 sum, then the limiter applies the master // output gain (in f32, so a boost past the ceiling is @@ -1884,6 +1915,16 @@ async fn run_core_loop( } } + CoreCommand::SetPeerGate(peer_id, threshold) => { + let threshold = threshold.clamp(0.0, 1.0); + let mut guard = peer_gate.lock().await; + if threshold <= 0.0 { + guard.remove(&peer_id); + } else { + guard.insert(peer_id, threshold); + } + } + CoreCommand::SetPeerMuted(peer_id, muted) => { let mut guard = locally_muted.lock().await; if muted {