Harden audio meter regression coverage
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
## Verbindliches Ziel
|
## Verbindliches Ziel
|
||||||
|
|
||||||
- Referenz ist der RTW PortaMonitor 1064X/1064X-PLUS, insbesondere dessen RTA-, PPM-, Peakmeter-, Goniometer- und Korrelationsverhalten.
|
- 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 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.
|
- 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.
|
- 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**
|
- [ ] **10. True Peak kontinuierlich und blockübergreifend korrigieren**
|
||||||
- **Soll:** Intersample-Peaks werden unabhängig von ihrer Lage zum ALSA-Block zuverlässig erkannt.
|
- **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.
|
- **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.
|
||||||
- **Falsch/kaputt:** Ungefähr die letzten acht Intervalle jedes Capture-Blocks werden nicht interpoliert und können zu niedrige dBTP-Werte liefern.
|
- **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.
|
||||||
- **Aufgabe:** Kontinuierlichen Oversampling-Filter mit vollständiger Historie verwenden und gegen ITU-Testmaterial sowie synthetische Grenzfälle prüfen.
|
- **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.
|
- **Abnahme:** Gleiche Peakwerte unabhängig von Blockgrenze, Periodengröße und Samplerate.
|
||||||
|
|
||||||
- [x] **11. DIN- und EBU-PPM softwareseitig norm- und RTW-nah auslegen**
|
- [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.
|
- **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.
|
- **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.
|
- **Soll VU:** RTW-artige Moving-Coil-Ballistik mit richtigem Einschwingen, Rücklauf und Überschwingen.
|
||||||
- **Ist VU:** 300-ms-Rechteckmittel der gleichgerichteten Samples.
|
- **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.
|
||||||
- **Falsch VU:** Boxcar-Mittelung entspricht nicht der mechanischen VU-Ballistik.
|
|
||||||
- **Soll RMS:** Dokumentiertes gleitendes Messfenster mit eindeutigem dBFS-/Kalibrierbezug.
|
- **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.
|
- **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.
|
||||||
- **Falsch RMS:** Wert und Unruhe hängen von der Periodengröße ab.
|
- **Noch extern zu prüfen:** Pegelkalibrierung und die optische Übereinstimmung mit dem konkreten RTW-PortaMonitor am analogen Eingang.
|
||||||
- **Aufgabe:** Beide Detektoren unabhängig von der Capture-Blockgröße implementieren und separat testen.
|
- **Abnahme:** Softwaretests bestehen; endgültige Geräteübereinstimmung folgt mit der analogen Referenzmessung.
|
||||||
- **Abnahme:** Identische Werte und Ballistiken bei verschiedenen ALSA-Perioden.
|
|
||||||
|
|
||||||
- [ ] **13. FFT-Modus als optionale Spektrumsansicht fachlich korrigieren**
|
- [ ] **13. FFT-Modus als optionale Spektrumsansicht fachlich korrigieren**
|
||||||
- **Soll:** FFT ist eine korrekte Zusatzansicht, aber nicht die RTW-IIR-Referenz.
|
- **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**
|
- [ ] **18. Automatisierte DSP- und Darstellungsregressionstests aufbauen**
|
||||||
- **Soll:** Keine Änderung kann unbemerkt Pegel, Frequenzgang, Ballistik, Latenz oder RTW-Darstellung verschlechtern.
|
- **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.
|
- **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.
|
- **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.
|
- **Abnahme:** Automatischer Bericht mit Erwartungswerten und Toleranzen für jede Messfunktion.
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const forced = context.buildRtaRuntimeConfig({
|
|||||||
RTA_BPO_MODE: '1_12',
|
RTA_BPO_MODE: '1_12',
|
||||||
RTA_FREQ_RANGE: 'lf',
|
RTA_FREQ_RANGE: 'lf',
|
||||||
RTA_IIR_ORDER: 2,
|
RTA_IIR_ORDER: 2,
|
||||||
|
RTA_DETECTOR: 'peak',
|
||||||
CORR_RESPONSE_S: 2.5,
|
CORR_RESPONSE_S: 2.5,
|
||||||
CORR_RESET_TOKEN: 7,
|
CORR_RESET_TOKEN: 7,
|
||||||
XY_POINTS: 256,
|
XY_POINTS: 256,
|
||||||
@@ -29,6 +30,8 @@ assert.equal(forced.engine, 'iir');
|
|||||||
assert.equal(forced.bpo, '1_12');
|
assert.equal(forced.bpo, '1_12');
|
||||||
assert.equal(forced.freqRange, 'norm');
|
assert.equal(forced.freqRange, 'norm');
|
||||||
assert.equal(forced.order, 6);
|
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.rtwCenters.length, 121);
|
||||||
assert.equal(forced.correlationResponseS, 2.5);
|
assert.equal(forced.correlationResponseS, 2.5);
|
||||||
assert.equal(forced.correlationResetToken, 7);
|
assert.equal(forced.correlationResetToken, 7);
|
||||||
|
|||||||
@@ -19,10 +19,15 @@ const [metrics] = sockets;
|
|||||||
let count = 0;
|
let count = 0;
|
||||||
let lastSeq = 0;
|
let lastSeq = 0;
|
||||||
let invalidPayload = false;
|
let invalidPayload = false;
|
||||||
|
let maxFrameAgeMs = 0;
|
||||||
metrics.addEventListener('message', (event) => {
|
metrics.addEventListener('message', (event) => {
|
||||||
const frame = JSON.parse(String(event.data));
|
const frame = JSON.parse(String(event.data));
|
||||||
const seq = Number(frame.seq);
|
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;
|
invalidPayload = true;
|
||||||
}
|
}
|
||||||
lastSeq = seq;
|
lastSeq = seq;
|
||||||
@@ -33,4 +38,4 @@ await new Promise((resolve) => setTimeout(resolve, 1100));
|
|||||||
sockets.forEach((socket) => socket.close());
|
sockets.forEach((socket) => socket.close());
|
||||||
assert.equal(invalidPayload, false, 'metrics must be ordered and contain no large visual payloads');
|
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}`);
|
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
@@ -27,6 +27,10 @@ use crate::rta::{
|
|||||||
StereoCascade, RTW_THIRD_OCTAVE_CENTERS,
|
StereoCascade, RTW_THIRD_OCTAVE_CENTERS,
|
||||||
};
|
};
|
||||||
use crate::state::NativeWavRecorder;
|
use crate::state::NativeWavRecorder;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use crate::true_peak::TruePeakDetector;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use crate::vu::VuMeter;
|
||||||
use crate::{
|
use crate::{
|
||||||
config::PhoenixConfig,
|
config::PhoenixConfig,
|
||||||
model::{InputSource, MeterFrame, PhoenixRtaConfig},
|
model::{InputSource, MeterFrame, PhoenixRtaConfig},
|
||||||
@@ -61,12 +65,6 @@ const BOX_MAX_DB: f32 = 9.0;
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
const VU_WINDOW_MS: f32 = 300.0;
|
const VU_WINDOW_MS: f32 = 300.0;
|
||||||
#[cfg(target_os = "linux")]
|
#[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;
|
const NATIVE_RECORDER_DISCONTINUITY_BLEND_FRAMES: usize = 256;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
const RTW_CENTERS_1_6: &[f32] = &[
|
const RTW_CENTERS_1_6: &[f32] = &[
|
||||||
@@ -223,7 +221,6 @@ struct LoudnessBiquadState {
|
|||||||
y2: f32,
|
y2: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
struct MovingAverageWindow {
|
struct MovingAverageWindow {
|
||||||
window_len: usize,
|
window_len: usize,
|
||||||
ring_l: Vec<f32>,
|
ring_l: Vec<f32>,
|
||||||
@@ -460,7 +457,8 @@ fn capture_until_restart(deps: AudioWorkerDeps, generation: u64) -> anyhow::Resu
|
|||||||
struct PpmState {
|
struct PpmState {
|
||||||
din_ppm: PpmDetector,
|
din_ppm: PpmDetector,
|
||||||
ebu_ppm: PpmDetector,
|
ebu_ppm: PpmDetector,
|
||||||
vu_window: MovingAverageWindow,
|
vu_meter: VuMeter,
|
||||||
|
rms_window: MovingAverageWindow,
|
||||||
last_rta: Option<RtaFrame>,
|
last_rta: Option<RtaFrame>,
|
||||||
last_spectro: Option<SpectroFrame>,
|
last_spectro: Option<SpectroFrame>,
|
||||||
rta_signature: String,
|
rta_signature: String,
|
||||||
@@ -478,8 +476,8 @@ struct PpmState {
|
|||||||
lr_delay_y1_l: f32,
|
lr_delay_y1_l: f32,
|
||||||
lr_delay_x1_r: f32,
|
lr_delay_x1_r: f32,
|
||||||
lr_delay_y1_r: f32,
|
lr_delay_y1_r: f32,
|
||||||
tp_history_l: Vec<f32>,
|
true_peak_l: TruePeakDetector,
|
||||||
tp_history_r: Vec<f32>,
|
true_peak_r: TruePeakDetector,
|
||||||
correlation: CorrelationMeter,
|
correlation: CorrelationMeter,
|
||||||
xy_pending_l: Vec<f32>,
|
xy_pending_l: Vec<f32>,
|
||||||
xy_pending_r: Vec<f32>,
|
xy_pending_r: Vec<f32>,
|
||||||
@@ -492,7 +490,8 @@ impl Default for PpmState {
|
|||||||
Self {
|
Self {
|
||||||
din_ppm: PpmDetector::new(48_000, PpmStandard::Din),
|
din_ppm: PpmDetector::new(48_000, PpmStandard::Din),
|
||||||
ebu_ppm: PpmDetector::new(48_000, PpmStandard::EbuTypeIib),
|
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_rta: None,
|
||||||
last_spectro: None,
|
last_spectro: None,
|
||||||
rta_signature: String::new(),
|
rta_signature: String::new(),
|
||||||
@@ -510,8 +509,8 @@ impl Default for PpmState {
|
|||||||
lr_delay_y1_l: 0.0,
|
lr_delay_y1_l: 0.0,
|
||||||
lr_delay_x1_r: 0.0,
|
lr_delay_x1_r: 0.0,
|
||||||
lr_delay_y1_r: 0.0,
|
lr_delay_y1_r: 0.0,
|
||||||
tp_history_l: Vec::new(),
|
true_peak_l: TruePeakDetector::default(),
|
||||||
tp_history_r: Vec::new(),
|
true_peak_r: TruePeakDetector::default(),
|
||||||
correlation: CorrelationMeter::new(48_000, 1.0, 0),
|
correlation: CorrelationMeter::new(48_000, 1.0, 0),
|
||||||
xy_pending_l: Vec::with_capacity(1024),
|
xy_pending_l: Vec::with_capacity(1024),
|
||||||
xy_pending_r: 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 {
|
fn create_moving_average_window(sample_rate: u32, window_ms: f32) -> MovingAverageWindow {
|
||||||
let sr = sample_rate.max(8_000) as f32;
|
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;
|
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) {
|
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)
|
let desired_len = ((window_ms.max(0.1) / 1000.0) * sample_rate.max(8_000) as f32)
|
||||||
.round()
|
.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);
|
*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) {
|
fn moving_average_push(state: &mut MovingAverageWindow, l: f32, r: f32) -> (f32, f32) {
|
||||||
if state.fill < state.window_len {
|
if state.fill < state.window_len {
|
||||||
state.fill += 1;
|
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));
|
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")]
|
#[cfg(target_os = "linux")]
|
||||||
fn take_goniometer_samples(state: &mut PpmState, target_points: usize) -> (Vec<f32>, Vec<f32>) {
|
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());
|
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,
|
ppm_state: &mut PpmState,
|
||||||
rta_config: &PhoenixRtaConfig,
|
rta_config: &PhoenixRtaConfig,
|
||||||
) -> MeterFrame {
|
) -> MeterFrame {
|
||||||
let mut sum_sq_l = 0.0f64;
|
let mut rms_power_l = 0.0f32;
|
||||||
let mut sum_sq_r = 0.0f64;
|
let mut rms_power_r = 0.0f32;
|
||||||
let mut peak_l = 0.0f32;
|
let mut peak_l = 0.0f32;
|
||||||
let mut peak_r = 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 frames = interleaved.len() / 2;
|
||||||
let mut wave_l = Vec::with_capacity(frames);
|
let mut wave_l = Vec::with_capacity(frames);
|
||||||
let mut wave_r = Vec::with_capacity(frames);
|
let mut wave_r = Vec::with_capacity(frames);
|
||||||
@@ -1179,7 +1102,8 @@ fn build_meter_frame(
|
|||||||
ppm_state
|
ppm_state
|
||||||
.ebu_ppm
|
.ebu_ppm
|
||||||
.ensure_profile(sample_rate, PpmStandard::EbuTypeIib);
|
.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_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));
|
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);
|
wave_env_accumulate(&mut ppm_state.wave_env, l, r, 2);
|
||||||
process_lufs_sample(&mut ppm_state.lufs, l, r, rta_config);
|
process_lufs_sample(&mut ppm_state.lufs, l, r, rta_config);
|
||||||
|
|
||||||
sum_sq_l += f64::from(l * l);
|
(rms_power_l, rms_power_r) = moving_average_push(&mut ppm_state.rms_window, l * l, r * r);
|
||||||
sum_sq_r += f64::from(r * r);
|
|
||||||
let abs_l = l.abs();
|
let abs_l = l.abs();
|
||||||
let abs_r = r.abs();
|
let abs_r = r.abs();
|
||||||
ppm_state.din_ppm.process(l, r);
|
ppm_state.din_ppm.process(l, r);
|
||||||
ppm_state.ebu_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_l = peak_l.max(abs_l);
|
||||||
peak_r = peak_r.max(abs_r);
|
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.correlation.process(l, r);
|
||||||
ppm_state.xy_pending_l.push(l);
|
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_l = dbfs(rms_power_l.max(0.0).sqrt());
|
||||||
let rms_r = dbfs((sum_sq_r / frames.max(1) as f64).sqrt() as f32);
|
let rms_r = dbfs(rms_power_r.max(0.0).sqrt());
|
||||||
let vu_rect_l = (ppm_state.vu_window.sum_l / ppm_state.vu_window.fill.max(1) as f64) as f32;
|
let vu_l = dbfs(vu_l_amp);
|
||||||
let vu_rect_r = (ppm_state.vu_window.sum_r / ppm_state.vu_window.fill.max(1) as f64) as f32;
|
let vu_r = dbfs(vu_r_amp);
|
||||||
let vu_l = dbfs(vu_rect_l * VU_RECT_TO_RMS_GAIN);
|
|
||||||
let vu_r = dbfs(vu_rect_r * VU_RECT_TO_RMS_GAIN);
|
|
||||||
update_box_meter(&mut ppm_state.lufs);
|
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_l = dbfs(true_peak_l.max(peak_l));
|
||||||
let tp_r = dbfs(estimate_true_peak(&mut ppm_state.tp_history_r, &wave_r).max(peak_r));
|
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_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_ebu_amp_l, ppm_ebu_amp_r) = ppm_state.ebu_ppm.levels();
|
||||||
let ppm_din_l = dbfs(ppm_din_amp_l);
|
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
|
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);
|
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]
|
#[test]
|
||||||
fn silence_and_single_channel_are_neutral() {
|
fn silence_and_single_channel_are_neutral() {
|
||||||
let mut meter = CorrelationMeter::new(48_000, 1.0, 0);
|
let mut meter = CorrelationMeter::new(48_000, 1.0, 0);
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ mod ppm;
|
|||||||
mod routes;
|
mod routes;
|
||||||
mod rta;
|
mod rta;
|
||||||
mod state;
|
mod state;
|
||||||
|
mod true_peak;
|
||||||
|
mod vu;
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:</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>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>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>
|
<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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user