Add per-peer listener-side noise gate
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled

Let a listener apply a noise gate to an individual peer's incoming
audio — "fix this person's noisy mic / background hum on my end" —
which is only possible because full-mesh P2P keeps every peer's stream
unmixed locally (server-mixed apps can't do per-listener per-peer DSP).

The DSP is the existing mic NoiseGate reused verbatim: it already
processes i16 frames at a fixed rate with hysteresis/attack/release/
hangover and takes the threshold per-frame. Wiring mirrors per-peer EQ:
- AppConfig.peer_gate map (threshold per peer id; absent/0 = off),
  persisted, never sent over the wire
- CoreCommand::SetPeerGate + Arc<Mutex<HashMap>> shared into the mixer
- a live HashMap<EndpointId, NoiseGate> in the mixer task, created
  lazily and dropped when disabled (no rebuild needed — threshold is
  passed per frame)
- Gate row (threshold slider, "Off" at zero) in each participant card
  next to Vol/Pan/EQ, persisting on release

The gate runs on the raw decoded frame: after the clean multitrack stem
tap (recordings stay ungated) but before volume/EQ, so the threshold
tracks the peer's true signal level regardless of our volume setting.
Same 0..METER_MAX scale as the mic gate.

+2 unit tests (config helper); +1 config back-compat assertion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 22:18:50 -04:00
co-authored by Claude Opus 4.8
parent c5375e200a
commit cbba4b644e
4 changed files with 117 additions and 1 deletions
+65 -1
View File
@@ -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::<EndpointId>() {
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<AppMessage> {
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<AppMessage> 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();
+7
View File
@@ -259,6 +259,11 @@ pub struct AppConfig {
/// string. Local preference only; never sent to peers. Absent entry = unity.
#[serde(default)]
pub peer_volume: HashMap<String, f32>,
/// 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<String, f32>,
/// 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
+4
View File
@@ -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),
+41
View File
@@ -869,6 +869,8 @@ async fn run_core_loop(
let peer_volumes = Arc::new(Mutex::new(HashMap::<EndpointId, f32>::new()));
let peer_eq = Arc::new(Mutex::new(HashMap::<EndpointId, EqSettings>::new()));
let peer_pan = Arc::new(Mutex::new(HashMap::<EndpointId, f32>::new()));
// Listener-side per-peer noise-gate thresholds (normalized RMS, 0.0 = off).
let peer_gate = Arc::new(Mutex::new(HashMap::<EndpointId, f32>::new()));
// Peers locally muted by us: decoded for level metering but not mixed.
let locally_muted = Arc::new(Mutex::new(HashSet::<EndpointId>::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<EndpointId, Eq> = 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<EndpointId, crate::audio::gate::NoiseGate> =
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<i16>, 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 {