Add per-peer listener-side noise gate
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:
+65
-1
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user