Harden audio meter regression coverage
This commit is contained in:
1 parent
9b5250d42b
commit
a123539023
9 files changed
+370
-121
No files matched your search
+62
-108
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -7,6 +7,8 @@ mod ppm;
|
||||
mod routes;
|
||||
mod rta;
|
||||
mod state;
|
||||
mod true_peak;
|
||||
mod vu;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user