Merge branch 'test/mic-meter-regression': mic meter + gate drag unit tests
This commit is contained in:
+33
-1
@@ -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
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user