feat: smooth noise gate for mic sensitivity

Replace the per-frame hard-cut noise gate with a stateful envelope gate
(src/audio/gate.rs):

- Hysteresis: opens at the slider threshold, closes only below 0.6x that,
  so speech near the threshold doesn't flap the gate.
- Attack/release: per-sample gain ramp (5ms open, 80ms close) instead of a
  click — the gate fades rather than dropping frames outright.
- Hangover: holds the gate open 200ms after the level drops, so word tails
  and brief pauses aren't chopped.

A fully-closed frame still reports don't-transmit, preserving the original
bandwidth win of not sending pure silence (receiver jitter buffer conceals
the gap). The live slider value is read per frame so changes apply
immediately. Settings slider gains a one-line hint. 6 unit tests cover
attack shape, hysteresis, hangover-then-release, disabled pass-through, and
the closed-gate no-transmit path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 16:35:41 -04:00
co-authored by Claude Opus 4.8
parent ead3031f3c
commit a7ad07d1f5
4 changed files with 243 additions and 12 deletions
+2 -1
View File
@@ -513,7 +513,8 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
row![
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).step(0.001)
slider(0.0..=0.1, state.config.noise_gate_threshold, AppMessage::NoiseGateChanged).step(0.001),
text("Smoothly fades out audio below this level. 0% disables the gate.").size(11).color(color_subtext),
].spacing(8).width(iced::Length::Fill),
column![
text("Network Privacy").size(14).color(color_subtext),
+230
View File
@@ -0,0 +1,230 @@
//! A smooth, stateful noise gate for the capture path.
//!
//! The original gate was a per-frame hard cut: compute the frame's RMS and, if it
//! fell below the threshold, drop the whole 20ms frame. That chops word onsets and
//! tails, chatters when the level sits right at the threshold, and offers no partial
//! attenuation. This replaces it with a proper gate envelope:
//!
//! - **Hysteresis** — the gate opens at `open_threshold` but only closes once the
//! level falls below a lower `close_threshold` (a fixed ratio of the open one), so
//! speech hovering near the threshold doesn't flap the gate on and off.
//! - **Attack / release** — when opening, the gain ramps 0→1 over a few ms; when
//! closing, it ramps 1→0 over a longer window. The ramp is applied per sample, so
//! the gate fades rather than clicking.
//! - **Hangover (hold)** — after the level drops, the gate stays fully open for a
//! hold window before it begins to release, so brief inter-word pauses and quiet
//! word tails survive instead of being clipped.
//!
//! A fully-closed frame (gain pinned at 0 with nothing to release) is reported as
//! "don't transmit" so the gate keeps the original bandwidth win of not sending
//! pure silence — the receiver's jitter buffer conceals the gap.
/// Gate timing/shape constants, in milliseconds. Tuned for voice.
const ATTACK_MS: f32 = 5.0;
const RELEASE_MS: f32 = 80.0;
const HOLD_MS: f32 = 200.0;
/// The close threshold as a fraction of the open threshold (hysteresis).
const CLOSE_RATIO: f32 = 0.6;
/// Below this open threshold the gate is considered disabled (pass-through).
const DISABLED_EPSILON: f32 = 0.0001;
/// A smooth noise gate. One instance lives in the capture thread and processes
/// each PCM frame in place, carrying its envelope state across frames.
pub struct NoiseGate {
/// Per-sample gain increment while opening (1.0 / attack_samples).
attack_step: f32,
/// Per-sample gain decrement while releasing (1.0 / release_samples).
release_step: f32,
/// How long (in samples) to hold the gate open after the level drops.
hold_samples: u32,
/// Current envelope gain in `0.0..=1.0`, carried across frames.
gain: f32,
/// Whether the gate currently considers the signal "present".
open: bool,
/// Remaining hold (in samples) before an open gate begins to release.
hold_counter: u32,
}
impl NoiseGate {
/// Builds a gate for the given sample rate (Hz). Thresholds are passed per
/// frame to [`process`](Self::process) so the live slider value applies
/// immediately without rebuilding the gate.
pub fn new(sample_rate: u32) -> Self {
let sr = sample_rate as f32;
let attack_samples = (ATTACK_MS / 1000.0 * sr).max(1.0);
let release_samples = (RELEASE_MS / 1000.0 * sr).max(1.0);
Self {
attack_step: 1.0 / attack_samples,
release_step: 1.0 / release_samples,
hold_samples: (HOLD_MS / 1000.0 * sr) as u32,
gain: 0.0,
open: false,
hold_counter: 0,
}
}
/// Applies the gate to one PCM frame in place. `open_threshold` is the live
/// slider value (normalized RMS, `0.0..`); pass `<= DISABLED_EPSILON` to
/// disable gating (pass-through). Returns `true` if the frame should be
/// transmitted, `false` only when the gate is fully closed (so the caller can
/// skip sending pure silence).
pub fn process(&mut self, pcm: &mut [i16], open_threshold: f32) -> bool {
// Disabled: pass through untouched, and make sure the envelope is parked
// open so re-enabling mid-stream doesn't start with a spurious fade-in.
if open_threshold <= DISABLED_EPSILON {
self.gain = 1.0;
self.open = true;
self.hold_counter = self.hold_samples;
return true;
}
if pcm.is_empty() {
return self.open || self.gain > 0.0;
}
let close_threshold = open_threshold * CLOSE_RATIO;
let rms = frame_rms(pcm);
// Update open/closed state with hysteresis + hold. Detection is per frame;
// the gain ramp below is per sample.
if rms >= open_threshold {
self.open = true;
self.hold_counter = self.hold_samples;
} else if self.open {
if rms >= close_threshold {
// Still above the close threshold — refresh the hold window.
self.hold_counter = self.hold_samples;
} else {
// Below close: spend the hold window, then begin releasing.
self.hold_counter = self.hold_counter.saturating_sub(pcm.len() as u32);
if self.hold_counter == 0 {
self.open = false;
}
}
}
let target = if self.open { 1.0 } else { 0.0 };
// Per-sample gain ramp toward the target, applied to the frame.
for sample in pcm.iter_mut() {
if self.gain < target {
self.gain = (self.gain + self.attack_step).min(target);
} else if self.gain > target {
self.gain = (self.gain - self.release_step).max(target);
}
*sample = (*sample as f32 * self.gain).round() as i16;
}
// Transmit unless the gate is fully closed with nothing left to release.
self.open || self.gain > 0.0
}
}
/// RMS of a PCM frame, normalized to `0.0..=1.0` (full-scale i16 == 1.0).
fn frame_rms(pcm: &[i16]) -> f32 {
if pcm.is_empty() {
return 0.0;
}
let mut sum_sq = 0.0f32;
for &s in pcm {
let n = s as f32 / 32768.0;
sum_sq += n * n;
}
(sum_sq / pcm.len() as f32).sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
const SR: u32 = 48000;
const FRAME: usize = 960; // 20ms @ 48kHz mono
/// A frame of constant amplitude (a crude tone) at the given i16 level.
fn frame(amp: i16) -> Vec<i16> {
vec![amp; FRAME]
}
/// Peak absolute sample in a frame — a proxy for "how open" the gate was.
fn peak(pcm: &[i16]) -> i16 {
pcm.iter().copied().map(|s| s.abs()).max().unwrap_or(0)
}
#[test]
fn disabled_threshold_passes_through_untouched() {
let mut g = NoiseGate::new(SR);
let mut f = frame(5000);
let original = f.clone();
assert!(g.process(&mut f, 0.0));
assert_eq!(f, original, "a disabled gate must not alter samples");
}
#[test]
fn loud_signal_opens_and_reaches_full_gain() {
let mut g = NoiseGate::new(SR);
// amp 10000 -> rms ~0.305, well above a 0.05 threshold.
// After a couple of frames the attack ramp should be complete.
let mut last = 0;
for _ in 0..3 {
let mut f = frame(10000);
assert!(g.process(&mut f, 0.05), "loud frame must transmit");
last = peak(&f);
}
assert!(last >= 9900, "gain should reach ~1.0 on sustained loud input, got peak {last}");
}
#[test]
fn attack_is_gradual_not_a_hard_jump() {
let mut g = NoiseGate::new(SR);
let mut f = frame(10000);
g.process(&mut f, 0.05);
// 5ms attack @48k = 240 samples; across a 960-sample frame the gain ramps
// 0->1, so the early samples are well below full scale (no instant click).
assert!(f[0].abs() < 5000, "attack should start near zero, got {}", f[0]);
assert!(f[FRAME - 1].abs() > 9000, "attack should complete within the frame");
}
#[test]
fn quiet_after_loud_is_held_open_then_released() {
let mut g = NoiseGate::new(SR);
// Open it.
for _ in 0..3 {
let mut f = frame(10000);
g.process(&mut f, 0.05);
}
// First quiet frame right after speech: hold keeps it open (not chopped).
let mut q = frame(50); // rms ~0.0015, below close (0.03)
assert!(g.process(&mut q, 0.05), "first quiet frame must stay open (hangover)");
assert!(peak(&q) > 0, "held-open frame must not be silenced immediately");
// Hold is 200ms = 10 frames; keep feeding quiet until it fully closes.
let mut closed = false;
for _ in 0..40 {
let mut q = frame(0);
if !g.process(&mut q, 0.05) {
closed = true;
break;
}
}
assert!(closed, "gate must eventually close and stop transmitting after sustained silence");
}
#[test]
fn hysteresis_keeps_gate_open_between_thresholds() {
let mut g = NoiseGate::new(SR);
// Open with a loud frame.
let mut f = frame(10000);
g.process(&mut f, 0.05); // open=0.05, close=0.03
// A frame between close and open thresholds: rms ~0.04 (amp ~1310).
let mut mid = frame(1310);
assert!(g.process(&mut mid, 0.05), "between-threshold frame must keep an open gate open");
assert!(g.open, "hysteresis: gate stays open above the close threshold");
}
#[test]
fn closed_gate_does_not_transmit_silence() {
let mut g = NoiseGate::new(SR);
// Never opened; feed silence — should report don't-transmit promptly.
let mut f = frame(0);
assert!(!g.process(&mut f, 0.05), "an unopened gate on silence must not transmit");
}
}
+1
View File
@@ -51,5 +51,6 @@ pub trait AudioBackend: Send + Sync {
fn stop(&self) -> Result<(), AudioError>;
}
pub mod gate;
pub mod pipewire_impl;
pub mod pw_cli;
+10 -11
View File
@@ -403,8 +403,12 @@ async fn run_core_loop(
// Per-sender packet sequence number, prepended to every frame so
// receivers can reorder and conceal loss. Wraps after ~years.
let mut seq: u32 = 0;
// Smooth noise gate (hysteresis + attack/release + hangover),
// carrying envelope state across frames. The live slider value
// is read per frame so changes apply immediately.
let mut gate = crate::audio::gate::NoiseGate::new(48000);
while let Ok(pcm) = capture_rx.recv() {
while let Ok(mut pcm) = capture_rx.recv() {
if is_muted_clone.load(Ordering::Relaxed) {
continue;
}
@@ -414,16 +418,11 @@ async fn run_core_loop(
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;
}
// Apply the gate in place; skip transmitting a fully-closed
// frame so we don't send pure silence (the receiver's jitter
// buffer conceals the gap).
if !gate.process(&mut pcm, ng_thresh) {
continue;
}
if let Ok(encoded) = encoder.encode(&pcm) {