From ca28c564438646fb1f5cc6587a087c4450789758 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Tue, 2 Jun 2026 15:50:43 -0400 Subject: [PATCH] test(audio): regression tests for mic meter + gate drag Extracts the peak-hold/throttle logic shared by the in-call capture thread and run_mic_monitor into MicLevelMeter, and adds unit coverage: - core: MicLevelMeter reports only after a full window, holds the window peak, resets between windows, and reports zero for silence. - app: GateMeter::x_to_threshold maps edges/midpoint correctly, clamps out-of-bounds drags, and stays finite for a zero-width (pre-layout) bar. 9 new tests, all green; clippy clean. Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 34 +++++++++++++++++- src/core/mod.rs | 96 ++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 113 insertions(+), 17 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index dcbe1da..257a5bf 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1150,11 +1150,43 @@ impl Program for GateMeter { #[cfg(test)] mod tests { - use super::{reconnect_attempt_chime, reconnected_chime}; + use super::{reconnect_attempt_chime, reconnected_chime, GateMeter, METER_MAX}; use crate::notify::Sound; use iroh::EndpointId; use std::collections::HashSet; + const W: f32 = 200.0; + + #[test] + fn gate_drag_maps_left_edge_to_zero() { + assert_eq!(GateMeter::x_to_threshold(0.0, W), 0.0); + } + + #[test] + fn gate_drag_maps_right_edge_to_full_scale() { + assert!((GateMeter::x_to_threshold(W, W) - METER_MAX).abs() < 1e-6); + } + + #[test] + fn gate_drag_maps_midpoint_to_half_scale() { + assert!((GateMeter::x_to_threshold(W / 2.0, W) - METER_MAX / 2.0).abs() < 1e-6); + } + + #[test] + fn gate_drag_clamps_out_of_bounds() { + // Dragging past either edge clamps to the axis ends (no overshoot). + assert_eq!(GateMeter::x_to_threshold(-50.0, W), 0.0); + assert!((GateMeter::x_to_threshold(W + 80.0, W) - METER_MAX).abs() < 1e-6); + } + + #[test] + fn gate_drag_zero_width_is_finite() { + // A degenerate bound (pre-layout) must not divide by zero / produce NaN. + let t = GateMeter::x_to_threshold(10.0, 0.0); + assert!(t.is_finite()); + assert!((0.0..=METER_MAX).contains(&t)); + } + /// A distinct, real `EndpointId` (via the same path the network tests use). fn id() -> EndpointId { iroh::EndpointAddr::from(iroh::SecretKey::generate().public()).id diff --git a/src/core/mod.rs b/src/core/mod.rs index ccdc844..d41898d 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -135,6 +135,36 @@ fn frame_level(frame: &[i16]) -> f32 { /// [`UiEvent::MicLevel`], so the meter doesn't flood the UI runtime at frame rate. const MIC_LEVEL_REPORT_SAMPLES: usize = 4800; +/// Peak-holds the raw mic level across captured frames and yields a value to +/// report roughly every [`MIC_LEVEL_REPORT_SAMPLES`] samples. Shared by the +/// in-call capture thread and the standalone monitor so both throttle and +/// peak-hold identically. +struct MicLevelMeter { + peak: f32, + acc: usize, +} + +impl MicLevelMeter { + fn new() -> Self { + Self { peak: 0.0, acc: 0 } + } + + /// Folds one frame into the running peak. Returns `Some(peak)` (and resets) + /// once enough samples have accumulated to emit a reading, else `None`. + fn push(&mut self, frame: &[i16]) -> Option { + self.peak = self.peak.max(frame_level(frame)); + self.acc += frame.len(); + if self.acc >= MIC_LEVEL_REPORT_SAMPLES { + let peak = self.peak; + self.peak = 0.0; + self.acc = 0; + Some(peak) + } else { + None + } + } +} + /// A standalone, capture-only mic monitor for gate calibration outside a call. /// Owns the worker thread that reads raw PCM and reports its level; the PipeWire /// capture stream itself lives in the shared backend. Tear down by stopping the @@ -146,16 +176,11 @@ struct MicMonitor { /// Drains a capture channel, reporting the raw (un-gated) mic level to the UI. /// Returns when the channel closes (i.e. the backend's capture stream stopped). fn run_mic_monitor(rx: std::sync::mpsc::Receiver>, ui_tx: mpsc::Sender) { - let mut peak = 0.0f32; - let mut acc = 0usize; + let mut meter = MicLevelMeter::new(); while let Ok(pcm) = rx.recv() { - peak = peak.max(frame_level(&pcm)); - acc += pcm.len(); - if acc >= MIC_LEVEL_REPORT_SAMPLES { + if let Some(peak) = meter.push(&pcm) { // Drop on a full channel — a stale meter reading is harmless. let _ = ui_tx.try_send(UiEvent::MicLevel(peak)); - peak = 0.0; - acc = 0; } } // Channel closed: the monitor was stopped. Snap the meter back to zero. @@ -537,16 +562,11 @@ async fn run_core_loop( let mut gate = crate::audio::gate::NoiseGate::new(48000); // Peak-held raw mic level for the settings meter, reported // pre-gate/pre-mute so calibration reflects the true input. - let mut mic_peak = 0.0f32; - let mut mic_acc = 0usize; + let mut mic_meter = MicLevelMeter::new(); while let Ok(mut pcm) = capture_rx.recv() { - mic_peak = mic_peak.max(frame_level(&pcm)); - mic_acc += pcm.len(); - if mic_acc >= MIC_LEVEL_REPORT_SAMPLES { - let _ = ui_tx_capture.try_send(UiEvent::MicLevel(mic_peak)); - mic_peak = 0.0; - mic_acc = 0; + if let Some(peak) = mic_meter.push(&pcm) { + let _ = ui_tx_capture.try_send(UiEvent::MicLevel(peak)); } if is_muted_clone.load(Ordering::Relaxed) { @@ -889,7 +909,51 @@ async fn run_core_loop( #[cfg(test)] mod tests { - use super::{apply_volume, frame_level, mix_frames}; + use super::{apply_volume, frame_level, mix_frames, MicLevelMeter, MIC_LEVEL_REPORT_SAMPLES}; + + /// A frame of constant amplitude with the given sample count. + fn frame(amp: i16, len: usize) -> Vec { + vec![amp; len] + } + + #[test] + fn mic_meter_reports_only_after_enough_samples() { + let mut m = MicLevelMeter::new(); + // One short frame well under the report window yields nothing yet. + assert_eq!(m.push(&frame(1000, 480)), None); + // A frame that crosses the window boundary triggers a report. + assert!(m.push(&frame(1000, MIC_LEVEL_REPORT_SAMPLES)).is_some()); + } + + #[test] + fn mic_meter_holds_the_peak_across_the_window() { + let mut m = MicLevelMeter::new(); + let chunk = MIC_LEVEL_REPORT_SAMPLES / 4; + // Loud frame first, then quiet ones — the reported value is the loud peak. + assert_eq!(m.push(&frame(8000, chunk)), None); + assert_eq!(m.push(&frame(100, chunk)), None); + assert_eq!(m.push(&frame(100, chunk)), None); + let reported = m.push(&frame(100, chunk)).expect("window complete"); + let loud = frame_level(&frame(8000, chunk)); + assert!((reported - loud).abs() < 1e-6, "peak should hold the loud frame"); + } + + #[test] + fn mic_meter_resets_after_reporting() { + let mut m = MicLevelMeter::new(); + // Fill and report a loud window. + assert!(m.push(&frame(8000, MIC_LEVEL_REPORT_SAMPLES)).is_some()); + // The next window of silence must report ~zero, not the stale loud peak. + let reported = m.push(&frame(0, MIC_LEVEL_REPORT_SAMPLES)).expect("second window"); + assert_eq!(reported, 0.0, "peak and accumulator reset between windows"); + } + + #[test] + fn mic_meter_silence_reports_zero() { + let mut m = MicLevelMeter::new(); + let reported = m.push(&frame(0, MIC_LEVEL_REPORT_SAMPLES)).expect("window complete"); + assert_eq!(reported, 0.0); + } #[test] fn mix_of_no_peers_is_silence() {