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:
+2
-1
@@ -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 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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user