From 2e868bfa3a8aa8f044a28935a85f7a6f00abdcbe Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:50:15 +0200 Subject: [PATCH] Correct audio metering and realtime displays --- TODO.md | 26 +- .../phoenix-global-config.analyzer.json | 2 +- .../phoenix-global-config.volumio.json | 2 +- scripts/test_phase_wheel.mjs | 43 +- scripts/test_rta_profile.mjs | 16 + src/audio.rs | 126 ++---- src/main.rs | 2 + src/model.rs | 13 +- src/phase_wheel.rs | 264 +++++++++++ src/ppm.rs | 68 +-- src/rms.rs | 213 +++++++++ src/routes.rs | 4 + src/state.rs | 51 ++- src/true_peak.rs | 198 ++++++--- www/core/audio.js | 14 +- www/core/config.js | 48 +- www/index.html | 33 +- www/main.js | 5 + www/meters/rms.js | 79 +--- www/meters/tp.js | 68 +-- www/meters/true_peak_scale.js | 22 + www/ui/options.js | 54 +-- www/views/classic_needles.js | 54 ++- www/views/peak_history.js | 68 +-- www/views/phase_wheel.js | 420 +++--------------- www/views/realtime.js | 9 +- 26 files changed, 999 insertions(+), 903 deletions(-) create mode 100644 src/phase_wheel.rs create mode 100644 src/rms.rs create mode 100644 www/meters/true_peak_scale.js diff --git a/TODO.md b/TODO.md index 465b46f..d23f003 100644 --- a/TODO.md +++ b/TODO.md @@ -93,16 +93,15 @@ Diese vorhandenen Funktionen sind nicht automatisch messtechnisch korrekt. Die f - **Geprüft:** RTA-Filtertests laufen bei 44,1, 48 und 96 kHz; die abschließende Prüfung mit real unterschiedlich aushandelnder Hardware bleibt Teil der Geräteabnahme. - **Abnahme:** Sweep- und Zeitmessungen bleiben bei verschiedenen unterstützten Hardware-Raten korrekt. -- [ ] **10. True Peak kontinuierlich und blockübergreifend korrigieren** +- [x] **10. True Peak kontinuierlich und blockübergreifend korrigieren** - **Soll:** Intersample-Peaks werden unabhängig von ihrer Lage zum ALSA-Block zuverlässig erkannt. - - **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. + - **Ist:** Die vierphasige FIR-Interpolation verwendet die zwölf Referenzkoeffizienten aus ITU-R BS.1770 Annex 2 und besitzt eine kontinuierliche Samplehistorie. Die zuvor an Capture-Blöcken übersprungenen Intervalle werden lückenlos ausgewertet. Hauptbalken, klassische Zeigeransicht und Verlauf teilen eine gemeinsame dBTP-Skala mit sichtbarer Übersteuerungsreserve bis +6 dBTP. + - **Geprüft:** EBU-Tech-3341-Testfälle 15 bis 19 einschließlich des +3-dBTP-Intersample-Signals bestehen innerhalb +0,2/-0,4 dB. Synthetische Intersample-Peaks werden oberhalb des Sample-Peaks erkannt; 64, 127, 128, 192 und 511 Samples große Capture-Blöcke liefern denselben Maximalwert. - **Abnahme:** Gleiche Peakwerte unabhängig von Blockgrenze, Periodengröße und Samplerate. - [x] **11. DIN- und EBU-PPM softwareseitig norm- und RTW-nah auslegen** - **Soll:** Richtige Skalen, Referenzpegel, Tonburst-Reaktion, Integration, Rücklauf, Peak Hold, Peak Memory und Over-Anzeige. - - **Ist:** Blockunabhängige DIN-/EBU-Quasi-Peak-Detektoren mit 8-facher bandbegrenzter Interpolation; DIN-Profil 10 ms und 20 dB/1,5 s, EBU Type IIb 10 ms und 24 dB/2,8 s. Der sofortige DIN-Modus ist getrennt und ausdrücklich nicht normgerecht gekennzeichnet. + - **Ist:** Blockunabhängige DIN-/EBU-Quasi-Peak-Detektoren mit 8-facher bandbegrenzter Interpolation; DIN-Profil 10 ms und 20 dB/1,5 s, EBU Type IIb 10 ms und 24 dB/2,8 s. Der alte, nicht normgerechte DIN-Sofortmodus wurde vollständig entfernt; ein eigener DIN-Tonbursttest sichert die feste 10-ms-Integration ab. - **Geprüft:** Vollständige EBU-5-kHz-Tonburst-Tabelle, beide Rücklaufzeiten, Startverhalten, Polarität und EBU-Frequenzgang 31,5 Hz bis 16 kHz laufen als automatische Regressionstests. Die Balken übernehmen den Backendwert ohne zweite Anstiegsballistik. - **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. @@ -111,10 +110,15 @@ Diese vorhandenen Funktionen sind nicht automatisch messtechnisch korrekt. Die f - **Soll VU:** RTW-artige Moving-Coil-Ballistik mit richtigem Einschwingen, Rücklauf und Überschwingen. - **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:** 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. + - **Ist RMS:** True RMS wird samplekontinuierlich aus der linearen Signalleistung berechnet. Fast (125 ms) und Slow (1 s) verwenden exponentielle Leistungsintegration; alternativ steht ein festes gleitendes 300-ms-Fenster bereit. Die frühere Browser-Nachglättung bereits logarithmierter dB-Werte und der fälschlich als RMS angebotene Impulse-Modus sind entfernt. Hauptbalken, Verlauf und klassische Ansicht verwenden denselben Backend-Messwert und dieselbe dBFS-/dBu-Umrechnung. + - **Geprüft:** Stationärer 1-kHz-Sinus ergibt in allen drei Integrationen -3,01 dBFS; 64, 127, 128, 192, 511 und 512 Samples große Capture-Blöcke liefern identische Werte. Fast und Slow werden zusätzlich an ihrer analytischen Sprungantwort 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. +- [x] **12a. RTA-Oktavauflösung zwischen Einzel- und Mehrfachansichten synchronisieren** + - **Ist:** Globale Konfiguration und aktiver DSP konnten durch getrennte API-Aufrufe auseinanderlaufen; beobachtet wurden global 1/6 und gleichzeitig aktiv 1/12. Der Server hält beide Werte jetzt invariant zusammen und repariert auch bereits abweichende Laufzeitzustände. Die eingebettete RTA-Ansicht folgt während des Aktualisierungs-Roundtrips unmittelbar der gewählten Auflösung und kann ein vorhandenes 1/12-Paket verlustfrei auf 1/6 oder 1/3 herunterselektieren. + - **Geprüft:** Regressionstests sichern die Backend-Reparatur sowie die lokale 1/12-zu-1/6-Auswahl einschließlich Average- und Peak-Bändern ab. + - [x] **13. FFT-Modus als optionale Spektrumsansicht fachlich korrigieren** - **Soll:** FFT ist eine korrekte Zusatzansicht, aber nicht die RTW-IIR-Referenz. - **Ist:** Das FFT-RTA summiert einseitige Binenergien mit Hann-Fensterleistungskompensation. Rand-Bins werden entsprechend ihrem tatsächlichen Bandüberlappungsanteil berücksichtigt; es findet keine Normierung auf die Anzahl oder Gesamtgewichtung der Bins mehr statt. Die native FFT-Integration wird im Browser nicht erneut ausgeführt. Der getrennte Spektrogramm-Amplitudenmaßstab bleibt unverändert. @@ -123,6 +127,12 @@ Diese vorhandenen Funktionen sind nicht automatisch messtechnisch korrekt. Die f ## Priorität 3 - Stereoanzeigen und visuelles RTW-Verhalten +- [x] **13a. Phasenrad samplekontinuierlich und bildratenunabhängig berechnen** + - **Ist:** Bandpass, 33-Tap-Hilbert-Transformation und energiegewichtete komplexe L/R-Kreuzleistung laufen mit der nativen Samplerate kontinuierlich im Audiokern. Der Hilbert-FIR verwendet einen dauerhaften Ringpuffer und bleibt deshalb über ALSA-, Transport- und Browsergrenzen lückenlos. + - **Datenweg:** Der Browser erhält mit jedem 60-Hz-Messzustand nur Winkel, Kohärenz, Bandpegel und Bandpeak. Die Phasenrechnung hängt nicht mehr von den für das Goniometer gewählten 128 bis 2048 XY-Punkten ab und belastet den Browser nicht mehr mit Bandpass/Hilbert-DSP. + - **Darstellung:** Winkel- und Radiusglättung verwenden reale Zeitkonstanten statt Faktoren pro Grafikframe. Die AGC wird nur mit einem neuen Messzustand fortgeschrieben und richtet den Bandpeak ohne den früheren zusätzlichen Verstärkungsfaktor auf -15 dB aus. + - **Geprüft:** Automatische Tests prüfen Phasenwinkel und Kohärenz eines Sinustons, einseitige Signale, erhaltene Filter-/Hilbert-Historie über Snapshots sowie identische Glättung bei unterschiedlicher Bildrate. + - [ ] **14. Goniometer-Datenweg und Persistenz optimieren** - **Soll:** Aktuelle XY-Daten erscheinen im nächsten möglichen Bildschirmframe; Fast/Medium/Slow-Persistenz wirkt wie das RTW-Instrument. - **Ist:** M/S-Darstellung, manueller Gain, AGC, Linien/Punkte sowie reproduzierbare Fast-/Medium-/Slow- und freie Phoenix-Persistenz sind vorhanden. @@ -158,8 +168,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. - - **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. + - **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 verwendet den ITU-R-BS.1770-Referenzfilter und prüft EBU-Tech-3341-Fälle 15 bis 19, Intersample-Erkennung und Blockgrenzen; RMS prüft Fast/Slow-Zeitantwort, das exakte 300-ms-Fenster, Pegel und Blockunabhängigkeit. Der Laufzeittest begrenzt zusätzlich das Alter empfangener Messframes auf 500 ms und erkennt damit Transport-Backlogs. + - **Noch offen:** Die transienten EBU-Dateitestfälle 20 bis 23, 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/reference-config/phoenix-global-config.analyzer.json b/reference-config/phoenix-global-config.analyzer.json index 483a1b1..2b19adc 100644 --- a/reference-config/phoenix-global-config.analyzer.json +++ b/reference-config/phoenix-global-config.analyzer.json @@ -11,10 +11,10 @@ "panelDividersEnabled": true, "ppmDinAttackMs": 10.0, "ppmDinDecayDbPerS": 13.333333, - "ppmDinFastAttack": false, "ppmDinLoudnessBoxes": true, "ppmEbuAttackMs": 10.0, "ppmEbuDecayDbPerS": 8.571429, + "rmsIntegration": "fast", "lufsIWindowMin": 4, "lufsINormEnabled": false, "ppmDinLoudnessOffsetDb": 0.0, diff --git a/reference-config/phoenix-global-config.volumio.json b/reference-config/phoenix-global-config.volumio.json index 7ecb3f1..6c35757 100644 --- a/reference-config/phoenix-global-config.volumio.json +++ b/reference-config/phoenix-global-config.volumio.json @@ -11,10 +11,10 @@ "panelDividersEnabled": true, "ppmDinAttackMs": 10.0, "ppmDinDecayDbPerS": 13.333333, - "ppmDinFastAttack": false, "ppmDinLoudnessBoxes": true, "ppmEbuAttackMs": 10.0, "ppmEbuDecayDbPerS": 8.571429, + "rmsIntegration": "fast", "lufsIWindowMin": 4, "lufsINormEnabled": false, "ppmDinLoudnessOffsetDb": 0.0, diff --git a/scripts/test_phase_wheel.mjs b/scripts/test_phase_wheel.mjs index 69a922f..a4c83bf 100644 --- a/scripts/test_phase_wheel.mjs +++ b/scripts/test_phase_wheel.mjs @@ -3,42 +3,17 @@ import fs from 'node:fs'; import vm from 'node:vm'; const source = fs.readFileSync(new URL('../www/views/phase_wheel.js', import.meta.url), 'utf8'); -const functionSource = source.match(/export function summarizeWeightedPhase[\s\S]*?\n\}/)?.[0]; -assert.ok(functionSource, 'weighted phase summary must remain testable'); const context = vm.createContext({ Number, Math }); -vm.runInContext(functionSource.replace('export function', 'function'), context); +const smoothingSource = source.match(/export function smoothingAlpha[\s\S]*?\n\}/)?.[0]; +assert.ok(smoothingSource, 'time-based smoothing must remain testable'); +vm.runInContext(smoothingSource.replace('export function', 'function'), context); -const dominant = context.summarizeWeightedPhase( - new Float32Array([0, Math.PI / 2]), - new Float32Array([100, 1]), - 2, -); -assert.ok(dominant, 'valid weighted phases must produce a summary'); -assert.ok(Math.abs(dominant.angle) < 0.02, - 'a quiet phase component must not pull a dominant component toward an unweighted mean'); +const oneFrame = context.smoothingAlpha(1 / 60, 0.2); +const twoFrames = 1 - Math.pow(1 - oneFrame, 2); +assert.ok(Math.abs(twoFrames - context.smoothingAlpha(1 / 30, 0.2)) < 1e-12, + 'smoothing over equal real time must not depend on the display frame rate'); -const wrapped = context.summarizeWeightedPhase( - new Float32Array([179 * Math.PI / 180, -179 * Math.PI / 180]), - new Float32Array([1, 1]), - 2, -); -assert.ok(Math.abs(Math.abs(wrapped.angle) - Math.PI) < 0.02, - 'circular averaging must preserve the wrap around at 180 degrees'); -assert.ok(wrapped.coherence > 0.99, - 'nearly aligned phase observations must report high coherence'); - -const opposed = context.summarizeWeightedPhase( - new Float32Array([0, Math.PI]), - new Float32Array([1, 1]), - 2, -); -assert.ok(opposed.coherence < 1e-6, - 'opposing phase observations must report that their mean direction is ambiguous'); - -assert.equal(context.summarizeWeightedPhase( - new Float32Array([0]), - new Float32Array([0]), - 1, -), null, 'samples without common L/R energy must not invent a phase angle'); +assert.doesNotMatch(source, /auto\.gain \* PHASE_AGC_BASE_GAIN/, + 'AGC target gain must not be multiplied a second time'); console.log('phase wheel regression tests passed'); diff --git a/scripts/test_rta_profile.mjs b/scripts/test_rta_profile.mjs index 5931462..53c9e84 100644 --- a/scripts/test_rta_profile.mjs +++ b/scripts/test_rta_profile.mjs @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import vm from 'node:vm'; +import { getRtwCenters } from '../www/core/rtw_centers.js'; +import { selectLocalRtwPacket } from '../www/views/realtime.js'; const source = fs.readFileSync(new URL('../www/core/audio.js', import.meta.url), 'utf8'); const functionSource = source.match(/export function buildRtaRuntimeConfig[\s\S]*?\n\}/)?.[0]; @@ -71,4 +73,18 @@ assert.equal(selectionContext.CONFIG.RTA_BAR_LAYOUT, 'rtw'); assert.equal(selectionContext.applyRtaBpoSelection('1_3'), '1_3'); assert.equal(selectionContext.CONFIG.RTA_BAR_LAYOUT, 'rtw'); +const twelfthCenters = getRtwCenters('1_12'); +const staleTwelfthPacket = { + engine: 'iir', + bpo: '1_12', + centers: twelfthCenters, + bands_avg: twelfthCenters.map((_, index) => index), + bands_peak: twelfthCenters.map((_, index) => index + 1000), +}; +const selectedSixth = selectLocalRtwPacket(staleTwelfthPacket, '1_6'); +assert.equal(selectedSixth.bpo, '1_6'); +assert.equal(selectedSixth.centers.length, getRtwCenters('1_6').length); +assert.equal(selectedSixth.bands_avg.length, selectedSixth.centers.length); +assert.equal(selectedSixth.bands_peak.length, selectedSixth.centers.length); + console.log('RTA profile regression tests passed'); diff --git a/src/audio.rs b/src/audio.rs index daf214b..eaeb6d0 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -18,8 +18,12 @@ use crate::goniometer::{selected_sample_indices, GoniometerClock}; #[cfg(target_os = "linux")] use crate::model::{RtaFrame, SpectroFrame, WaveEnvFrame}; #[cfg(target_os = "linux")] +use crate::phase_wheel::PhaseWheelAnalyzer; +#[cfg(target_os = "linux")] use crate::ppm::{PpmDetector, PpmStandard}; #[cfg(target_os = "linux")] +use crate::rms::{RmsIntegration, TrueRmsDetector}; +#[cfg(target_os = "linux")] use crate::rta::{ design_fractional_octave_band, design_legacy_repeated_band, exact_fractional_octave_center, fractional_octave_edges, integrate_power, integrate_power_asymmetric, normalize_weighting, @@ -82,7 +86,6 @@ const BOX_MIN_DB: f32 = -90.0; #[cfg(target_os = "linux")] const BOX_MAX_DB: f32 = 9.0; #[cfg(target_os = "linux")] -const VU_WINDOW_MS: f32 = 300.0; #[cfg(target_os = "linux")] const NATIVE_RECORDER_DISCONTINUITY_BLEND_FRAMES: usize = 256; #[cfg(target_os = "linux")] @@ -335,16 +338,6 @@ struct LoudnessBiquadState { y2: f32, } -struct MovingAverageWindow { - window_len: usize, - ring_l: Vec, - ring_r: Vec, - pos: usize, - fill: usize, - sum_l: f64, - sum_r: f64, -} - #[cfg(target_os = "linux")] struct LufsState { sample_rate: u32, @@ -438,6 +431,10 @@ pub fn spawn_audio_capture_worker(deps: AudioWorkerDeps) { period_size: deps.config.period_size, correlation: 0.0, correlation_negative_peak: 0.0, + phase_angle_rad: None, + phase_coherence: 0.0, + phase_level: 0.0, + phase_peak: 0.0, rms_l: -120.0, rms_r: -120.0, vu_l: -120.0, @@ -578,7 +575,7 @@ struct PpmState { din_ppm: PpmDetector, ebu_ppm: PpmDetector, vu_meter: VuMeter, - rms_window: MovingAverageWindow, + rms_meter: TrueRmsDetector, rta_signature: String, rta_state: Option, spectro_signature: String, @@ -599,6 +596,7 @@ struct PpmState { transport_clock: GoniometerClock, transport_peaks: TransportPeaks, correlation: CorrelationMeter, + phase_wheel: PhaseWheelAnalyzer, xy_pending_l: Vec, xy_pending_r: Vec, } @@ -610,7 +608,7 @@ impl Default for PpmState { din_ppm: PpmDetector::new(48_000, PpmStandard::Din), ebu_ppm: PpmDetector::new(48_000, PpmStandard::EbuTypeIib), vu_meter: VuMeter::new(48_000), - rms_window: create_moving_average_window(48_000, VU_WINDOW_MS), + rms_meter: TrueRmsDetector::new(48_000, RmsIntegration::Fast), rta_signature: String::new(), rta_state: None, spectro_signature: String::new(), @@ -631,6 +629,7 @@ impl Default for PpmState { transport_clock: GoniometerClock::default(), transport_peaks: TransportPeaks::default(), correlation: CorrelationMeter::new(48_000, 1.0, 0), + phase_wheel: PhaseWheelAnalyzer::new(48_000), xy_pending_l: Vec::with_capacity(1024), xy_pending_r: Vec::with_capacity(1024), } @@ -779,50 +778,6 @@ fn wave_env_flush(state: &mut WaveEnvState) -> Option { }) } -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; - MovingAverageWindow { - window_len, - ring_l: vec![0.0; window_len], - ring_r: vec![0.0; window_len], - pos: 0, - fill: 0, - sum_l: 0.0, - sum_r: 0.0, - } -} - -#[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() - .max(1.0) as usize; - if state.window_len == desired_len { - return; - } - *state = create_moving_average_window(sample_rate, window_ms); -} - -fn moving_average_push(state: &mut MovingAverageWindow, l: f32, r: f32) -> (f32, f32) { - if state.fill < state.window_len { - state.fill += 1; - } else { - state.sum_l -= f64::from(state.ring_l[state.pos]); - state.sum_r -= f64::from(state.ring_r[state.pos]); - } - state.ring_l[state.pos] = l; - state.ring_r[state.pos] = r; - state.sum_l += f64::from(l); - state.sum_r += f64::from(r); - state.pos = (state.pos + 1) % state.window_len; - let denom = state.fill.max(1) as f32; - ( - (state.sum_l / denom as f64) as f32, - (state.sum_r / denom as f64) as f32, - ) -} - #[cfg(target_os = "linux")] fn create_biquad_from_coeffs(b0: f32, b1: f32, b2: f32, a1: f32, a2: f32) -> LoudnessBiquadState { LoudnessBiquadState { @@ -1198,25 +1153,25 @@ fn process_audio_block( rta_config: &PhoenixRtaConfig, spectro_requested: bool, ) -> (Option, Option) { - let mut rms_power_l = 0.0f32; - let mut rms_power_r = 0.0f32; + let mut rms_amp_l = 0.0f32; + let mut rms_amp_r = 0.0f32; let mut vu_l_amp = 0.0f32; let mut vu_r_amp = 0.0f32; let frames = interleaved.len() / 2; ensure_wave_env_state(&mut ppm_state.wave_env, sample_rate); ensure_lufs_state(&mut ppm_state.lufs, sample_rate); - let din_standard = if rta_config.ppm_din_fast_attack { - PpmStandard::DinSample - } else { - PpmStandard::Din - }; - ppm_state.din_ppm.ensure_profile(sample_rate, din_standard); + ppm_state + .din_ppm + .ensure_profile(sample_rate, PpmStandard::Din); ppm_state .ebu_ppm .ensure_profile(sample_rate, PpmStandard::EbuTypeIib); ppm_state.vu_meter.ensure_sample_rate(sample_rate); - ensure_moving_average_window(&mut ppm_state.rms_window, sample_rate, VU_WINDOW_MS); + ppm_state.rms_meter.configure( + sample_rate, + RmsIntegration::from_config(&rta_config.rms_integration), + ); 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)); @@ -1229,6 +1184,7 @@ fn process_audio_block( ensure_rta_state(ppm_state, sample_rate, rta_config); ensure_spectro_state(ppm_state, sample_rate, rta_config); ensure_lr_delay_state(ppm_state, rta_config); + ppm_state.phase_wheel.configure(sample_rate); for frame in interleaved.chunks_exact(2) { let mut l = ((frame[0] as f32) / 32768.0) * gain_l; @@ -1242,7 +1198,7 @@ fn process_audio_block( wave_env_accumulate(&mut ppm_state.wave_env, l, r, 2); process_lufs_sample(&mut ppm_state.lufs, l, r, rta_config); - (rms_power_l, rms_power_r) = moving_average_push(&mut ppm_state.rms_window, l * l, r * r); + (rms_amp_l, rms_amp_r) = ppm_state.rms_meter.process(l, r); let abs_l = l.abs(); let abs_r = r.abs(); ppm_state.din_ppm.process(l, r); @@ -1255,6 +1211,7 @@ fn process_audio_block( .observe(abs_l, abs_r, true_peak_l, true_peak_r); ppm_state.correlation.process(l, r); + ppm_state.phase_wheel.process(l, r); ppm_state.xy_pending_l.push(l); ppm_state.xy_pending_r.push(r); @@ -1313,8 +1270,8 @@ fn process_audio_block( let tp_l = dbfs(transport_peak_l); let tp_r = dbfs(transport_peak_r); - let rms_l = dbfs(rms_power_l.max(0.0).sqrt()); - let rms_r = dbfs(rms_power_r.max(0.0).sqrt()); + let rms_l = dbfs(rms_amp_l); + let rms_r = dbfs(rms_amp_r); let vu_l = dbfs(vu_l_amp); let vu_r = dbfs(vu_r_amp); update_box_meter(&mut ppm_state.lufs); @@ -1326,6 +1283,7 @@ fn process_audio_block( let ppm_ebu_r = dbfs(ppm_ebu_amp_r); let wave_env = wave_env_flush(&mut ppm_state.wave_env); let (xy_l, xy_r) = take_goniometer_samples(ppm_state, rta_config.xy_points as usize); + let phase = ppm_state.phase_wheel.take_snapshot(); let frame = MeterFrame { seq: seq.fetch_add(1, Ordering::Relaxed) + 1, @@ -1337,6 +1295,10 @@ fn process_audio_block( period_size: frames.min(u32::MAX as usize) as u32, correlation: ppm_state.correlation.value(), correlation_negative_peak: ppm_state.correlation.negative_peak(), + phase_angle_rad: phase.angle_rad, + phase_coherence: phase.coherence, + phase_level: phase.level, + phase_peak: phase.peak, rms_l, rms_r, vu_l, @@ -2323,32 +2285,6 @@ mod tests { bins[tone_bin] } - 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); - } - } - #[test] fn fft_integrated_energy_compensates_hann_window_and_fft_size() { let expected = 0.5f32; diff --git a/src/main.rs b/src/main.rs index a2600c6..d77e3ab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,9 @@ mod config; mod correlation; mod goniometer; mod model; +mod phase_wheel; mod ppm; +mod rms; mod routes; mod rta; mod state; diff --git a/src/model.rs b/src/model.rs index bb8b65c..c8cfc2c 100644 --- a/src/model.rs +++ b/src/model.rs @@ -76,9 +76,9 @@ pub struct PhoenixRtaConfig { pub input_offset_db_r: f32, pub ppm_din_attack_ms: f32, pub ppm_din_decay_db_per_s: f32, - pub ppm_din_fast_attack: bool, pub ppm_ebu_attack_ms: f32, pub ppm_ebu_decay_db_per_s: f32, + pub rms_integration: String, pub lufs_i_window_min: u32, pub lufs_i_norm_enabled: bool, pub correlation_response_s: f32, @@ -112,9 +112,9 @@ impl Default for PhoenixRtaConfig { input_offset_db_r: -5.0, ppm_din_attack_ms: 10.0, ppm_din_decay_db_per_s: 20.0 / 1.5, - ppm_din_fast_attack: false, ppm_ebu_attack_ms: 10.0, ppm_ebu_decay_db_per_s: 24.0 / 2.8, + rms_integration: "fast".to_string(), lufs_i_window_min: 4, lufs_i_norm_enabled: false, correlation_response_s: 1.0, @@ -140,9 +140,9 @@ pub struct PhoenixGlobalConfig { pub panel_dividers_enabled: bool, pub ppm_din_attack_ms: f32, pub ppm_din_decay_db_per_s: f32, - pub ppm_din_fast_attack: bool, pub ppm_ebu_attack_ms: f32, pub ppm_ebu_decay_db_per_s: f32, + pub rms_integration: String, pub lufs_i_window_min: u32, pub lufs_i_norm_enabled: bool, pub ppm_din_loudness_boxes: bool, @@ -209,9 +209,9 @@ impl Default for PhoenixGlobalConfig { panel_dividers_enabled: true, ppm_din_attack_ms: 10.0, ppm_din_decay_db_per_s: 20.0 / 1.5, - ppm_din_fast_attack: false, ppm_ebu_attack_ms: 10.0, ppm_ebu_decay_db_per_s: 24.0 / 2.8, + rms_integration: "fast".to_string(), lufs_i_window_min: 4, lufs_i_norm_enabled: false, ppm_din_loudness_boxes: true, @@ -279,6 +279,11 @@ pub struct MeterFrame { pub period_size: u32, pub correlation: f32, pub correlation_negative_peak: f32, + #[serde(skip_serializing_if = "Option::is_none")] + pub phase_angle_rad: Option, + pub phase_coherence: f32, + pub phase_level: f32, + pub phase_peak: f32, pub rms_l: f32, pub rms_r: f32, pub vu_l: f32, diff --git a/src/phase_wheel.rs b/src/phase_wheel.rs new file mode 100644 index 0000000..1b29541 --- /dev/null +++ b/src/phase_wheel.rs @@ -0,0 +1,264 @@ +//! Continuous phase-wheel analysis on the native-rate audio stream. + +const HILBERT_TAPS: usize = 33; +const HILBERT_HALF: usize = (HILBERT_TAPS - 1) / 2; +const BANDPASS_LOW_HZ: f64 = 300.0; +const BANDPASS_HIGH_HZ: f64 = 5_000.0; + +#[derive(Clone, Copy, Debug, Default)] +pub struct PhaseWheelSnapshot { + pub angle_rad: Option, + pub coherence: f32, + pub level: f32, + pub peak: f32, +} + +#[derive(Clone, Copy, Debug, Default)] +struct BandpassChannel { + hp_x: f64, + hp_y: f64, + lp_y: f64, +} + +impl BandpassChannel { + fn process(&mut self, sample: f64, hp_alpha: f64, lp_alpha: f64) -> f64 { + let hp = hp_alpha * (self.hp_y + sample - self.hp_x); + self.hp_x = sample; + self.hp_y = hp; + self.lp_y = lp_alpha * hp + (1.0 - lp_alpha) * self.lp_y; + self.lp_y + } +} + +pub struct PhaseWheelAnalyzer { + sample_rate: u32, + hp_alpha: f64, + lp_alpha: f64, + band_l: BandpassChannel, + band_r: BandpassChannel, + ring_l: [f64; HILBERT_TAPS], + ring_r: [f64; HILBERT_TAPS], + hilbert: [f64; HILBERT_TAPS], + write: usize, + fill: usize, + cross_re: f64, + cross_im: f64, + weight_sum: f64, + amplitude_sum: f64, + amplitude_peak: f64, + count: usize, +} + +impl PhaseWheelAnalyzer { + pub fn new(sample_rate: u32) -> Self { + let mut analyzer = Self { + sample_rate: 0, + hp_alpha: 0.0, + lp_alpha: 0.0, + band_l: BandpassChannel::default(), + band_r: BandpassChannel::default(), + ring_l: [0.0; HILBERT_TAPS], + ring_r: [0.0; HILBERT_TAPS], + hilbert: build_hilbert_kernel(), + write: 0, + fill: 0, + cross_re: 0.0, + cross_im: 0.0, + weight_sum: 0.0, + amplitude_sum: 0.0, + amplitude_peak: 0.0, + count: 0, + }; + analyzer.configure(sample_rate); + analyzer + } + + pub fn configure(&mut self, sample_rate: u32) { + let rate = sample_rate.max(8_000); + if self.sample_rate == rate { + return; + } + self.sample_rate = rate; + self.hp_alpha = highpass_alpha(rate, BANDPASS_LOW_HZ); + self.lp_alpha = lowpass_alpha(rate, BANDPASS_HIGH_HZ); + self.band_l = BandpassChannel::default(); + self.band_r = BandpassChannel::default(); + self.ring_l.fill(0.0); + self.ring_r.fill(0.0); + self.write = 0; + self.fill = 0; + self.clear_accumulator(); + } + + pub fn process(&mut self, left: f32, right: f32) { + let filtered_l = self + .band_l + .process(left as f64, self.hp_alpha, self.lp_alpha); + let filtered_r = self + .band_r + .process(right as f64, self.hp_alpha, self.lp_alpha); + self.ring_l[self.write] = filtered_l; + self.ring_r[self.write] = filtered_r; + self.write = (self.write + 1) % HILBERT_TAPS; + self.fill = (self.fill + 1).min(HILBERT_TAPS); + if self.fill < HILBERT_TAPS { + return; + } + + // `write` points at the oldest sample. The real component is delayed + // by half the FIR length, so it is aligned with the causal Hilbert FIR. + let real_index = (self.write + HILBERT_HALF) % HILBERT_TAPS; + let l_re = self.ring_l[real_index].clamp(-1.0, 1.0); + let r_re = self.ring_r[real_index].clamp(-1.0, 1.0); + let mut l_im = 0.0; + let mut r_im = 0.0; + // The ideal odd Hilbert kernel has zero coefficients at every even + // offset; with a 33-tap kernel those are the even tap indices. + for tap in (1..HILBERT_TAPS).step_by(2) { + let index = (self.write + tap) % HILBERT_TAPS; + l_im += self.ring_l[index] * self.hilbert[tap]; + r_im += self.ring_r[index] * self.hilbert[tap]; + } + + let mag_l = l_re.hypot(l_im).min(1.0); + let mag_r = r_re.hypot(r_im).min(1.0); + let weight = mag_l * mag_r; + // zL * conj(zR): its argument is the energy-weighted L/R phase. + self.cross_re += l_re * r_re + l_im * r_im; + self.cross_im += l_im * r_re - l_re * r_im; + self.weight_sum += weight; + let amplitude = 0.5 * (mag_l + mag_r); + self.amplitude_sum += amplitude; + self.amplitude_peak = self.amplitude_peak.max(amplitude); + self.count += 1; + } + + pub fn take_snapshot(&mut self) -> PhaseWheelSnapshot { + let level = if self.count > 0 { + (self.amplitude_sum / self.count as f64) as f32 + } else { + 0.0 + }; + let resultant = self.cross_re.hypot(self.cross_im); + let angle_rad = if self.weight_sum > 1e-12 && resultant > self.weight_sum * 1e-9 { + Some(self.cross_im.atan2(self.cross_re) as f32) + } else { + None + }; + let coherence = if self.weight_sum > 1e-12 { + (resultant / self.weight_sum).clamp(0.0, 1.0) as f32 + } else { + 0.0 + }; + let snapshot = PhaseWheelSnapshot { + angle_rad, + coherence, + level, + peak: self.amplitude_peak as f32, + }; + self.clear_accumulator(); + snapshot + } + + fn clear_accumulator(&mut self) { + self.cross_re = 0.0; + self.cross_im = 0.0; + self.weight_sum = 0.0; + self.amplitude_sum = 0.0; + self.amplitude_peak = 0.0; + self.count = 0; + } +} + +fn highpass_alpha(sample_rate: u32, cutoff: f64) -> f64 { + let rc = 1.0 / (2.0 * std::f64::consts::PI * cutoff.max(1.0)); + let dt = 1.0 / sample_rate.max(1) as f64; + (rc / (rc + dt)).clamp(0.0, 1.0) +} + +fn lowpass_alpha(sample_rate: u32, cutoff: f64) -> f64 { + let rc = 1.0 / (2.0 * std::f64::consts::PI * cutoff.max(1.0)); + let dt = 1.0 / sample_rate.max(1) as f64; + (dt / (rc + dt)).clamp(0.0, 1.0) +} + +fn build_hilbert_kernel() -> [f64; HILBERT_TAPS] { + let mut kernel = [0.0; HILBERT_TAPS]; + for (index, value) in kernel.iter_mut().enumerate() { + let offset = index as isize - HILBERT_HALF as isize; + if offset == 0 || offset % 2 == 0 { + continue; + } + let window = 0.54 + - 0.46 + * ((2.0 * std::f64::consts::PI * index as f64) / (HILBERT_TAPS - 1) as f64).cos(); + *value = 2.0 / (std::f64::consts::PI * offset as f64) * window; + } + kernel +} + +#[cfg(test)] +mod tests { + use super::*; + + fn feed_tone(analyzer: &mut PhaseWheelAnalyzer, phase: f64, samples: usize) { + let omega = 2.0 * std::f64::consts::PI * 1_000.0 / 48_000.0; + for index in 0..samples { + let t = omega * index as f64; + analyzer.process((0.5 * t.sin()) as f32, (0.5 * (t - phase).sin()) as f32); + } + } + + #[test] + fn continuous_analyzer_tracks_tone_phase() { + let mut analyzer = PhaseWheelAnalyzer::new(48_000); + feed_tone(&mut analyzer, std::f64::consts::FRAC_PI_2, 4_800); + let snapshot = analyzer.take_snapshot(); + let angle = snapshot.angle_rad.expect("coherent tone has a phase"); + assert!((angle.abs() - std::f32::consts::FRAC_PI_2).abs() < 0.03); + assert!( + snapshot.coherence > 0.9, + "coherence was {}", + snapshot.coherence + ); + assert!(snapshot.level > 0.1); + assert!(snapshot.peak >= snapshot.level); + } + + #[test] + fn snapshot_reset_does_not_reset_filter_or_hilbert_history() { + let mut analyzer = PhaseWheelAnalyzer::new(48_000); + feed_tone(&mut analyzer, 0.4, 2_400); + let first = analyzer.take_snapshot().angle_rad.unwrap(); + feed_tone(&mut analyzer, 0.4, 800); + let second = analyzer.take_snapshot().angle_rad.unwrap(); + assert!((first - second).abs() < 0.03); + } + + #[test] + fn one_sided_signal_does_not_invent_a_phase() { + let mut analyzer = PhaseWheelAnalyzer::new(48_000); + for index in 0..2_400 { + let t = 2.0 * std::f64::consts::PI * 1_000.0 * index as f64 / 48_000.0; + analyzer.process((0.5 * t.sin()) as f32, 0.0); + } + let snapshot = analyzer.take_snapshot(); + assert!(snapshot.angle_rad.is_none()); + assert_eq!(snapshot.coherence, 0.0); + } + + #[test] + fn energetic_component_dominates_a_quiet_conflicting_tone() { + let mut analyzer = PhaseWheelAnalyzer::new(48_000); + for index in 0..9_600 { + let t = index as f64 / 48_000.0; + let strong = 2.0 * std::f64::consts::PI * 1_000.0 * t; + let quiet = 2.0 * std::f64::consts::PI * 2_000.0 * t; + let left = 0.5 * strong.sin() + 0.04 * quiet.sin(); + let right = 0.5 * strong.sin() + 0.04 * (quiet - std::f64::consts::FRAC_PI_2).sin(); + analyzer.process(left as f32, right as f32); + } + let angle = analyzer.take_snapshot().angle_rad.unwrap(); + assert!(angle.abs() < 0.03, "quiet tone pulled phase to {angle}"); + } +} diff --git a/src/ppm.rs b/src/ppm.rs index 9a025c0..385da9e 100644 --- a/src/ppm.rs +++ b/src/ppm.rs @@ -2,9 +2,10 @@ //! //! The detector is deliberately independent of ALSA block boundaries. Its two //! attack branches are calibrated against the 5 kHz tone-burst response in EBU -//! Tech 3205-E. DIN uses the RTW PortaMonitor/Peakmeter norm profile (10 ms -//! integration, 20 dB return in 1.5 s). The optional DIN sample mode is kept -//! separate and must never be labelled as a standards-compliant DIN reading. +//! Tech 3205-E. DIN uses the DIN 45406 / IEC 60268-10 profile (10 ms +//! integration, 20 dB return in 1.5 s). Both standards define the normal +//! quasi-peak attack by the 5 kHz, 10 ms burst reaching 2 dB below the +//! continuous-tone indication; their normal-mode return times differ. #![cfg_attr(not(target_os = "linux"), allow(dead_code))] @@ -19,21 +20,16 @@ const INTERP_TAPS: usize = INTERP_RADIUS * 2 + 1; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PpmStandard { Din, - DinSample, EbuTypeIib, } impl PpmStandard { fn return_db_per_second(self) -> f32 { match self { - Self::Din | Self::DinSample => 20.0 / 1.5, + Self::Din => 20.0 / 1.5, Self::EbuTypeIib => 24.0 / 2.8, } } - - fn uses_sample_attack(self) -> bool { - matches!(self, Self::DinSample) - } } #[derive(Clone, Copy, Debug, Default)] @@ -61,11 +57,7 @@ pub struct PpmDetector { impl PpmDetector { pub fn new(sample_rate: u32, standard: PpmStandard) -> Self { let sr = sample_rate.max(8_000); - let detector_rate = if standard.uses_sample_attack() { - sr as f32 - } else { - (sr * OVERSAMPLE as u32) as f32 - }; + let detector_rate = (sr * OVERSAMPLE as u32) as f32; let coeff = |tau_s: f32| (-1.0 / (detector_rate * tau_s)).exp(); let release_coeff = 10.0f32.powf(-standard.return_db_per_second() / (20.0 * detector_rate)); Self { @@ -91,31 +83,10 @@ impl PpmDetector { } pub fn process(&mut self, left: f32, right: f32) -> (f32, f32) { - if self.standard.uses_sample_attack() { - let l = Self::process_channel( - &mut self.left, - left.abs(), - self.standard, - self.fast_coeff, - self.slow_coeff, - self.release_coeff, - ); - let r = Self::process_channel( - &mut self.right, - right.abs(), - self.standard, - self.fast_coeff, - self.slow_coeff, - self.release_coeff, - ); - return (l, r); - } - self.raw_l[self.raw_pos] = left; self.raw_r[self.raw_pos] = right; self.raw_pos = (self.raw_pos + 1) % INTERP_TAPS; - let standard = self.standard; let fast_coeff = self.fast_coeff; let slow_coeff = self.slow_coeff; let release_coeff = self.release_coeff; @@ -131,7 +102,6 @@ impl PpmDetector { Self::process_channel( &mut self.left, l.abs(), - standard, fast_coeff, slow_coeff, release_coeff, @@ -139,7 +109,6 @@ impl PpmDetector { Self::process_channel( &mut self.right, r.abs(), - standard, fast_coeff, slow_coeff, release_coeff, @@ -183,18 +152,10 @@ impl PpmDetector { fn process_channel( state: &mut PpmChannel, input: f32, - standard: PpmStandard, fast_coeff: f32, slow_coeff: f32, release_coeff: f32, ) -> f32 { - if standard.uses_sample_attack() { - state.output = input.max(state.output * release_coeff); - state.fast = state.output; - state.slow = state.output; - return state.output; - } - state.fast = if input > state.fast { fast_coeff * state.fast + (1.0 - fast_coeff) * input } else { @@ -273,6 +234,23 @@ mod tests { } } + #[test] + fn din_matches_normative_ten_millisecond_tone_burst() { + // DIN 45406 / IEC 60268-10 integration time: a 5 kHz burst at + // reference level must indicate 2 dB below the continuous-tone value + // after 10 ms. RTW specifies the same 10 ms normal integration for + // its DIN peakmeters. + let mut continuous = PpmDetector::new(SR, PpmStandard::Din); + let reference = run_tone(&mut continuous, 5_000.0, 0.5, 500.0); + let mut detector = PpmDetector::new(SR, PpmStandard::Din); + let measured = run_tone(&mut detector, 5_000.0, 0.5, 10.0); + let relative_db = db(measured / reference); + assert!( + (relative_db - (-2.0)).abs() <= 0.5, + "10 ms: measured {relative_db:.3} dB, expected -2.000 +/- 0.500 dB" + ); + } + #[test] fn ebu_return_time_is_24_db_in_2_8_seconds() { assert_return_time(PpmStandard::EbuTypeIib, 24.0, 2.8, 0.02); diff --git a/src/rms.rs b/src/rms.rs new file mode 100644 index 0000000..767d6f4 --- /dev/null +++ b/src/rms.rs @@ -0,0 +1,213 @@ +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RmsIntegration { + Fast, + Slow, + Window300, +} + +impl RmsIntegration { + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + pub fn from_config(value: &str) -> Self { + match value.trim().to_ascii_lowercase().as_str() { + "slow" => Self::Slow, + "window" | "window300" | "none" => Self::Window300, + _ => Self::Fast, + } + } +} + +/// Sample-continuous true-RMS detector. +/// +/// Time weighting is applied to linear squared samples. Taking the square root +/// and converting to decibels happens only after integration. This is important: +/// averaging already-logarithmic dB values does not produce an RMS value. +pub struct TrueRmsDetector { + sample_rate: u32, + mode: RmsIntegration, + power_l: f64, + power_r: f64, + fast_alpha: f64, + slow_alpha: f64, + window_l: Vec, + window_r: Vec, + window_pos: usize, + window_fill: usize, + window_sum_l: f64, + window_sum_r: f64, +} + +impl TrueRmsDetector { + pub fn new(sample_rate: u32, mode: RmsIntegration) -> Self { + let mut detector = Self { + sample_rate: 0, + mode, + power_l: 0.0, + power_r: 0.0, + fast_alpha: 0.0, + slow_alpha: 0.0, + window_l: Vec::new(), + window_r: Vec::new(), + window_pos: 0, + window_fill: 0, + window_sum_l: 0.0, + window_sum_r: 0.0, + }; + detector.configure(sample_rate, mode); + detector + } + + pub fn configure(&mut self, sample_rate: u32, mode: RmsIntegration) { + let sample_rate = sample_rate.max(8_000); + if self.sample_rate == sample_rate && self.mode == mode { + return; + } + self.sample_rate = sample_rate; + self.mode = mode; + self.fast_alpha = alpha(sample_rate, 0.125); + self.slow_alpha = alpha(sample_rate, 1.0); + if mode == RmsIntegration::Window300 { + let window_len = ((sample_rate as f64 * 0.300).round() as usize).max(1); + self.window_l = vec![0.0; window_len]; + self.window_r = vec![0.0; window_len]; + } else { + self.window_l.clear(); + self.window_r.clear(); + } + self.reset(); + } + + pub fn reset(&mut self) { + self.power_l = 0.0; + self.power_r = 0.0; + self.window_l.fill(0.0); + self.window_r.fill(0.0); + self.window_pos = 0; + self.window_fill = 0; + self.window_sum_l = 0.0; + self.window_sum_r = 0.0; + } + + pub fn process(&mut self, left: f32, right: f32) -> (f32, f32) { + let input_l = f64::from(left) * f64::from(left); + let input_r = f64::from(right) * f64::from(right); + match self.mode { + RmsIntegration::Fast => { + self.power_l += self.fast_alpha * (input_l - self.power_l); + self.power_r += self.fast_alpha * (input_r - self.power_r); + } + RmsIntegration::Slow => { + self.power_l += self.slow_alpha * (input_l - self.power_l); + self.power_r += self.slow_alpha * (input_r - self.power_r); + } + RmsIntegration::Window300 => self.process_window(input_l, input_r), + } + ( + self.power_l.max(0.0).sqrt() as f32, + self.power_r.max(0.0).sqrt() as f32, + ) + } + + fn process_window(&mut self, input_l: f64, input_r: f64) { + if self.window_fill < self.window_l.len() { + self.window_fill += 1; + } else { + self.window_sum_l -= self.window_l[self.window_pos]; + self.window_sum_r -= self.window_r[self.window_pos]; + } + self.window_l[self.window_pos] = input_l; + self.window_r[self.window_pos] = input_r; + self.window_sum_l += input_l; + self.window_sum_r += input_r; + self.window_pos = (self.window_pos + 1) % self.window_l.len(); + let denom = self.window_fill.max(1) as f64; + self.power_l = self.window_sum_l / denom; + self.power_r = self.window_sum_r / denom; + } +} + +fn alpha(sample_rate: u32, tau_seconds: f64) -> f64 { + 1.0 - (-1.0 / (sample_rate as f64 * tau_seconds)).exp() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sine_rms(mode: RmsIntegration, block_size: usize) -> f32 { + let sample_rate = 48_000u32; + let mut detector = TrueRmsDetector::new(sample_rate, mode); + let samples: Vec = (0..sample_rate * 12) + .map(|index| { + (2.0 * std::f32::consts::PI * 1_000.0 * index as f32 / sample_rate as f32).sin() + }) + .collect(); + let mut result = 0.0; + for block in samples.chunks(block_size) { + for &sample in block { + result = detector.process(sample, sample).0; + } + } + result + } + + #[test] + fn sine_level_is_true_rms_for_every_integration() { + for mode in [ + RmsIntegration::Fast, + RmsIntegration::Slow, + RmsIntegration::Window300, + ] { + let measured = sine_rms(mode, 127); + assert!( + (measured - std::f32::consts::FRAC_1_SQRT_2).abs() < 2.0e-3, + "{mode:?}: {measured}" + ); + } + } + + #[test] + fn result_is_independent_of_capture_blocks() { + for mode in [ + RmsIntegration::Fast, + RmsIntegration::Slow, + RmsIntegration::Window300, + ] { + let reference = sine_rms(mode, 1); + for block_size in [64, 127, 128, 192, 511, 512] { + assert!((sine_rms(mode, block_size) - reference).abs() < 1.0e-7); + } + } + } + + #[test] + fn fast_and_slow_apply_the_declared_power_time_constants() { + let sample_rate = 48_000; + for (mode, tau) in [(RmsIntegration::Fast, 0.125), (RmsIntegration::Slow, 1.0)] { + let mut detector = TrueRmsDetector::new(sample_rate, mode); + let samples = (sample_rate as f64 * tau).round() as usize; + let mut value = 0.0; + for _ in 0..samples { + value = detector.process(1.0, 1.0).0; + } + let expected = (1.0f64 - (-1.0f64).exp()).sqrt() as f32; + assert!((value - expected).abs() < 2.0e-5, "{mode:?}: {value}"); + } + } + + #[test] + fn rectangular_window_is_exactly_300_milliseconds() { + for sample_rate in [44_100, 48_000, 96_000] { + let mut detector = TrueRmsDetector::new(sample_rate, RmsIntegration::Window300); + let window_len = (sample_rate as f64 * 0.300).round() as usize; + assert_eq!(detector.window_l.len(), window_len); + let mut value = detector.process(1.0, 0.5).0; + for _ in 1..window_len { + value = detector.process(0.0, 0.0).0; + } + assert!(value > 0.0); + let (left, right) = detector.process(0.0, 0.0); + assert_eq!(left, 0.0); + assert_eq!(right, 0.0); + } + } +} diff --git a/src/routes.rs b/src/routes.rs index 2d75678..f9a2e03 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1281,6 +1281,10 @@ mod tests { period_size: 128, correlation: 0.25, correlation_negative_peak: -0.5, + phase_angle_rad: Some(0.25), + phase_coherence: 0.75, + phase_level: 0.2, + phase_peak: 0.3, rms_l: -20.0, rms_r: -21.0, vu_l: -20.0, diff --git a/src/state.rs b/src/state.rs index d374473..b1c8e78 100644 --- a/src/state.rs +++ b/src/state.rs @@ -167,7 +167,10 @@ impl AppState { let updated_global = PhoenixGlobalConfig { fft_size: normalized.fft_size, input_source: current.input_source, - rta_bpo_mode: current.rta_bpo_mode.clone(), + // RTA resolution has one authoritative value. Keeping the old + // global value here allowed /rta-config and /global-config to + // disagree until the service was restarted. + rta_bpo_mode: normalized.bpo.clone(), input_offset_db_l: normalized.input_offset_db_l, input_offset_db_r: normalized.input_offset_db_r, mono_input: normalized.mono_input, @@ -178,9 +181,9 @@ impl AppState { panel_dividers_enabled: current.panel_dividers_enabled, ppm_din_attack_ms: normalized.ppm_din_attack_ms, ppm_din_decay_db_per_s: normalized.ppm_din_decay_db_per_s, - ppm_din_fast_attack: normalized.ppm_din_fast_attack, ppm_ebu_attack_ms: normalized.ppm_ebu_attack_ms, ppm_ebu_decay_db_per_s: normalized.ppm_ebu_decay_db_per_s, + rms_integration: normalized.rms_integration.clone(), lufs_i_window_min: normalized.lufs_i_window_min, lufs_i_norm_enabled: normalized.lufs_i_norm_enabled, ppm_din_loudness_boxes: current.ppm_din_loudness_boxes, @@ -260,6 +263,15 @@ impl AppState { let normalized = normalize_global_config(payload, &self.config); let current_global = self.global_config.read().await.clone(); if current_global == normalized { + // A legacy or racing /rta-config request may have changed the + // runtime engine without changing the persisted global config. + // Re-apply the authoritative global fields even when persistence + // itself does not need an update. + let repaired_rta = { + let current = self.rta_config.read().await.clone(); + apply_global_to_rta(current, &normalized) + }; + *self.rta_config.write().await = repaired_rta; return Ok(PhoenixGlobalConfigEnvelope { revision: self.global_config_revision(), config: current_global, @@ -462,9 +474,9 @@ fn normalize_rta_config(mut config: PhoenixRtaConfig) -> PhoenixRtaConfig { // compatibility with older clients without allowing silent mistuning. config.ppm_din_attack_ms = 10.0; config.ppm_din_decay_db_per_s = 20.0 / 1.5; - config.ppm_din_fast_attack = !!config.ppm_din_fast_attack; config.ppm_ebu_attack_ms = 10.0; config.ppm_ebu_decay_db_per_s = 24.0 / 2.8; + config.rms_integration = normalize_rms_integration(&config.rms_integration); config.lufs_i_window_min = config.lufs_i_window_min.clamp(1, 10); config.lufs_i_norm_enabled = !!config.lufs_i_norm_enabled; config.correlation_response_s = @@ -550,9 +562,9 @@ fn normalize_global_config( config.panel_dividers_enabled = !!config.panel_dividers_enabled; config.ppm_din_attack_ms = 10.0; config.ppm_din_decay_db_per_s = 20.0 / 1.5; - config.ppm_din_fast_attack = !!config.ppm_din_fast_attack; config.ppm_ebu_attack_ms = 10.0; config.ppm_ebu_decay_db_per_s = 24.0 / 2.8; + config.rms_integration = normalize_rms_integration(&config.rms_integration); config.lufs_i_window_min = config.lufs_i_window_min.clamp(1, 10); config.lufs_i_norm_enabled = !!config.lufs_i_norm_enabled; config.ppm_din_loudness_boxes = !!config.ppm_din_loudness_boxes; @@ -728,15 +740,23 @@ fn apply_global_to_rta( config.lr_fractional_delay_samples = global.lr_fractional_delay_samples; config.ppm_din_attack_ms = global.ppm_din_attack_ms; config.ppm_din_decay_db_per_s = global.ppm_din_decay_db_per_s; - config.ppm_din_fast_attack = global.ppm_din_fast_attack; config.ppm_ebu_attack_ms = global.ppm_ebu_attack_ms; config.ppm_ebu_decay_db_per_s = global.ppm_ebu_decay_db_per_s; + config.rms_integration = global.rms_integration.clone(); config.lufs_i_window_min = global.lufs_i_window_min; config.lufs_i_norm_enabled = global.lufs_i_norm_enabled; config.xy_points = global.xy_points; normalize_rta_config(config) } +fn normalize_rms_integration(value: &str) -> String { + match value.trim().to_ascii_lowercase().as_str() { + "slow" => "slow".to_string(), + "window" | "window300" | "none" => "window".to_string(), + _ => "fast".to_string(), + } +} + fn load_global_config(runtime: &PhoenixConfig) -> PhoenixGlobalConfig { let fallback = normalize_global_config(runtime.default_global_config(), runtime); let Ok(raw) = std::fs::read_to_string(&runtime.global_config_path) else { @@ -866,4 +886,25 @@ mod tests { assert_eq!(config.bpo, "1_12"); assert_eq!(config.engine, "iir"); } + + #[test] + fn global_octave_resolution_repairs_a_divergent_runtime_config() { + let mut global = PhoenixGlobalConfig::default(); + global.rta_bpo_mode = "1_6".to_string(); + let runtime = PhoenixRtaConfig { + bpo: "1_12".to_string(), + ..PhoenixRtaConfig::default() + }; + let repaired = apply_global_to_rta(runtime, &global); + assert_eq!(repaired.bpo, "1_6"); + } + + #[test] + fn rms_integration_rejects_non_rms_impulse_mode() { + assert_eq!(normalize_rms_integration("fast"), "fast"); + assert_eq!(normalize_rms_integration("slow"), "slow"); + assert_eq!(normalize_rms_integration("window300"), "window"); + assert_eq!(normalize_rms_integration("none"), "window"); + assert_eq!(normalize_rms_integration("impulse"), "fast"); + } } diff --git a/src/true_peak.rs b/src/true_peak.rs index fd952f4..dfd4acb 100644 --- a/src/true_peak.rs +++ b/src/true_peak.rs @@ -1,80 +1,105 @@ -//! Continuous, block-boundary-independent 4x true-peak interpolation. +//! Sample-continuous true-peak detector according to ITU-R BS.1770 Annex 2. +//! +//! The four polyphase FIR branches are the reference 4x interpolation filter +//! specified by BS.1770. Keeping the twelve input samples in a persistent ring +//! makes the result independent of ALSA capture and WebSocket block boundaries. -use std::collections::VecDeque; +const PHASES: usize = 4; +const TAPS: usize = 12; -const OVERSAMPLE: usize = 4; -const RADIUS: usize = 8; -const BUFFER_LEN: usize = RADIUS * 2 + 1; +// ITU-R BS.1770 Annex 2, Table 2: coefficients for 4x oversampling. +const INTERPOLATOR: [[f32; TAPS]; PHASES] = [ + [ + 0.001_708_984_4, + -0.010_986_328, + 0.019_653_32, + -0.033_203_125, + 0.059_448_242, + -0.137_329_1, + 0.972_167_97, + 0.188_598_63, + -0.071_289_06, + 0.037_597_656, + -0.021_362_305, + 0.010_986_328, + ], + [ + -0.029_174_805, + 0.029_296_875, + -0.051_757_813, + 0.089_111_33, + -0.166_503_9, + 0.465_087_9, + 0.779_785_16, + -0.200_317_38, + 0.101_562_5, + -0.058_227_54, + 0.033_081_055, + -0.018_920_898, + ], + [ + -0.018_920_898, + 0.033_081_055, + -0.058_227_54, + 0.101_562_5, + -0.200_317_38, + 0.779_785_16, + 0.465_087_9, + -0.166_503_9, + 0.089_111_33, + -0.051_757_813, + 0.029_296_875, + -0.029_174_805, + ], + [ + 0.010_986_328, + -0.021_362_305, + 0.037_597_656, + -0.071_289_06, + 0.188_598_63, + 0.972_167_97, + -0.137_329_1, + 0.059_448_242, + -0.033_203_125, + 0.019_653_32, + -0.010_986_328, + 0.001_708_984_4, + ], +]; #[derive(Clone, Debug)] pub struct TruePeakDetector { - samples: VecDeque, + history: [f32; TAPS], + next: usize, } impl Default for TruePeakDetector { fn default() -> Self { Self { - samples: VecDeque::with_capacity(BUFFER_LEN + 1), + history: [0.0; TAPS], + next: 0, } } } impl TruePeakDetector { pub fn process(&mut self, sample: f32) -> f32 { + self.history[self.next] = sample; + self.next = (self.next + 1) % TAPS; + let mut peak = sample.abs(); - self.samples.push_back(sample); - if self.samples.len() < BUFFER_LEN { - return peak; + for phase in &INTERPOLATOR { + let mut interpolated = 0.0f32; + for (tap, &coefficient) in phase.iter().enumerate() { + let index = (self.next + tap) % TAPS; + interpolated += self.history[index] * coefficient; + } + peak = peak.max(interpolated.abs()); } - - 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::*; @@ -87,19 +112,49 @@ mod tests { peak = peak.max(detector.process(sample)); } } - for _ in 0..BUFFER_LEN { + // Drain the fixed FIR delay without resetting its sample history. + for _ in 0..TAPS { peak = peak.max(detector.process(0.0)); } peak } + fn sine(divisor: f32, amplitude: f32, phase_degrees: f32) -> Vec { + let phase = phase_degrees.to_radians(); + let count = 4_800usize; + let fade = 480usize; + (0..count) + .map(|index| { + let angle = 2.0 * std::f32::consts::PI * index as f32 / divisor + phase; + let edge = index.min(count - 1 - index); + let taper = if edge < fade { + let x = edge as f32 / fade as f32; + 0.5 - 0.5 * (std::f32::consts::PI * x).cos() + } else { + 1.0 + }; + angle.sin() * amplitude * taper + }) + .collect() + } + + fn dbtp(value: f32) -> f32 { + 20.0 * value.max(1.0e-12).log10() + } + + fn assert_ebu_true_peak(divisor: f32, amplitude: f32, phase_degrees: f32, expected: f32) { + let measured = dbtp(run_in_blocks(&sine(divisor, amplitude, phase_degrees), 128)); + let low = expected - 0.4; + let high = expected + 0.2; + assert!( + (low..=high).contains(&measured), + "measured {measured:.4} dBTP, expected {expected:.1} dBTP (+0.2/-0.4)" + ); + } + #[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 samples = sine(48_000.0 / 11_025.0, 0.9, 17.761_692); 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); @@ -108,19 +163,22 @@ mod tests { #[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 samples = sine(48_000.0 / 11_025.0, 0.9, 17.761_692); 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}" - ); + } + + #[test] + fn passes_ebu_tech_3341_true_peak_tests_15_to_19() { + // EBU Tech 3341 v4, minimum-requirements tests 15-19. Frequency is + // expressed as a divisor of fs, so these remain valid at every rate. + assert_ebu_true_peak(4.0, 0.50, 0.0, -6.0); + assert_ebu_true_peak(4.0, 0.50, 45.0, -6.0); + assert_ebu_true_peak(6.0, 0.50, 60.0, -6.0); + assert_ebu_true_peak(8.0, 0.50, 67.5, -6.0); + assert_ebu_true_peak(4.0, 1.41, 45.0, 3.0); } } diff --git a/www/core/audio.js b/www/core/audio.js index 774ab6c..fc8bb1e 100644 --- a/www/core/audio.js +++ b/www/core/audio.js @@ -444,6 +444,11 @@ async function applyIncomingAudioPacket(env, packet, CONFIG, sampleTs = performa if (typeof d.correlationNegativePeak === 'number') { env.audio.correlationNegativePeak = d.correlationNegativePeak; } + env.audio.phaseAngleRad = typeof d.phaseAngleRad === 'number' ? d.phaseAngleRad : null; + env.audio.phaseSeq = Number.isFinite(seq) ? seq : ((env.audio.phaseSeq || 0) + 1); + if (typeof d.phaseCoherence === 'number') env.audio.phaseCoherence = d.phaseCoherence; + if (typeof d.phaseLevel === 'number') env.audio.phaseLevel = d.phaseLevel; + if (typeof d.phasePeak === 'number') env.audio.phasePeak = d.phasePeak; if (d.waveL && env.audio.pushWaveSamples) { const channelCount = d.waveChannels || (d.waveR ? 2 : 1); env.audio.sampleRate = d.sampleRate || env.audio.sampleRate || 48000; @@ -502,6 +507,10 @@ function buildPhoenixMeterPacket(frame) { const ppmBoxR = Number(frame?.ppm_box_r); const correlation = Number(frame?.correlation); const correlationNegativePeak = Number(frame?.correlation_negative_peak); + const phaseAngleRad = Number(frame?.phase_angle_rad); + const phaseCoherence = Number(frame?.phase_coherence); + const phaseLevel = Number(frame?.phase_level); + const phasePeak = Number(frame?.phase_peak); const xyL = Array.isArray(frame?.xy_l) ? frame.xy_l : null; const xyR = Array.isArray(frame?.xy_r) ? frame.xy_r : null; const waveL = Array.isArray(frame?.wave_l) && frame.wave_l.length ? frame.wave_l : null; @@ -515,6 +524,10 @@ function buildPhoenixMeterPacket(frame) { correlationNegativePeak: Number.isFinite(correlationNegativePeak) ? Math.max(-1, Math.min(1, correlationNegativePeak)) : 0, + phaseAngleRad: Number.isFinite(phaseAngleRad) ? phaseAngleRad : null, + phaseCoherence: Number.isFinite(phaseCoherence) ? Math.max(0, Math.min(1, phaseCoherence)) : 0, + phaseLevel: Number.isFinite(phaseLevel) ? Math.max(0, phaseLevel) : 0, + phasePeak: Number.isFinite(phasePeak) ? Math.max(0, phasePeak) : 0, rmsL: Number.isFinite(rmsL) ? rmsL : -120, rmsR: Number.isFinite(rmsR) ? rmsR : -120, tpL: Number.isFinite(tpL) ? tpL : -120, @@ -926,7 +939,6 @@ export function buildRtaRuntimeConfig(CONFIG = {}) { inputOffsetDbR: Number.isFinite(CONFIG.INPUT_OFFSET_DB_R) ? CONFIG.INPUT_OFFSET_DB_R : -5, ppmDinAttackMs: 10, ppmDinDecayDbPerS: 20 / 1.5, - ppmDinFastAttack: !!CONFIG.PPM_DIN_FAST_ATTACK, ppmEbuAttackMs: 10, ppmEbuDecayDbPerS: 24 / 2.8, lufsIWindowMin: Number.isFinite(CONFIG.LUFS_I_WINDOW_MIN) ? CONFIG.LUFS_I_WINDOW_MIN : 4, diff --git a/www/core/config.js b/www/core/config.js index 368fc4e..84772f4 100644 --- a/www/core/config.js +++ b/www/core/config.js @@ -106,7 +106,7 @@ const CONFIG = { VU_REF_IS_PLUS4: true, // 0 VU soll +4 dBu entsprechen → DIGITAL_REF + 4 dB. VU_DBFS_REF: -14.0, - VU_OFFSET_DB: 1.92, + VU_OFFSET_DB: 0.0, VU_HEADER_SHOW_VALUE: false, TP_OFFSET_DB: 0.0, TP_HEADER_SHOW_VALUE: false, @@ -119,8 +119,9 @@ const CONFIG = { PPM_RED_BAR_ONLY: true, TP_RED_BAR_ONLY: true, RMS_RED_BAR_ONLY: true, - RMS_TC_MODE: 'fast', // 'impulse' (35 ms), 'fast' (125 ms), 'slow' (1000 ms), 'none' - RMS_TC_MS: null, // optional override in ms (falls gesetzt, überschreibt den Modus) + // True-RMS integration is performed sample-continuously on linear power in + // the backend. "window" is a rectangular sliding 300-ms measurement. + RMS_TC_MODE: 'fast', // 'fast', 'slow', 'window' VU_COLOR_NORMAL: '#ffe066', VU_COLOR_WARN: '#ff3b3b', PPM_DIN_COLOR_NORMAL: '#ffe066', @@ -161,9 +162,6 @@ const CONFIG = { PPM_DIN_RED_START: 0, // Fixed RTW/DIN profile: 10 ms integration and 20 dB return in 1.5 s. PPM_DIN_ATTACK_MS: 10, - // Non-normative: when enabled, PPM DIN attack becomes instant (no integration). - // Useful to compensate perceived meter lag caused by device / pipeline latency. - PPM_DIN_FAST_ATTACK: false, PPM_DIN_DECAY_DB_PER_S: 20 / 1.5, // Hold time for DIN PPM: verlängert auf 1000 ms (1 s), damit der Peak‑Hold // auch bei kurzen Transienten deutlich sichtbar bleibt. Gemäß @@ -589,9 +587,9 @@ const PHOENIX_GLOBAL_OPTION_KEYS = Object.freeze([ 'LR_FRACTIONAL_DELAY_SAMPLES', 'PPM_DIN_ATTACK_MS', 'PPM_DIN_DECAY_DB_PER_S', - 'PPM_DIN_FAST_ATTACK', 'PPM_EBU_ATTACK_MS', 'PPM_EBU_DECAY_DB_PER_S', + 'RMS_TC_MODE', 'LUFS_I_WINDOW_MIN', 'LUFS_I_NORM_ENABLED', 'PPM_DIN_LOUDNESS_BOXES', @@ -679,8 +677,8 @@ const BROADCAST_STANDARDS = { }; const STORAGE_KEY = 'analyzer_config'; -const VU_OFFSET_ALIGNMENT_DB = 1.92; -const VU_OFFSET_ALIGNMENT_MIGRATION_KEY = 'migrate_vu_offset_alignment_plus2_v1'; +const VU_OFFSET_DEFAULT_DB = 0.0; +const VU_OFFSET_ZERO_MIGRATION_KEY = 'migrate_vu_offset_default_zero_v2'; const INPUT_OFFSET_DB_MIN = -60; const INPUT_OFFSET_DB_MAX = 20; const LR_DELAY_ZERO_MIGRATION_KEY = 'migrate_lr_delay_zero_to_0_05_v1'; @@ -845,21 +843,23 @@ function loadConfig(opts = {}) { if (!Number.isFinite(CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB)) CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB = 0; CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB = Math.max(-7, Math.min(7, CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB)); try { - const needsVuMigration = !localStorage.getItem(VU_OFFSET_ALIGNMENT_MIGRATION_KEY); + const needsVuMigration = !localStorage.getItem(VU_OFFSET_ZERO_MIGRATION_KEY); const savedVuOffset = Number(saved?.VU_OFFSET_DB); - const oldDefaultOffset = Number(CONFIG_DEFAULTS?.VU_OFFSET_DB); - const looksLikeOldDefault = !Number.isFinite(savedVuOffset) - || Math.abs(savedVuOffset - oldDefaultOffset) < 0.02 + const looksLikeOldAutomaticOffset = !Number.isFinite(savedVuOffset) + || Math.abs(savedVuOffset - 1.92) < 0.02 || Math.abs(savedVuOffset - (-0.08)) < 0.02 || Math.abs(savedVuOffset - (-0.05)) < 0.02; - if (needsVuMigration && looksLikeOldDefault) { - CONFIG.VU_OFFSET_DB = VU_OFFSET_ALIGNMENT_DB; + if (needsVuMigration && looksLikeOldAutomaticOffset) { + CONFIG.VU_OFFSET_DB = VU_OFFSET_DEFAULT_DB; } - if (needsVuMigration) localStorage.setItem(VU_OFFSET_ALIGNMENT_MIGRATION_KEY, '1'); + if (needsVuMigration) localStorage.setItem(VU_OFFSET_ZERO_MIGRATION_KEY, '1'); } catch (_) {} if (!Number.isFinite(CONFIG.LUFS_I_WINDOW_MIN)) CONFIG.LUFS_I_WINDOW_MIN = CONFIG_DEFAULTS.LUFS_I_WINDOW_MIN ?? 4; CONFIG.LUFS_I_WINDOW_MIN = Math.max(1, Math.min(10, Math.round(CONFIG.LUFS_I_WINDOW_MIN))); + CONFIG.RMS_TC_MODE = (CONFIG.RMS_TC_MODE === 'slow' || CONFIG.RMS_TC_MODE === 'window') + ? CONFIG.RMS_TC_MODE + : 'fast'; CONFIG.LUFS_I_NORM_ENABLED = !!CONFIG.LUFS_I_NORM_ENABLED; CONFIG.STOPWATCH_DISPLAY_STYLE = (CONFIG.STOPWATCH_DISPLAY_STYLE === 'seven') ? 'seven' : 'mono'; @@ -876,7 +876,6 @@ function loadConfig(opts = {}) { if (!Number.isFinite(CONFIG.LR_FRACTIONAL_DELAY_SAMPLES)) CONFIG.LR_FRACTIONAL_DELAY_SAMPLES = CONFIG_DEFAULTS.LR_FRACTIONAL_DELAY_SAMPLES; CONFIG.LR_FRACTIONAL_DELAY_SAMPLES = Math.max(-1.5, Math.min(1.5, Number(CONFIG.LR_FRACTIONAL_DELAY_SAMPLES))); CONFIG.PHOENIX_BASE_URL = normalizePhoenixBaseUrlForCurrentClient(CONFIG.PHOENIX_BASE_URL || CONFIG_DEFAULTS.PHOENIX_BASE_URL || DEFAULT_PHOENIX_BASE_URL); - CONFIG.PPM_DIN_FAST_ATTACK = !!CONFIG.PPM_DIN_FAST_ATTACK; // Migrate stale profiles: detector ballistics are standards profiles, // not user-adjustable display preferences. CONFIG.PPM_DIN_ATTACK_MS = 10; @@ -1055,6 +1054,12 @@ function buildPhoenixGlobalConfigPayload() { return (mode === '1_3' || mode === '1_6' || mode === '1_12') ? mode : '1_6'; }; const phaseAmplitudeMode = normalizePhaseAmplitudeMode(CONFIG.PHASE_AMPLITUDE_MODE); + const rmsIntegration = (() => { + const mode = String(CONFIG.RMS_TC_MODE || '').trim().toLowerCase(); + if (mode === 'slow' || mode === 'window') return mode; + if (mode === 'none' || mode === 'window300') return 'window'; + return 'fast'; + })(); return { fftSize, rtaBpoMode: normalizeRtaBpoMode(CONFIG.RTA_BPO_MODE), @@ -1065,9 +1070,9 @@ function buildPhoenixGlobalConfigPayload() { lrFractionalDelaySamples: Number.isFinite(CONFIG.LR_FRACTIONAL_DELAY_SAMPLES) ? CONFIG.LR_FRACTIONAL_DELAY_SAMPLES : (CONFIG_DEFAULTS.LR_FRACTIONAL_DELAY_SAMPLES ?? 0.996), ppmDinAttackMs: 10, ppmDinDecayDbPerS: 20 / 1.5, - ppmDinFastAttack: !!CONFIG.PPM_DIN_FAST_ATTACK, ppmEbuAttackMs: 10, ppmEbuDecayDbPerS: 24 / 2.8, + rmsIntegration, lufsIWindowMin: Number.isFinite(CONFIG.LUFS_I_WINDOW_MIN) ? CONFIG.LUFS_I_WINDOW_MIN : (CONFIG_DEFAULTS.LUFS_I_WINDOW_MIN ?? 4), lufsINormEnabled: !!CONFIG.LUFS_I_NORM_ENABLED, ppmDinLoudnessBoxes: !!CONFIG.PPM_DIN_LOUDNESS_BOXES, @@ -1162,9 +1167,12 @@ function applyPhoenixGlobalConfig(payload = {}) { if ('spectroScrollMode' in payload) CONFIG.SPECTRO_SCROLL_MODE = normalizeSpectroScroll(payload.spectroScrollMode); CONFIG.PPM_DIN_ATTACK_MS = 10; CONFIG.PPM_DIN_DECAY_DB_PER_S = 20 / 1.5; - if ('ppmDinFastAttack' in payload) CONFIG.PPM_DIN_FAST_ATTACK = !!payload.ppmDinFastAttack; CONFIG.PPM_EBU_ATTACK_MS = 10; CONFIG.PPM_EBU_DECAY_DB_PER_S = 24 / 2.8; + if ('rmsIntegration' in payload) { + const mode = String(payload.rmsIntegration || '').trim().toLowerCase(); + CONFIG.RMS_TC_MODE = (mode === 'slow' || mode === 'window') ? mode : 'fast'; + } const lufsWin = Number(payload.lufsIWindowMin); if (Number.isFinite(lufsWin)) CONFIG.LUFS_I_WINDOW_MIN = Math.max(1, Math.min(10, Math.round(lufsWin))); if ('lufsINormEnabled' in payload) CONFIG.LUFS_I_NORM_ENABLED = !!payload.lufsINormEnabled; @@ -1297,7 +1305,7 @@ function applyBroadcastStandard(name) { CONFIG.VU_RED_START = p.VU_RED_START; // Offsets zurücksetzen (wie bisher) - CONFIG.VU_OFFSET_DB = VU_OFFSET_ALIGNMENT_DB; + CONFIG.VU_OFFSET_DB = VU_OFFSET_DEFAULT_DB; CONFIG.TP_OFFSET_DB = 0; CONFIG.RMS_OFFSET_DB = 0; diff --git a/www/index.html b/www/index.html index 1e60ae0..8a7d709 100644 --- a/www/index.html +++ b/www/index.html @@ -1052,13 +1052,6 @@ Dokument‑Variante (PML=+6 dBu; AL=0 dBu → −6 dB). Deaktiviert: AL → −9 dB. -
- - Umgeht die feste 10-ms-Normintegration und zeigt Sample-Peaks sofort an (kann gefühltes Anzeige-Lag reduzieren). -
Blendet „RMS …“ aus und zeigt oben den aktuellen Wert
-
- +
+
@@ -1182,10 +1175,9 @@ - IEC 60268: Impulse 35 ms, Fast 125 ms, Slow 1000 ms + Integration der linearen Signalleistung im Audiokern. Fast/Slow verwenden 125 ms beziehungsweise 1 s; Fenster misst gleitend über exakt 300 ms.
@@ -1279,7 +1271,7 @@
  • Echtzeit-Datenweg: WebSocket-Verarbeitung auf „latest value wins“ umgestellt. Messwerte, Spektrogramm sowie Goniometer/Waveform verwenden getrennte, begrenzte Datenwege; alte Frames können keine anwachsende Anzeigeverzögerung mehr bilden.
  • Browserlast: Single-Slot-Puffer und Sequenzprüfung für Mess-, Spektrogramm- und Visualisierungsdaten ergänzt. Große Rohsamplefelder werden nicht mehr in jedem JSON-Messpaket wiederholt.
  • Spektrogramm: Langzeitstillstand und stotterndes Nachholen beseitigt. Inkrementelles Spaltenzeichnen, Worker-ACK, Watchdog/Neustart und begrenztes Überspringen veralteter Spalten ergänzt. 0,5×, 1×, 2×, 4× und 6× besitzen nun eine feste, FFT- und DPI-unabhängige Zeitbasis.
  • -
  • DIN-/EBU-PPM: Blockunabhängige Quasi-Peak-Detektoren mit bandbegrenzter Interpolation, korrekter Attack- und Rücklaufballistik sowie automatischen Tonburst-, Frequenzgang- und Polaritätstests implementiert. Eine zweite Browser-Anstiegsballistik wurde entfernt.
  • +
  • DIN-/EBU-PPM: Blockunabhängige Quasi-Peak-Detektoren mit bandbegrenzter Interpolation, korrekter Attack- und Rücklaufballistik sowie automatischen Tonburst-, Frequenzgang- und Polaritätstests implementiert. Eine zweite Browser-Anstiegsballistik und der alte nicht normgerechte DIN-Fast-Attack-Sonderweg wurden vollständig entfernt.
  • RTW-RTA: RTW-Profil verbindlich auf die IIR-Oktavteilband-Filterbank festgelegt. Bis zur Vergleichsmessung am realen RTW-Gerät sind die bisherige Phoenix/RTW-Charakteristik und vollständige Butterworth-Bandpässe sechster Ordnung getrennt auswählbar.
  • RTA-Detektor und Reaktionszeit: Average (RMS) und Peak (gleitendes 10-ms-Maximum) sind nun getrennt von Fast, Medium, Slow und Impulse wählbar. Fast wurde auf 125 ms korrigiert; Impulse verwendet 35 ms Anstieg und 1,5 s Rücklauf. Die IIR-Detektoren arbeiten samplegenau und unabhängig von der ALSA-Periodengröße. Alte Einstellungen werden automatisch migriert.
  • RTA-Auflösung: Umschaltung zwischen 1/3, 1/6 und 1/12 repariert. Alle drei Auflösungen bleiben im RTW-Profil aktiv und verwenden dessen IIR-Filterbank, Darstellung und Ballistik.
  • @@ -1287,7 +1279,8 @@
  • 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.
  • +
  • Phasenrad: Bandpass, Hilbert-Transformation und energiegewichtete L/R-Phasenmittelung laufen nun samplekontinuierlich im Audiokern statt auf ausgedünnten Browser-XY-Punkten. Paketgrenzen beeinflussen den Winkel nicht mehr; Glättung und AGC arbeiten zeitbasiert, und die doppelte AGC-Verstärkung wurde entfernt.
  • +
  • True Peak, RMS und VU: True Peak verwendet den vierphasigen Referenz-FIR aus ITU-R BS.1770, besteht die EBU-Testfälle 15 bis 19 und zeigt Übersteuerungen in allen Ansichten bis +6 dBTP an. True RMS integriert samplekontinuierlich im Leistungsbereich mit Fast (125 ms), Slow (1 s) oder einem gleitenden 300-ms-Fenster; Browser-Doppelglättung und der RMS-fremde Impulse-Modus wurden entfernt. VU verwendet ein Moving-Coil-Modell mit 300-ms-Sprungantwort und 1 bis 1,5 % Überschwingen; der fälschliche Standardoffset von +1,92 dB wurde auf 0 dB korrigiert.
  • Qualitätssicherung: Automatische Regressionstests für PPM, RTA, A/C/Z, mehrere Sampleraten, Korrelationssignale, Goniometertaktung, Binärprotokolle sowie Spektrogramm-Zeitbasis und Langlauf ergänzt.
  • @@ -1529,7 +1522,6 @@

    Veröffentlichung: 04.03.2026

    Änderungen:

      -
    • PPM DIN: Neue Option „Fast‑Attack“ (sofortes Anlaufen, nicht normgerecht) inkl. Hinweis‑Popup beim Aktivieren; Ballistik wird live aktualisiert.
    @@ -1896,17 +1888,6 @@ - - -