diff --git a/src/app/mod.rs b/src/app/mod.rs index d2de2b1..6f693e0 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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), diff --git a/src/audio/gate.rs b/src/audio/gate.rs new file mode 100644 index 0000000..6bc85a1 --- /dev/null +++ b/src/audio/gate.rs @@ -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 { + 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"); + } +} diff --git a/src/audio/mod.rs b/src/audio/mod.rs index e5ce4ac..5ff8a30 100644 --- a/src/audio/mod.rs +++ b/src/audio/mod.rs @@ -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; diff --git a/src/core/mod.rs b/src/core/mod.rs index 79d104c..7d04c05 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -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) {