From a123539023b5cb5aa561bbc82da2a4d6cb631a65 Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:36:26 +0200 Subject: [PATCH] Harden audio meter regression coverage --- TODO.md | 22 ++-- scripts/test_rta_profile.mjs | 3 + scripts/test_websocket_runtime.mjs | 9 +- src/audio.rs | 170 +++++++++++------------------ src/correlation.rs | 20 ++++ src/main.rs | 2 + src/true_peak.rs | 126 +++++++++++++++++++++ src/vu.rs | 138 +++++++++++++++++++++++ www/index.html | 1 + 9 files changed, 370 insertions(+), 121 deletions(-) create mode 100644 src/true_peak.rs create mode 100644 src/vu.rs diff --git a/TODO.md b/TODO.md index d0b6a37..fe35ef2 100644 --- a/TODO.md +++ b/TODO.md @@ -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. diff --git a/scripts/test_rta_profile.mjs b/scripts/test_rta_profile.mjs index c62b202..4b9ead4 100644 --- a/scripts/test_rta_profile.mjs +++ b/scripts/test_rta_profile.mjs @@ -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); diff --git a/scripts/test_websocket_runtime.mjs b/scripts/test_websocket_runtime.mjs index a08ad89..9dce44d 100644 --- a/scripts/test_websocket_runtime.mjs +++ b/scripts/test_websocket_runtime.mjs @@ -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)`); diff --git a/src/audio.rs b/src/audio.rs index faceb64..238802d 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -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, @@ -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, last_spectro: Option, 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, - tp_history_r: Vec, + true_peak_l: TruePeakDetector, + true_peak_r: TruePeakDetector, correlation: CorrelationMeter, xy_pending_l: Vec, xy_pending_r: Vec, @@ -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 { }) } -#[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, 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, Vec) { 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 = (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); + } + } +} diff --git a/src/correlation.rs b/src/correlation.rs index 2f184cb..e9808c7 100644 --- a/src/correlation.rs +++ b/src/correlation.rs @@ -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); diff --git a/src/main.rs b/src/main.rs index 9d14df1..a2600c6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,8 @@ mod ppm; mod routes; mod rta; mod state; +mod true_peak; +mod vu; use std::net::SocketAddr; diff --git a/src/true_peak.rs b/src/true_peak.rs new file mode 100644 index 0000000..fd952f4 --- /dev/null +++ b/src/true_peak.rs @@ -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, +} + +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 = (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 = (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}" + ); + } +} diff --git a/src/vu.rs b/src/vu.rs new file mode 100644 index 0000000..544c289 --- /dev/null +++ b/src/vu.rs @@ -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 = (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); + } + } +} diff --git a/www/index.html b/www/index.html index 08930fd..e0730e7 100644 --- a/www/index.html +++ b/www/index.html @@ -1278,6 +1278,7 @@
  • Goniometer: 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.
  • Goniometer-Persistenz: 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.
  • Korrelation: 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.
  • +
  • True Peak, RMS und VU: 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.
  • Qualitätssicherung: Automatische Regressionstests für PPM, RTA, A/C/Z, mehrere Sampleraten, Korrelationssignale, Goniometertaktung, Binärprotokolle sowie Spektrogramm-Zeitbasis und Langlauf ergänzt.