Harden audio meter regression coverage

This commit is contained in:
Mikei386
2026-07-21 21:36:26 +02:00
parent 9b5250d42b
commit a123539023
9 changed files with 370 additions and 121 deletions
+11 -11
View File
@@ -3,6 +3,7 @@
## Verbindliches Ziel
- Referenz ist der RTW PortaMonitor 1064X/1064X-PLUS, insbesondere dessen RTA-, PPM-, Peakmeter-, Goniometer- und Korrelationsverhalten.
- Gemessen werden analoge Line-Signale. Deshalb umfasst die endgültige Referenzmessung immer den vollständigen analogen Eingang einschließlich Vorstufe, Antialiasing und A/D-Wandler; reine Digitaltests sichern nur den nachfolgenden DSP ab.
- Der primäre RTW-nahe RTA arbeitet als IIR-Fractional-Octave-Filterbank. FFT bleibt eine optionale, getrennt gekennzeichnete Spektrumsansicht.
- Der RTW-RTA-Modus soll 31 Bänder in 1/3-Oktaven von 20 Hz bis 20 kHz sowie ein Verhalten entsprechend IEC 225/ANSI Class 2 beziehungsweise der passenden aktuellen Nachfolgenorm bieten.
- Vorgesehene RTA-Detektoren: Average (RMS) und Peak (10 ms); dazu die RMS-Reaktionszeiten Fast, Medium, Slow und Impulse sowie Peak Hold 2,5 s, 4 s oder manuell.
@@ -93,9 +94,9 @@ Diese vorhandenen Funktionen sind nicht automatisch messtechnisch korrekt. Die f
- [ ] **10. True Peak kontinuierlich und blockübergreifend korrigieren**
- **Soll:** Intersample-Peaks werden unabhängig von ihrer Lage zum ALSA-Block zuverlässig erkannt.
- **Ist:** Eine 4-fache Sinc-Interpolation mit kurzer Historie existiert.
- **Falsch/kaputt:** Ungefähr die letzten acht Intervalle jedes Capture-Blocks werden nicht interpoliert und können zu niedrige dBTP-Werte liefern.
- **Aufgabe:** Kontinuierlichen Oversampling-Filter mit vollständiger Historie verwenden und gegen ITU-Testmaterial sowie synthetische Grenzfälle prüfen.
- **Ist:** Die 4-fache bandbegrenzte Interpolation besitzt nun eine kontinuierliche Historie mit dem erforderlichen Zukunftsanteil. Die zuvor an jedem Capture-Block übersprungenen Intervalle werden verzögert, aber lückenlos ausgewertet.
- **Geprüft:** Synthetische Intersample-Peaks werden oberhalb des Sample-Peaks erkannt; 64, 127, 128, 192 und 511 Samples große Capture-Blöcke liefern denselben Maximalwert.
- **Noch offen:** Formelle Validierung mit ITU-/EBU-Testmaterial und dem vollständigen geforderten dBTP-Toleranzsatz.
- **Abnahme:** Gleiche Peakwerte unabhängig von Blockgrenze, Periodengröße und Samplerate.
- [x] **11. DIN- und EBU-PPM softwareseitig norm- und RTW-nah auslegen**
@@ -105,15 +106,13 @@ Diese vorhandenen Funktionen sind nicht automatisch messtechnisch korrekt. Die f
- **Noch offen:** Absolute Pegel- und Skalenprüfung mit kalibriertem Generator, Eingangs-Hardware und realem RTW-Gerät bleibt unter Punkt 16 erforderlich.
- **Abnahme:** Softwaretests bestehen; die endgültige Aussage zur Messgeräte-Konformität erfolgt erst nach der Hardwarevergleichsmessung.
- [ ] **12. VU und RMS eindeutig und reproduzierbar definieren**
- [x] **12. VU und RMS softwareseitig eindeutig und reproduzierbar definieren**
- **Soll VU:** RTW-artige Moving-Coil-Ballistik mit richtigem Einschwingen, Rücklauf und Überschwingen.
- **Ist VU:** 300-ms-Rechteckmittel der gleichgerichteten Samples.
- **Falsch VU:** Boxcar-Mittelung entspricht nicht der mechanischen VU-Ballistik.
- **Ist VU:** Vollweggleichrichtung mit RMS-Kalibrierung und unterdämpftem Moving-Coil-Modell. Der 1-kHz-Sprung erreicht nach 300 ms etwa 99 % und überschwingt um 1 bis 1,5 %; der Rücklauf und mehrere Capture-Perioden werden automatisch geprüft.
- **Soll RMS:** Dokumentiertes gleitendes Messfenster mit eindeutigem dBFS-/Kalibrierbezug.
- **Ist RMS:** RMS nur über den aktuellen ALSA-Block, bei 128 Samples etwa 2,67 ms.
- **Falsch RMS:** Wert und Unruhe hängen von der Periodengröße ab.
- **Aufgabe:** Beide Detektoren unabhängig von der Capture-Blockgröße implementieren und separat testen.
- **Abnahme:** Identische Werte und Ballistiken bei verschiedenen ALSA-Perioden.
- **Ist RMS:** True RMS wird jetzt samplekontinuierlich über ein festes gleitendes 300-ms-Leistungsfenster berechnet. Der Wert ist unabhängig von der ALSA-Periode; ein 1-kHz-Sinus und fünf verschiedene Blockgrößen werden automatisch geprüft.
- **Noch extern zu prüfen:** Pegelkalibrierung und die optische Übereinstimmung mit dem konkreten RTW-PortaMonitor am analogen Eingang.
- **Abnahme:** Softwaretests bestehen; endgültige Geräteübereinstimmung folgt mit der analogen Referenzmessung.
- [ ] **13. FFT-Modus als optionale Spektrumsansicht fachlich korrigieren**
- **Soll:** FFT ist eine korrekte Zusatzansicht, aber nicht die RTW-IIR-Referenz.
@@ -159,7 +158,8 @@ Diese vorhandenen Funktionen sind nicht automatisch messtechnisch korrekt. Die f
- [ ] **18. Automatisierte DSP- und Darstellungsregressionstests aufbauen**
- **Soll:** Keine Änderung kann unbemerkt Pegel, Frequenzgang, Ballistik, Latenz oder RTW-Darstellung verschlechtern.
- **Ist:** DIN-/EBU-PPM, Spektrogramm-Zeitbasis und Langlauf, Worker-Verhalten sowie die Binärprotokolle sind automatisiert abgesichert. Servertests prüfen außerdem das Zusammenführen der Waveform-Hüllkurve und die Trennung großer Nutzdaten vom JSON-Messstrom.
- **Noch offen:** RTA und Korrelation besitzen nun DSP-Tests; der Goniometerweg prüft mehrere Sampleraten, Periodengrößen, Punktreduktion und Persistenzprofile. True Peak, VU/RMS, dynamische Referenzvergleiche und End-to-End-Latenz besitzen noch keine vollständige automatische Regression. Deshalb bleibt dieser Gesamtpunkt offen.
- **Ergänzt:** RTA und Korrelation besitzen DSP-Tests; die Korrelation prüft jetzt auch einen dynamischen Phasensprung und unterscheidet dabei 1,0 von 2,5 Sekunden. Der Goniometerweg prüft mehrere Sampleraten, Periodengrößen, Punktreduktion und Persistenzprofile. True Peak prüft Intersample-Erkennung und Blockgrenzen, RMS das feste 300-ms-Fenster. Der Laufzeittest begrenzt zusätzlich das Alter empfangener Messframes auf 500 ms und erkennt damit Transport-Backlogs.
- **Noch offen:** Vollständige ITU-True-Peak-Vektoren, echte Capture-bis-Paint-Latenz und dynamische RTW-Gerätevergleichsreihen. Deshalb bleibt dieser Gesamtpunkt offen.
- **Aufgabe:** Einzeltöne aller 31 Bänder, Sweeps, Weiß-/Rosarauschen, Pegelsprünge, Tonbursts, Phasen-/Korrelationssignale und Intersample-Peaks testen. Sampleraten, Perioden und Blockgrenzen variieren. RTW-Screenshots und Messprotokolle als Referenz verwenden, soweit rechtlich möglich.
- **Abnahme:** Automatischer Bericht mit Erwartungswerten und Toleranzen für jede Messfunktion.
+3
View File
@@ -21,6 +21,7 @@ const forced = context.buildRtaRuntimeConfig({
RTA_BPO_MODE: '1_12',
RTA_FREQ_RANGE: 'lf',
RTA_IIR_ORDER: 2,
RTA_DETECTOR: 'peak',
CORR_RESPONSE_S: 2.5,
CORR_RESET_TOKEN: 7,
XY_POINTS: 256,
@@ -29,6 +30,8 @@ assert.equal(forced.engine, 'iir');
assert.equal(forced.bpo, '1_12');
assert.equal(forced.freqRange, 'norm');
assert.equal(forced.order, 6);
assert.equal(forced.detector, 'peak');
assert.equal(forced.tauFast, 0.125);
assert.equal(forced.rtwCenters.length, 121);
assert.equal(forced.correlationResponseS, 2.5);
assert.equal(forced.correlationResetToken, 7);
+7 -2
View File
@@ -19,10 +19,15 @@ const [metrics] = sockets;
let count = 0;
let lastSeq = 0;
let invalidPayload = false;
let maxFrameAgeMs = 0;
metrics.addEventListener('message', (event) => {
const frame = JSON.parse(String(event.data));
const seq = Number(frame.seq);
if (seq <= lastSeq || 'spectro' in frame || 'wave_env' in frame || 'xy_l' in frame || 'wave_l' in frame) {
const timestamp = Number(frame.timestamp_ms);
const age = Date.now() - timestamp;
maxFrameAgeMs = Math.max(maxFrameAgeMs, age);
if (seq <= lastSeq || !Number.isFinite(timestamp) || age < -100 || age > 500
|| 'spectro' in frame || 'wave_env' in frame || 'xy_l' in frame || 'wave_l' in frame) {
invalidPayload = true;
}
lastSeq = seq;
@@ -33,4 +38,4 @@ await new Promise((resolve) => setTimeout(resolve, 1100));
sockets.forEach((socket) => socket.close());
assert.equal(invalidPayload, false, 'metrics must be ordered and contain no large visual payloads');
assert.ok(count >= 50 && count <= 75, `expected about 60 metrics/s, received ${count}`);
console.log(`runtime websocket test passed (${count} metrics in 1.1 s; all three streams opened)`);
console.log(`runtime websocket test passed (${count} metrics in 1.1 s; max frame age ${maxFrameAgeMs} ms; all three streams opened)`);
+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);
}
}
}
+1
View File
@@ -1278,6 +1278,7 @@
<li><strong>Goniometer:</strong> Nur neue XY-Samplepaare werden mit einer periodengrößenunabhängigen mittleren Rate von 60 Hz übertragen. Punktbegrenzung wirkt bereits auf den Transport; begrenzte und wiederverwendbare Spurpuffer reduzieren Speicher- und Zeichenlast.</li>
<li><strong>Goniometer-Persistenz:</strong> Reproduzierbare Fast-, Medium- und Slow-Profile sowie ein freies Phoenix-Fade ergänzt. Künstliche Bézier-Verformung der Messspur entfernt; AGC und Silence-Gate arbeiten zeit- beziehungsweise fensterbasiert.</li>
<li><strong>Korrelation:</strong> Kontinuierliche DSP-Messung aus L², R² und L·R mit wählbaren 1,0/2,5 Sekunden ergänzt. Bildratenabhängige Doppelglättung entfernt; Negative-Peak-Memory, Marker und manueller Reset hinzugefügt.</li>
<li><strong>True Peak, RMS und VU:</strong> True-Peak-Interpolation läuft nun lückenlos über Capture-Blockgrenzen; ein festes gleitendes 300-ms-Leistungsfenster ersetzt das periodengrößenabhängige Block-RMS. VU verwendet statt Rechteckmittelung ein Moving-Coil-Modell mit 300-ms-Sprungantwort und 1 bis 1,5 % Überschwingen.</li>
<li><strong>Qualitätssicherung:</strong> Automatische Regressionstests für PPM, RTA, A/C/Z, mehrere Sampleraten, Korrelationssignale, Goniometertaktung, Binärprotokolle sowie Spektrogramm-Zeitbasis und Langlauf ergänzt.</li>
</ul>
</div>