//! 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 { 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); } }