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>
339 lines
12 KiB
Rust
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);
|
|
}
|
|
}
|