Harden audio meter regression coverage

This commit is contained in:
Mikei386 committed 2026-07-21 21:36:26 +02:00
1 parent 9b5250d42b
commit a123539023
9 files changed
+370 -121

No files matched your search

+62 -108
View File
@@ -27,6 +27,10 @@ use crate::rta::{
StereoCascade, RTW_THIRD_OCTAVE_CENTERS,
};
use crate::state::NativeWavRecorder;
#[cfg(target_os = "linux")]
use crate::true_peak::TruePeakDetector;
#[cfg(target_os = "linux")]
use crate::vu::VuMeter;
use crate::{
config::PhoenixConfig,
model::{InputSource, MeterFrame, PhoenixRtaConfig},
@@ -61,12 +65,6 @@ const BOX_MAX_DB: f32 = 9.0;
#[cfg(target_os = "linux")]
const VU_WINDOW_MS: f32 = 300.0;
#[cfg(target_os = "linux")]
const VU_RECT_TO_RMS_GAIN: f32 = std::f32::consts::PI / (2.0 * std::f32::consts::SQRT_2);
#[cfg(target_os = "linux")]
const TRUE_PEAK_OVERSAMPLE: usize = 4;
#[cfg(target_os = "linux")]
const TRUE_PEAK_INTERP_RADIUS: usize = 8;
#[cfg(target_os = "linux")]
const NATIVE_RECORDER_DISCONTINUITY_BLEND_FRAMES: usize = 256;
#[cfg(target_os = "linux")]
const RTW_CENTERS_1_6: &[f32] = &[
@@ -223,7 +221,6 @@ struct LoudnessBiquadState {
y2: f32,
}
#[cfg(target_os = "linux")]
struct MovingAverageWindow {
window_len: usize,
ring_l: Vec<f32>,
@@ -460,7 +457,8 @@ fn capture_until_restart(deps: AudioWorkerDeps, generation: u64) -> anyhow::Resu
struct PpmState {
din_ppm: PpmDetector,
ebu_ppm: PpmDetector,
vu_window: MovingAverageWindow,
vu_meter: VuMeter,
rms_window: MovingAverageWindow,
last_rta: Option<RtaFrame>,
last_spectro: Option<SpectroFrame>,
rta_signature: String,
@@ -478,8 +476,8 @@ struct PpmState {
lr_delay_y1_l: f32,
lr_delay_x1_r: f32,
lr_delay_y1_r: f32,
tp_history_l: Vec<f32>,
tp_history_r: Vec<f32>,
true_peak_l: TruePeakDetector,
true_peak_r: TruePeakDetector,
correlation: CorrelationMeter,
xy_pending_l: Vec<f32>,
xy_pending_r: Vec<f32>,
@@ -492,7 +490,8 @@ impl Default for PpmState {
Self {
din_ppm: PpmDetector::new(48_000, PpmStandard::Din),
ebu_ppm: PpmDetector::new(48_000, PpmStandard::EbuTypeIib),
vu_window: create_moving_average_window(48_000, VU_WINDOW_MS),
vu_meter: VuMeter::new(48_000),
rms_window: create_moving_average_window(48_000, VU_WINDOW_MS),
last_rta: None,
last_spectro: None,
rta_signature: String::new(),
@@ -510,8 +509,8 @@ impl Default for PpmState {
lr_delay_y1_l: 0.0,
lr_delay_x1_r: 0.0,
lr_delay_y1_r: 0.0,
tp_history_l: Vec::new(),
tp_history_r: Vec::new(),
true_peak_l: TruePeakDetector::default(),
true_peak_r: TruePeakDetector::default(),
correlation: CorrelationMeter::new(48_000, 1.0, 0),
xy_pending_l: Vec::with_capacity(1024),
xy_pending_r: Vec::with_capacity(1024),
@@ -662,7 +661,6 @@ fn wave_env_flush(state: &mut WaveEnvState) -> Option<WaveEnvFrame> {
})
}
#[cfg(target_os = "linux")]
fn create_moving_average_window(sample_rate: u32, window_ms: f32) -> MovingAverageWindow {
let sr = sample_rate.max(8_000) as f32;
let window_len = ((window_ms.max(0.1) / 1000.0) * sr).round().max(1.0) as usize;
@@ -677,7 +675,7 @@ fn create_moving_average_window(sample_rate: u32, window_ms: f32) -> MovingAvera
}
}
#[cfg(target_os = "linux")]
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn ensure_moving_average_window(state: &mut MovingAverageWindow, sample_rate: u32, window_ms: f32) {
let desired_len = ((window_ms.max(0.1) / 1000.0) * sample_rate.max(8_000) as f32)
.round()
@@ -688,7 +686,6 @@ fn ensure_moving_average_window(state: &mut MovingAverageWindow, sample_rate: u3
*state = create_moving_average_window(sample_rate, window_ms);
}
#[cfg(target_os = "linux")]
fn moving_average_push(state: &mut MovingAverageWindow, l: f32, r: f32) -> (f32, f32) {
if state.fill < state.window_len {
state.fill += 1;
@@ -1047,84 +1044,6 @@ fn update_box_meter(state: &mut LufsState) {
state.ppm_box_r = clamp_box_db(loudness_from_power(p_box_r));
}
#[cfg(target_os = "linux")]
fn sinc(x: f32) -> f32 {
if x.abs() < 1.0e-6 {
1.0
} else {
let pix = std::f32::consts::PI * x;
pix.sin() / pix
}
}
#[cfg(target_os = "linux")]
fn blackman_window(x: f32, radius: usize) -> f32 {
let span = (radius * 2) as f32;
if span <= 0.0 {
return 1.0;
}
let n = x + radius as f32;
let phase = 2.0 * std::f32::consts::PI * n / span;
0.42 - 0.5 * phase.cos() + 0.08 * (2.0 * phase).cos()
}
#[cfg(target_os = "linux")]
fn interpolate_true_peak_sample(samples: &[f32], pos: f32) -> f32 {
let radius = TRUE_PEAK_INTERP_RADIUS as isize;
let base = pos.floor() as isize;
let mut sum = 0.0f32;
let mut norm = 0.0f32;
for n in (base - radius + 1)..=(base + radius) {
if n < 0 || n >= samples.len() as isize {
continue;
}
let x = pos - n as f32;
let w = sinc(x) * blackman_window(x, TRUE_PEAK_INTERP_RADIUS);
sum += samples[n as usize] * w;
norm += w;
}
if norm.abs() > 1.0e-6 {
sum / norm
} else {
0.0
}
}
#[cfg(target_os = "linux")]
fn estimate_true_peak(history: &mut Vec<f32>, block: &[f32]) -> f32 {
let radius = TRUE_PEAK_INTERP_RADIUS;
let hist_len = history.len();
let mut peak = block
.iter()
.copied()
.fold(0.0f32, |acc, v| acc.max(v.abs()));
if block.is_empty() {
return peak;
}
let mut samples = Vec::with_capacity(hist_len + block.len());
samples.extend_from_slice(history);
samples.extend_from_slice(block);
if hist_len >= radius && samples.len() > radius + 1 {
let start_interval = hist_len.saturating_sub(1);
let end_interval = samples.len().saturating_sub(radius + 1);
for i in start_interval..end_interval {
for phase in 1..TRUE_PEAK_OVERSAMPLE {
let pos = i as f32 + (phase as f32 / TRUE_PEAK_OVERSAMPLE as f32);
peak = peak.max(interpolate_true_peak_sample(&samples, pos).abs());
}
}
}
if samples.len() > radius {
let keep_from = samples.len() - radius;
history.clear();
history.extend_from_slice(&samples[keep_from..]);
} else {
history.clear();
history.extend_from_slice(&samples);
}
peak
}
#[cfg(target_os = "linux")]
fn take_goniometer_samples(state: &mut PpmState, target_points: usize) -> (Vec<f32>, Vec<f32>) {
let available = state.xy_pending_l.len().min(state.xy_pending_r.len());
@@ -1160,10 +1079,14 @@ fn build_meter_frame(
ppm_state: &mut PpmState,
rta_config: &PhoenixRtaConfig,
) -> MeterFrame {
let mut sum_sq_l = 0.0f64;
let mut sum_sq_r = 0.0f64;
let mut rms_power_l = 0.0f32;
let mut rms_power_r = 0.0f32;
let mut peak_l = 0.0f32;
let mut peak_r = 0.0f32;
let mut true_peak_l = 0.0f32;
let mut true_peak_r = 0.0f32;
let mut vu_l_amp = 0.0f32;
let mut vu_r_amp = 0.0f32;
let frames = interleaved.len() / 2;
let mut wave_l = Vec::with_capacity(frames);
let mut wave_r = Vec::with_capacity(frames);
@@ -1179,7 +1102,8 @@ fn build_meter_frame(
ppm_state
.ebu_ppm
.ensure_profile(sample_rate, PpmStandard::EbuTypeIib);
ensure_moving_average_window(&mut ppm_state.vu_window, sample_rate, VU_WINDOW_MS);
ppm_state.vu_meter.ensure_sample_rate(sample_rate);
ensure_moving_average_window(&mut ppm_state.rms_window, sample_rate, VU_WINDOW_MS);
let gain_l = db_gain(configured_input_offset_db(rta_config.input_offset_db_l));
let gain_r = db_gain(configured_input_offset_db(rta_config.input_offset_db_r));
@@ -1207,15 +1131,16 @@ fn build_meter_frame(
wave_env_accumulate(&mut ppm_state.wave_env, l, r, 2);
process_lufs_sample(&mut ppm_state.lufs, l, r, rta_config);
sum_sq_l += f64::from(l * l);
sum_sq_r += f64::from(r * r);
(rms_power_l, rms_power_r) = moving_average_push(&mut ppm_state.rms_window, l * l, r * r);
let abs_l = l.abs();
let abs_r = r.abs();
ppm_state.din_ppm.process(l, r);
ppm_state.ebu_ppm.process(l, r);
let _ = moving_average_push(&mut ppm_state.vu_window, abs_l, abs_r);
(vu_l_amp, vu_r_amp) = ppm_state.vu_meter.process(l, r);
peak_l = peak_l.max(abs_l);
peak_r = peak_r.max(abs_r);
true_peak_l = true_peak_l.max(ppm_state.true_peak_l.process(l));
true_peak_r = true_peak_r.max(ppm_state.true_peak_r.process(r));
ppm_state.correlation.process(l, r);
ppm_state.xy_pending_l.push(l);
@@ -1254,15 +1179,13 @@ fn build_meter_frame(
}
}
let rms_l = dbfs((sum_sq_l / frames.max(1) as f64).sqrt() as f32);
let rms_r = dbfs((sum_sq_r / frames.max(1) as f64).sqrt() as f32);
let vu_rect_l = (ppm_state.vu_window.sum_l / ppm_state.vu_window.fill.max(1) as f64) as f32;
let vu_rect_r = (ppm_state.vu_window.sum_r / ppm_state.vu_window.fill.max(1) as f64) as f32;
let vu_l = dbfs(vu_rect_l * VU_RECT_TO_RMS_GAIN);
let vu_r = dbfs(vu_rect_r * VU_RECT_TO_RMS_GAIN);
let rms_l = dbfs(rms_power_l.max(0.0).sqrt());
let rms_r = dbfs(rms_power_r.max(0.0).sqrt());
let vu_l = dbfs(vu_l_amp);
let vu_r = dbfs(vu_r_amp);
update_box_meter(&mut ppm_state.lufs);
let tp_l = dbfs(estimate_true_peak(&mut ppm_state.tp_history_l, &wave_l).max(peak_l));
let tp_r = dbfs(estimate_true_peak(&mut ppm_state.tp_history_r, &wave_r).max(peak_r));
let tp_l = dbfs(true_peak_l.max(peak_l));
let tp_r = dbfs(true_peak_r.max(peak_r));
let (ppm_din_amp_l, ppm_din_amp_r) = ppm_state.din_ppm.levels();
let (ppm_ebu_amp_l, ppm_ebu_amp_r) = ppm_state.ebu_ppm.levels();
let ppm_din_l = dbfs(ppm_din_amp_l);
@@ -2198,3 +2121,34 @@ fn band_weighting_gain(f_lo: f32, center: f32, f_hi: f32, mode: &str) -> f32 {
}
sum / sample_points.len() as f32
}
#[cfg(test)]
mod tests {
use super::*;
fn run_rms(period: usize) -> f32 {
let sample_rate = 48_000;
let mut window = create_moving_average_window(sample_rate, 300.0);
let mut result = 0.0;
let samples: Vec<f32> = (0..sample_rate as usize)
.map(|index| {
(2.0 * std::f32::consts::PI * 1_000.0 * index as f32 / sample_rate as f32).sin()
})
.collect();
for block in samples.chunks(period) {
for &sample in block {
result = moving_average_push(&mut window, sample * sample, sample * sample).0;
}
}
result.sqrt()
}
#[test]
fn sliding_rms_is_independent_of_capture_period() {
let reference = run_rms(1);
assert!((reference - std::f32::consts::FRAC_1_SQRT_2).abs() < 1.0e-4);
for period in [64, 127, 128, 192, 512] {
assert!((run_rms(period) - reference).abs() < 1.0e-7);
}
}
}
+20
View File
@@ -145,6 +145,26 @@ mod tests {
assert_eq!(fast.negative_peak(), 0.0);
}
#[test]
fn fast_response_tracks_a_phase_reversal_before_slow_response() {
let sample_rate = 48_000;
let mut fast = CorrelationMeter::new(sample_rate, 1.0, 0);
let mut slow = CorrelationMeter::new(sample_rate, 2.5, 0);
for index in 0..sample_rate as usize * 5 {
let sample = if index & 1 == 0 { 0.5 } else { -0.5 };
fast.process(sample, sample);
slow.process(sample, sample);
}
for index in 0..sample_rate as usize {
let sample = if index & 1 == 0 { 0.5 } else { -0.5 };
fast.process(sample, -sample);
slow.process(sample, -sample);
}
assert!(fast.value() < slow.value() - 0.35);
assert!(fast.negative_peak() < 0.0);
assert_eq!(slow.negative_peak(), 0.0);
}
#[test]
fn silence_and_single_channel_are_neutral() {
let mut meter = CorrelationMeter::new(48_000, 1.0, 0);
+2
View File
@@ -7,6 +7,8 @@ mod ppm;
mod routes;
mod rta;
mod state;
mod true_peak;
mod vu;
use std::net::SocketAddr;
+126
View File
@@ -0,0 +1,126 @@
//! Continuous, block-boundary-independent 4x true-peak interpolation.
use std::collections::VecDeque;
const OVERSAMPLE: usize = 4;
const RADIUS: usize = 8;
const BUFFER_LEN: usize = RADIUS * 2 + 1;
#[derive(Clone, Debug)]
pub struct TruePeakDetector {
samples: VecDeque<f32>,
}
impl Default for TruePeakDetector {
fn default() -> Self {
Self {
samples: VecDeque::with_capacity(BUFFER_LEN + 1),
}
}
}
impl TruePeakDetector {
pub fn process(&mut self, sample: f32) -> f32 {
let mut peak = sample.abs();
self.samples.push_back(sample);
if self.samples.len() < BUFFER_LEN {
return peak;
}
let mut contiguous = [0.0f32; BUFFER_LEN];
for (target, source) in contiguous.iter_mut().zip(self.samples.iter()) {
*target = *source;
}
for phase in 1..OVERSAMPLE {
let position = RADIUS as f32 + phase as f32 / OVERSAMPLE as f32;
peak = peak.max(interpolate(&contiguous, position).abs());
}
self.samples.pop_front();
peak
}
}
fn sinc(value: f32) -> f32 {
if value.abs() < 1.0e-6 {
1.0
} else {
let x = std::f32::consts::PI * value;
x.sin() / x
}
}
fn blackman(value: f32) -> f32 {
let span = (RADIUS * 2) as f32;
let phase = 2.0 * std::f32::consts::PI * (value + RADIUS as f32) / span;
0.42 - 0.5 * phase.cos() + 0.08 * (2.0 * phase).cos()
}
fn interpolate(samples: &[f32], position: f32) -> f32 {
let base = position.floor() as isize;
let mut sum = 0.0;
let mut norm = 0.0;
for index in (base - RADIUS as isize + 1)..=(base + RADIUS as isize) {
if !(0..samples.len() as isize).contains(&index) {
continue;
}
let distance = position - index as f32;
let weight = sinc(distance) * blackman(distance);
sum += samples[index as usize] * weight;
norm += weight;
}
if norm.abs() > 1.0e-6 {
sum / norm
} else {
0.0
}
}
#[cfg(test)]
mod tests {
use super::*;
fn run_in_blocks(samples: &[f32], block_size: usize) -> f32 {
let mut detector = TruePeakDetector::default();
let mut peak = 0.0f32;
for block in samples.chunks(block_size) {
for &sample in block {
peak = peak.max(detector.process(sample));
}
}
for _ in 0..BUFFER_LEN {
peak = peak.max(detector.process(0.0));
}
peak
}
#[test]
fn result_is_independent_of_capture_block_boundaries() {
let samples: Vec<f32> = (0..4_800)
.map(|index| {
(2.0 * std::f32::consts::PI * 11_025.0 * index as f32 / 48_000.0 + 0.31).sin() * 0.9
})
.collect();
let reference = run_in_blocks(&samples, 1);
for size in [64, 127, 128, 192, 511] {
assert!((run_in_blocks(&samples, size) - reference).abs() < 1.0e-7);
}
}
#[test]
fn detects_an_intersample_peak_above_sample_peak() {
let samples: Vec<f32> = (0..4_800)
.map(|index| {
(2.0 * std::f32::consts::PI * 11_025.0 * index as f32 / 48_000.0 + 0.31).sin() * 0.9
})
.collect();
let sample_peak = samples
.iter()
.fold(0.0f32, |peak, value| peak.max(value.abs()));
let detected = run_in_blocks(&samples, 128);
assert!(detected > sample_peak + 0.001);
assert!(
detected <= 0.91,
"unexpected interpolation overshoot: {detected}"
);
}
}
+138
View File
@@ -0,0 +1,138 @@
//! Standard-volume-indicator style full-wave detector and moving-coil model.
const RESONANCE_HZ: f64 = 2.1;
const Q: f64 = 0.62;
const RECTIFIED_TO_RMS: f64 = std::f64::consts::PI / (2.0 * std::f64::consts::SQRT_2);
#[derive(Clone, Copy, Debug, Default)]
struct ChannelState {
x1: f64,
x2: f64,
y1: f64,
y2: f64,
}
#[derive(Clone, Debug)]
pub struct VuMeter {
sample_rate: u32,
b0: f64,
b1: f64,
b2: f64,
a1: f64,
a2: f64,
left: ChannelState,
right: ChannelState,
}
impl VuMeter {
pub fn new(sample_rate: u32) -> Self {
let mut meter = Self {
sample_rate: 0,
b0: 0.0,
b1: 0.0,
b2: 0.0,
a1: 0.0,
a2: 0.0,
left: ChannelState::default(),
right: ChannelState::default(),
};
meter.ensure_sample_rate(sample_rate);
meter
}
pub fn ensure_sample_rate(&mut self, sample_rate: u32) {
let sample_rate = sample_rate.max(8_000);
if self.sample_rate == sample_rate {
return;
}
self.sample_rate = sample_rate;
let omega = 2.0 * std::f64::consts::PI * RESONANCE_HZ / f64::from(sample_rate);
let cosine = omega.cos();
let alpha = omega.sin() / (2.0 * Q);
let a0 = 1.0 + alpha;
self.b0 = (1.0 - cosine) * 0.5 / a0;
self.b1 = (1.0 - cosine) / a0;
self.b2 = self.b0;
self.a1 = -2.0 * cosine / a0;
self.a2 = (1.0 - alpha) / a0;
self.left = ChannelState::default();
self.right = ChannelState::default();
}
pub fn process(&mut self, left: f32, right: f32) -> (f32, f32) {
let coefficients = (self.b0, self.b1, self.b2, self.a1, self.a2);
let left = Self::process_channel(&mut self.left, f64::from(left.abs()), coefficients);
let right = Self::process_channel(&mut self.right, f64::from(right.abs()), coefficients);
(left as f32, right as f32)
}
fn process_channel(
state: &mut ChannelState,
rectified: f64,
(b0, b1, b2, a1, a2): (f64, f64, f64, f64, f64),
) -> f64 {
let input = rectified * RECTIFIED_TO_RMS;
let output = b0 * input + b1 * state.x1 + b2 * state.x2 - a1 * state.y1 - a2 * state.y2;
state.x2 = state.x1;
state.x1 = input;
state.y2 = state.y1;
state.y1 = output;
output.max(0.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sine(index: usize, sample_rate: u32) -> f32 {
(2.0 * std::f32::consts::PI * 1_000.0 * index as f32 / sample_rate as f32).sin()
}
#[test]
fn moving_coil_step_reaches_99_percent_and_has_standard_overshoot() {
let sample_rate = 48_000;
let reference = std::f32::consts::FRAC_1_SQRT_2;
let mut meter = VuMeter::new(sample_rate);
let mut at_300_ms = 0.0;
let mut maximum = 0.0f32;
for index in 0..sample_rate as usize {
let output = meter.process(sine(index, sample_rate), 0.0).0;
maximum = maximum.max(output);
if index + 1 == sample_rate as usize * 3 / 10 {
at_300_ms = output;
}
}
assert!((at_300_ms / reference - 0.99).abs() < 0.015);
let overshoot = maximum / reference - 1.0;
assert!((0.01..=0.015).contains(&overshoot), "overshoot={overshoot}");
let mut released = 0.0;
for _ in 0..sample_rate as usize * 3 / 10 {
released = meter.process(0.0, 0.0).0;
}
assert!(released < reference * 0.02);
}
#[test]
fn result_is_independent_of_capture_period() {
fn run(period: usize) -> f32 {
let sample_rate = 48_000;
let mut meter = VuMeter::new(sample_rate);
let samples: Vec<f32> = (0..sample_rate as usize)
.map(|index| sine(index, sample_rate))
.collect();
let mut result = 0.0;
for block in samples.chunks(period) {
for &sample in block {
result = meter.process(sample, sample).0;
}
}
result
}
let reference = run(1);
for period in [64, 127, 128, 192, 512] {
assert!((run(period) - reference).abs() < 1.0e-7);
}
}
}