Files
peerspeak/src/audio/gate.rs
T
molluskandClaude Opus 4.8 fa951e570f test(audio): edge-case unit tests for the noise gate
Covers the previously-untested branches of the NoiseGate envelope/timing:
frame_rms known values, empty-frame transmit-follows-state, disabled gate
parks the envelope open (no fade-in on re-enable), hold-window-then-release
ordering, sustained mid-level refreshes the hold, and a loud signal
re-opening a releasing gate. Gate tests 6 -> 12; test-only, no prod change.

Implemented by Gemini per next-task.md; reviewed against the real diff and
re-verified (build + clippy --all-targets + test all green) by the senior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 16:00:15 -04:00

339 lines
12 KiB
Rust

//! 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");
}
#[test]
fn frame_rms_known_values() {
assert_eq!(frame_rms(&[]), 0.0);
assert_eq!(frame_rms(&frame(0)), 0.0);
let f_high = frame(16384);
let rms_high = frame_rms(&f_high);
assert!((rms_high - 0.5).abs() < 1e-4, "rms_high was {}", rms_high);
let f_low = frame(3277);
let rms_low = frame_rms(&f_low);
assert!((rms_low - 0.1).abs() < 1e-3, "rms_low was {}", rms_low);
}
#[test]
fn empty_frame_transmit_follows_gate_state() {
let mut g = NoiseGate::new(SR);
// fresh gate (never opened)
assert!(!g.process(&mut [], 0.05));
// open it with loud signals
for _ in 0..3 {
let mut f = frame(10000);
assert!(g.process(&mut f, 0.05));
}
// now empty frame should transmit
assert!(g.process(&mut [], 0.05));
}
#[test]
fn disabled_parks_envelope_so_reenable_has_no_fade_in() {
let mut g = NoiseGate::new(SR);
let mut f1 = frame(5000);
assert!(g.process(&mut f1, 0.0)); // disabled
let mut f2 = frame(10000);
assert!(g.process(&mut f2, 0.05)); // enabled
assert!(f2[0].abs() > 9000, "expected first sample of enabled frame to have no fade-in, got {}", f2[0]);
}
#[test]
fn hold_keeps_open_through_window_then_releases_to_closed() {
let mut g = NoiseGate::new(SR);
// open it
for _ in 0..3 {
let mut f = frame(10000);
g.process(&mut f, 0.05);
}
let mut results = Vec::new();
for _ in 0..25 {
let mut f = frame(0);
results.push(g.process(&mut f, 0.05));
}
assert!(results[4], "should still transmit at the 5th silent frame");
assert!(!results[19], "should not transmit at the 20th silent frame");
}
#[test]
fn sustained_mid_level_refreshes_hold_and_stays_open() {
let mut g = NoiseGate::new(SR);
// open it loud
for _ in 0..3 {
let mut f = frame(10000);
g.process(&mut f, 0.05);
}
// feed 30x frame(1310) (rms ~0.04, between close 0.03 and open 0.05)
for _ in 0..30 {
let mut f = frame(1310);
assert!(g.process(&mut f, 0.05));
}
assert!(g.open, "gate must stay open (hold refreshed by mid-level input)");
}
#[test]
fn loud_signal_reopens_a_releasing_gate() {
let mut g = NoiseGate::new(SR);
// open it
for _ in 0..3 {
let mut f = frame(10000);
g.process(&mut f, 0.05);
}
// feed silent frames to fully close
let mut closed = false;
for _ in 0..40 {
let mut f = frame(0);
if !g.process(&mut f, 0.05) {
closed = true;
break;
}
}
assert!(closed);
// feed frame(10000) @ 0.05 a few times
let mut last_peak = 0;
for _ in 0..3 {
let mut f = frame(10000);
assert!(g.process(&mut f, 0.05));
last_peak = peak(&f);
}
assert!(g.open);
assert!(last_peak >= 9900, "peak of the 3rd reopened frame must be >= 9900, got {}", last_peak);
}
}