Merge branch 'test/mic-meter-regression': mic meter + gate drag unit tests

This commit is contained in:
2026-06-02 15:50:43 -04:00
2 changed files with 113 additions and 17 deletions
+33 -1
View File
@@ -1150,11 +1150,43 @@ impl Program<AppMessage> 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
+80 -16
View File
@@ -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<f32> {
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<Vec<i16>>, ui_tx: mpsc::Sender<UiEvent>) {
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<i16> {
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() {