Correct audio metering and realtime displays

This commit is contained in:
Mikei386
2026-07-22 10:50:15 +02:00
parent 97352ecfce
commit 2e868bfa3a
26 changed files with 999 additions and 903 deletions
+18 -8
View File
@@ -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.
@@ -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,
@@ -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,
+9 -34
View File
@@ -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');
+16
View File
@@ -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');
+31 -95
View File
@@ -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<f32>,
ring_r: Vec<f32>,
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<RtaEngineState>,
spectro_signature: String,
@@ -599,6 +596,7 @@ struct PpmState {
transport_clock: GoniometerClock,
transport_peaks: TransportPeaks,
correlation: CorrelationMeter,
phase_wheel: PhaseWheelAnalyzer,
xy_pending_l: Vec<f32>,
xy_pending_r: Vec<f32>,
}
@@ -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<WaveEnvFrame> {
})
}
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<MeterFrame>, Option<SpectroFrame>) {
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<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);
}
}
#[test]
fn fft_integrated_energy_compensates_hann_window_and_fft_size() {
let expected = 0.5f32;
+2
View File
@@ -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;
+9 -4
View File
@@ -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<f32>,
pub phase_coherence: f32,
pub phase_level: f32,
pub phase_peak: f32,
pub rms_l: f32,
pub rms_r: f32,
pub vu_l: f32,
+264
View File
@@ -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<f32>,
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}");
}
}
+23 -45
View File
@@ -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);
+213
View File
@@ -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<f64>,
window_r: Vec<f64>,
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<f32> = (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);
}
}
}
+4
View File
@@ -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,
+46 -5
View File
@@ -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");
}
}
+127 -69
View File
@@ -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<f32>,
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;
}
let mut contiguous = [0.0f32; BUFFER_LEN];
for (target, source) in contiguous.iter_mut().zip(self.samples.iter()) {
*target = *source;
peak = peak.max(interpolated.abs());
}
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<f32> {
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<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 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<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 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);
}
}
+13 -1
View File
@@ -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,
+28 -20
View File
@@ -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 PeakHold
// 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;
+7 -26
View File
@@ -1052,13 +1052,6 @@
</label>
<small>DokumentVariante (PML=+6dBu; AL=0dBu → 6dB). Deaktiviert: AL → 9dB.</small>
</div>
<div class="opt">
<label class="row">
<input id="opt_ppmDinFastAttack" type="checkbox">
PPM DIN: FastAttack (sofort, nicht normgerecht)
</label>
<small>Umgeht die feste 10-ms-Normintegration und zeigt Sample-Peaks sofort an (kann gefühltes Anzeige-Lag reduzieren).</small>
</div>
<div class="opt">
<label class="row" style="align-items:center;gap:8px">
<input id="opt_ppmDinHeaderValue" type="checkbox">
@@ -1170,8 +1163,8 @@
</label>
<small>Blendet „RMS …“ aus und zeigt oben den aktuellen Wert</small>
</div>
<div class="opt"><label>RMS: Rot ab (dBFS)</label>
<input id="opt_rmsRedThr" type="number" min="-40" max="0" step="0.5" style="width:110px">
<div class="opt"><label>RMS: Rot ab (Anzeigeeinheit)</label>
<input id="opt_rmsRedThr" type="number" min="-60" max="24" step="0.5" style="width:110px">
<label class="row"><input id="opt_rmsRedBarOnly" type="checkbox"> nur roter Teil</label>
</div>
<div class="opt"><label>RMS Farben</label>
@@ -1182,10 +1175,9 @@
<select id="opt_rmsTc" style="width:170px">
<option value="fast">Fast (125 ms)</option>
<option value="slow">Slow (1000 ms)</option>
<option value="impulse">Impulse (35 ms)</option>
<option value="none">Keine (instant)</option>
<option value="window">Fenster (300 ms)</option>
</select>
<small>IEC 60268: Impulse 35 ms, Fast 125 ms, Slow 1000 ms</small>
<small>Integration der linearen Signalleistung im Audiokern. Fast/Slow verwenden 125 ms beziehungsweise 1 s; Fenster misst gleitend über exakt 300 ms.</small>
</div>
</div>
</details>
@@ -1279,7 +1271,7 @@
<li><strong>Echtzeit-Datenweg:</strong> 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.</li>
<li><strong>Browserlast:</strong> 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.</li>
<li><strong>Spektrogramm:</strong> 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.</li>
<li><strong>DIN-/EBU-PPM:</strong> 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.</li>
<li><strong>DIN-/EBU-PPM:</strong> 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.</li>
<li><strong>RTW-RTA:</strong> 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.</li>
<li><strong>RTA-Detektor und Reaktionszeit:</strong> 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.</li>
<li><strong>RTA-Auflösung:</strong> 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.</li>
@@ -1287,7 +1279,8 @@
<li><strong>Goniometer:</strong> Nur neue XY-Samplepaare werden mit einer periodengrößenunabhängigen mittleren Rate von 60 Hz übertragen. Punktbegrenzung wirkt bereits auf den Transport; begrenzte und wiederverwendbare Spurpuffer reduzieren Speicher- und Zeichenlast.</li>
<li><strong>Goniometer-Persistenz:</strong> Reproduzierbare Fast-, Medium- und Slow-Profile sowie ein freies Phoenix-Fade ergänzt. Künstliche Bézier-Verformung der Messspur entfernt; AGC und Silence-Gate arbeiten zeit- beziehungsweise fensterbasiert.</li>
<li><strong>Korrelation:</strong> Kontinuierliche DSP-Messung aus L², R² und L·R mit wählbaren 1,0/2,5 Sekunden ergänzt. Bildratenabhängige Doppelglättung entfernt; Negative-Peak-Memory, Marker und manueller Reset hinzugefügt.</li>
<li><strong>True Peak, RMS und VU:</strong> True-Peak-Interpolation läuft nun lückenlos über Capture-Blockgrenzen; ein festes gleitendes 300-ms-Leistungsfenster ersetzt das periodengrößenabhängige Block-RMS. VU verwendet statt Rechteckmittelung ein Moving-Coil-Modell mit 300-ms-Sprungantwort und 1 bis 1,5 % Überschwingen.</li>
<li><strong>Phasenrad:</strong> 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.</li>
<li><strong>True Peak, RMS und VU:</strong> 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.</li>
<li><strong>Qualitätssicherung:</strong> Automatische Regressionstests für PPM, RTA, A/C/Z, mehrere Sampleraten, Korrelationssignale, Goniometertaktung, Binärprotokolle sowie Spektrogramm-Zeitbasis und Langlauf ergänzt.</li>
</ul>
</div>
@@ -1529,7 +1522,6 @@
<p style="margin:0; color:#8fd3d4;">Veröffentlichung: 04.03.2026</p>
<p style="margin:4px 0 0 12px; color:#8fd3d4;">Änderungen:</p>
<ul style="margin:2px 0 0 24px; color:#b2c7d9;">
<li>PPM DIN: Neue Option „FastAttack“ (sofortes Anlaufen, nicht normgerecht) inkl. HinweisPopup beim Aktivieren; Ballistik wird live aktualisiert.</li>
</ul>
</div>
</div>
@@ -1896,17 +1888,6 @@
</div>
</div>
<div id="ppmDinFastWarn" class="rec-warning" style="display:none">
<div class="rec-warning-box">
<h3>PPM DIN FastAttack</h3>
<p>Dieser Sample-Peak-Sondermodus lässt das PPM DIN sofort ansteigen (ohne 10-ms-Normintegration).</p>
<p>Dadurch kann die Anzeige „schneller“ wirken, ist aber <b>nicht mehr normgerecht</b> (DIN 45406 / IEC 6026810).</p>
<p>Hinweis: Das ändert nur die AnzeigeBallistik und kompensiert keine echte AudioLatenz.</p>
<button id="ppmDinFastWarnBtn" class="btn btn-lg" type="button">Verstanden</button>
</div>
</div>
<div id="presetSaveRemoteNotice" class="rec-warning" style="display:none">
<div class="rec-warning-box">
<h3>Preset hier nicht speichern</h3>
+5
View File
@@ -1700,6 +1700,11 @@ const audioState = {
xyL: null,
xyR: null,
xySeq: 0,
phaseAngleRad: null,
phaseCoherence: 0,
phaseLevel: 0,
phasePeak: 0,
phaseSeq: 0,
rtaData: null,
rmsDb: { L: -120, R: -120, mono: -120 },
updateRtaConfig: null,
+12 -67
View File
@@ -15,32 +15,25 @@ import { drawCachedStaticLayer } from './static_layer.js';
import { METER_HEADER_FONT } from './scale_helpers.js';
import { HEADER_BG, LABEL_COLOR, OK_COLOR, WARN_COLOR } from '../core/theme.js';
const RMS_FRAME_MS_NOMINAL = 16; // ~60 Hz UI-Refresh
const RMS_FRAME_MS_MAX = 40; // Obergrenze für dt in der Glättung
const RMS_FRAME_MS_SKIP = 250; // Heuristischer Schutz: riesige Gaps komplett skippen
export const id = 'rms';
export function initShared(CONFIG = {}) {
const offset = CONFIG.RMS_OFFSET_DB || 0;
const initVal = -60 + offset;
const initVal = -60;
return {
values: { L: initVal, R: initVal }, // intern immer dBFS RMS aus der Messkette
offset,
values: { L: initVal, R: initVal }, // unveränderte dBFS-RMS-Werte aus dem Backend
offset: 0,
lastValidL: initVal,
lastValidR: initVal,
_smooth: null,
};
}
export function update(packet, shared) {
const offset = shared.offset || 0;
const floor = -60 + offset;
const floor = -60;
if (!Number.isFinite(shared.lastValidL)) shared.lastValidL = floor;
if (!Number.isFinite(shared.lastValidR)) shared.lastValidR = floor;
if (Number.isFinite(packet?.rmsL)) {
const vL = packet.rmsL + offset;
const vL = packet.rmsL;
shared.values.L = vL;
shared.lastValidL = vL;
} else {
@@ -48,7 +41,7 @@ export function update(packet, shared) {
}
if (Number.isFinite(packet?.rmsR)) {
const vR = packet.rmsR + offset;
const vR = packet.rmsR;
shared.values.R = vR;
shared.lastValidR = vR;
} else {
@@ -65,6 +58,7 @@ export function draw(g, rect, CONFIG = {}, shared) {
// Anzeige-Umschaltung & Skala
const isDBU = (MODE === 'dbu');
const offset = Number.isFinite(CONFIG.RMS_OFFSET_DB) ? CONFIG.RMS_OFFSET_DB : 0;
const LOG_MIN = -60;
const LOG_TOP = isDBU ? +24 : 0;
@@ -120,35 +114,9 @@ export function draw(g, rect, CONFIG = {}, shared) {
g.fillRect(rect.x, rect.y - 24, rect.w, 24);
g.restore();
// IEC-ähnliche Zeitkonstanten (Impulse/Fast/Slow) für die Anzeige
const tauMs = resolveRmsTau(CONFIG);
if (tauMs > 0) {
const now = performance.now();
if (!shared._smooth) {
shared._smooth = {
L: shared.values.L,
R: shared.values.R,
lastTs: now - RMS_FRAME_MS_NOMINAL,
};
}
const lastTs = shared._smooth.lastTs ?? (now - RMS_FRAME_MS_NOMINAL);
const dtRawMs = Math.max(0, now - lastTs);
shared._smooth.lastTs = now;
if (dtRawMs <= RMS_FRAME_MS_SKIP) {
const dtUsedMs = Math.min(dtRawMs, RMS_FRAME_MS_MAX); // clamp dt to ignore occasional large gaps
const alpha = 1 - Math.exp(-dtUsedMs / tauMs);
shared._smooth.L += alpha * (shared.values.L - shared._smooth.L);
shared._smooth.R += alpha * (shared.values.R - shared._smooth.R);
}
} else {
shared._smooth = null;
}
// Werte in Anzeigeeinheit clampen
const rawDispL = toDisplay(shared._smooth?.L ?? shared.values.L);
const rawDispR = toDisplay(shared._smooth?.R ?? shared.values.R);
const smooth = smoothHeader(shared, rawDispL, rawDispR);
const rawDispL = toDisplay(shared.values.L + offset);
const rawDispR = toDisplay(shared.values.R + offset);
const vL = clamp(rawDispL, LOG_MIN, LOG_TOP);
const vR = clamp(rawDispR, LOG_MIN, LOG_TOP);
g.save();
@@ -167,11 +135,11 @@ export function draw(g, rect, CONFIG = {}, shared) {
const isRedL = rawDispL > RED_START;
const isRedR = rawDispR > RED_START;
g.fillStyle = isRedL ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
g.fillText(fmt(smooth.L), centerLeft, yText);
g.fillText(fmt(rawDispL), centerLeft, yText);
g.fillStyle = CONFIG.HEADER_TEXT_COLOR || MID_COLOR;
g.fillText('|', centerX, yText);
g.fillStyle = isRedR ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
g.fillText(fmt(smooth.R), centerRight, yText);
g.fillText(fmt(rawDispR), centerRight, yText);
} else {
const title = isDBU ? 'RMS (dBu)' : 'RMS (dBFS RMS)';
g.fillText(title, centerX, rect.y - 12);
@@ -284,7 +252,7 @@ function drawRmsStaticOverlay(g, shared, rect, CONFIG, geom) {
bottomValue: LOG_MIN,
highlightTick: null,
});
const unitLabel = 'dBFS (RMS)';
const unitLabel = isDBU ? 'dBu (RMS)' : 'dBFS (RMS)';
cg.fillStyle = LABEL_COLOR;
cg.textAlign = 'center';
const baseY = rect.y + rect.h + 16;
@@ -303,19 +271,6 @@ function drawRmsStaticOverlay(g, shared, rect, CONFIG, geom) {
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function resolveRmsTau(CONFIG = {}) {
const mode = String(CONFIG.RMS_TC_MODE || 'fast').toLowerCase();
const custom = Number(CONFIG.RMS_TC_MS);
if (Number.isFinite(custom) && custom > 0) return custom;
switch (mode) {
case 'impulse': return 35;
case 'slow': return 1000;
case 'none': return 0;
case 'fast':
default: return 125;
}
}
function drawRedStripeLR(g, leftX, barW, rightX, centerX, mapY, yWarn, yTop) {
const innerRight = leftX + barW;
const gapL = Math.abs(centerX - innerRight);
@@ -491,13 +446,3 @@ function getRmsAlignmentHighlight(CONFIG) {
color: WARN_COLOR,
};
}
function smoothHeader(shared, rawL, rawR, alpha = 0.2) {
if (!shared._header) {
shared._header = { L: rawL, R: rawR };
return shared._header;
}
shared._header.L += alpha * (rawL - shared._header.L);
shared._header.R += alpha * (rawR - shared._header.R);
return shared._header;
}
+32 -32
View File
@@ -1,25 +1,12 @@
// meters/tp.js — True Peak (dBTP) L/R mit nichtlinearer Skala, Warnschwelle und Glanzkante
// meters/tp.js — True Peak (dBTP) L/R mit Übersteuerungsreserve bis +6 dBTP
import { createPeakHoldState, stepPeakHold } from '../core/utils.js';
import { drawHairlineGrid, METER_HEADER_FONT } from './scale_helpers.js';
import { drawCachedStaticLayer } from './static_layer.js';
import { TRUE_PEAK_MINOR_TICKS, TRUE_PEAK_SCALE } from './true_peak_scale.js';
import { HEADER_BG, LABEL_COLOR, MID_COLOR, WARN_COLOR } from '../core/theme.js';
export const id = 'tp';
const TP_PERCENT_SCALE = [
{ db: -60, percent: '0,00 %', frac: 0.0000 },
{ db: -50, percent: '16,67 %', frac: 0.0373 },
{ db: -40, percent: '33,33 %', frac: 0.1429 },
{ db: -35, percent: '41,67 %', frac: 0.2112 },
{ db: -30, percent: '50,00 %', frac: 0.2857 },
{ db: -25, percent: '58,33 %', frac: 0.3851 },
{ db: -20, percent: '66,67 %', frac: 0.4783 },
{ db: -15, percent: '75,00 %', frac: 0.6087 },
{ db: -10, percent: '83,33 %', frac: 0.7391 },
{ db: -5, percent: '91,67 %', frac: 0.8696 },
{ db: 0, percent: '100,00 %', frac: 1.0000 },
];
export function initShared(CONFIG) {
const now = performance.now();
return {
@@ -42,22 +29,22 @@ export function update(packet, shared) {
shared.values.R = R + (shared.offset || 0);
}
const LOG_MIN = TP_PERCENT_SCALE[0].db;
const LOG_TOP = TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].db;
const LOG_MIN = TRUE_PEAK_SCALE[0].db;
const LOG_TOP = TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].db;
function interpolateFrac(db) {
if (db <= TP_PERCENT_SCALE[0].db) return TP_PERCENT_SCALE[0].frac;
if (db >= TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].db) return TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].frac;
for (let i = 1; i < TP_PERCENT_SCALE.length; i++) {
const prev = TP_PERCENT_SCALE[i - 1];
const curr = TP_PERCENT_SCALE[i];
if (db <= TRUE_PEAK_SCALE[0].db) return TRUE_PEAK_SCALE[0].frac;
if (db >= TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].db) return TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].frac;
for (let i = 1; i < TRUE_PEAK_SCALE.length; i++) {
const prev = TRUE_PEAK_SCALE[i - 1];
const curr = TRUE_PEAK_SCALE[i];
if (db <= curr.db) {
const span = curr.db - prev.db || 1;
const t = (db - prev.db) / span;
return prev.frac + t * (curr.frac - prev.frac);
}
}
return TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].frac;
return TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].frac;
}
export function draw(g, rect, CONFIG, shared) {
@@ -122,13 +109,30 @@ export function draw(g, rect, CONFIG, shared) {
g.font = prevFont;
g.restore();
const yFloor = mapY(LOG_MIN);
const configuredRedStart = Number(CONFIG.TP_RED_START);
const redStart = Math.max(LOG_MIN, Math.min(LOG_TOP, Number.isFinite(configuredRedStart) ? configuredRedStart : -1));
const yRed = mapY(redStart);
const innerW = Math.max(2, barW - 2);
const colNorm = CONFIG.TP_COLOR_NORMAL || MID_COLOR;
const colWarn = CONFIG.TP_COLOR_WARN || WARN_COLOR;
const redOnly = CONFIG.TP_RED_BAR_ONLY !== false;
const drawBar = (x0, val) => {
const yVal = mapY(val);
const colNorm = CONFIG.TP_COLOR_NORMAL;
if (val > redStart) {
if (redOnly) {
g.fillStyle = colNorm;
g.fillRect(x0 + 1, yRed, innerW, Math.max(0, yFloor - yRed));
g.fillStyle = colWarn;
g.fillRect(x0 + 1, yVal, innerW, Math.max(0, yRed - yVal));
} else {
g.fillStyle = colWarn;
g.fillRect(x0 + 1, yVal, innerW, Math.max(0, yFloor - yVal));
}
} else {
g.fillStyle = colNorm;
g.fillRect(x0 + 1, yVal, innerW, Math.max(0, yFloor - yVal));
}
// Glanzkante
g.globalAlpha = .12;
g.fillStyle = '#fff';
@@ -211,9 +215,6 @@ function drawTpStaticOverlay(g, shared, rect, CONFIG, geom, mapY) {
});
}
// Balken-Overlay-Ticks auf Basis der Vintage-Prozent-Skala
const EXTRA_MINOR_TICKS = [-47, -19, -18, -17, -16, -14, -13, -12, -11, -9, -8, -7, -6, -4, -3, -2, -1];
function drawOverlayTicksLR(g, leftX, rightX, widthPx, mapY, alignmentTick = null) {
if (!g) return;
@@ -254,14 +255,14 @@ function drawOverlayTicksLR(g, leftX, rightX, widthPx, mapY, alignmentTick = nul
g.beginPath(); g.moveTo(x1R, yPix); g.lineTo(x2R, yPix); g.stroke();
};
for (const point of TP_PERCENT_SCALE) {
for (const point of TRUE_PEAK_SCALE) {
const y = Math.round(mapY(point.db)) + 0.5;
const isHighlight = highlightValue !== null && approx(point.db, highlightValue);
if (isHighlight) highlightMatched = true;
drawMajor(y, isHighlight);
}
for (const db of EXTRA_MINOR_TICKS) {
for (const db of TRUE_PEAK_MINOR_TICKS) {
const y = Math.round(mapY(db)) + 0.5;
const isHighlight = highlightValue !== null && approx(db, highlightValue);
if (isHighlight) highlightMatched = true;
@@ -289,11 +290,10 @@ function drawScale(g, colRect, centerX, mapY, CONFIG) {
g.textAlign = 'center';
g.textBaseline = 'alphabetic';
for (const point of TP_PERCENT_SCALE) {
for (const point of TRUE_PEAK_SCALE) {
const y = mapY(point.db);
if (y < colRect.y || y > colRect.y + colRect.h) continue;
const value = Math.abs(point.db);
const dbLabel = Number.isInteger(value) ? String(value) : value.toFixed(1);
const dbLabel = point.db > 0 ? `+${point.db}` : String(point.db);
g.fillText(dbLabel, centerX, y + 5);
}
g.font = prevFont;
+22
View File
@@ -0,0 +1,22 @@
// Shared dBTP scale for every True-Peak presentation. The final 10% is kept
// above 0 dBTP so intersample overloads remain measurable up to +6 dBTP.
export const TRUE_PEAK_SCALE = Object.freeze([
{ db: -60, frac: 0.0000 },
{ db: -50, frac: 0.0336 },
{ db: -40, frac: 0.1286 },
{ db: -35, frac: 0.1901 },
{ db: -30, frac: 0.2571 },
{ db: -25, frac: 0.3466 },
{ db: -20, frac: 0.4305 },
{ db: -15, frac: 0.5478 },
{ db: -10, frac: 0.6652 },
{ db: -5, frac: 0.7826 },
{ db: 0, frac: 0.9000 },
{ db: +3, frac: 0.9500 },
{ db: +6, frac: 1.0000 },
]);
export const TRUE_PEAK_MINOR_TICKS = Object.freeze([
-47, -19, -18, -17, -16, -14, -13, -12, -11, -9, -8, -7, -6, -4, -3, -2, -1,
+1, +2, +4, +5,
]);
+2 -52
View File
@@ -7,7 +7,6 @@ import { clamp, clampPow2 } from '../core/utils.js';
// DOM helper
const E = (id) => document.getElementById(id);
const PPM_LOUD_WARN_KEY = 'ppm_din_loudness_warn_ack_v1';
const PPM_DIN_FAST_WARN_KEY = 'ppm_din_fast_attack_warn_ack_v1';
function getPpmDinVisibleBaseOffset() {
const baseMode = (CONFIG.PPM_DIN_MODE === 'al_minus6') ? -6 : -9;
@@ -81,27 +80,6 @@ function showPpmLoudnessWarning(onConfirm) {
wrap.style.display = 'flex';
}
function showPpmDinFastAttackWarning(onConfirm) {
const wrap = E('ppmDinFastWarn');
const btn = E('ppmDinFastWarnBtn');
if (!wrap || !btn) {
if (onConfirm) onConfirm();
return;
}
if (!wrap.dataset.bound) {
btn.addEventListener('click', () => {
try { localStorage.setItem(PPM_DIN_FAST_WARN_KEY, '1'); } catch (_) {}
wrap.style.display = 'none';
if (onConfirm) onConfirm();
});
wrap.addEventListener('click', (e) => {
if (e.target === wrap) wrap.style.display = 'none';
});
wrap.dataset.bound = '1';
}
wrap.style.display = 'flex';
}
export function setupOptions(env) {
// Initial laden
loadConfig();
@@ -237,7 +215,6 @@ function syncUI() {
['opt_ppmDinColWarn', CONFIG.PPM_DIN_COLOR_WARN],
['opt_ppmDinHeaderValue', CONFIG.PPM_DIN_HEADER_SHOW_VALUE, null, null, 'checkbox'],
['opt_ppmDinLoudnessBox', CONFIG.PPM_DIN_LOUDNESS_BOXES, null, null, 'checkbox'],
['opt_ppmDinFastAttack', CONFIG.PPM_DIN_FAST_ATTACK, null, null, 'checkbox'],
['opt_ppmDinLoudOff', CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB ?? 0, 'val_ppmDinLoudOff', (v)=>`${Number(v).toFixed(1)} dB`],
['opt_ppmEbuColNorm', CONFIG.PPM_EBU_COLOR_NORMAL],
['opt_ppmEbuColWarn', CONFIG.PPM_EBU_COLOR_WARN],
@@ -544,7 +521,6 @@ function wireHandlers(env) {
const notifyRta = /^opt_rta/i.test(id)
|| id === 'opt_lufsIWindowMin'
|| id === 'opt_lufsINorm';
const notifyPpm = (id === 'opt_ppmDinFastAttack');
const inputType = String(el.type || '').toLowerCase();
const commitOnInput = isCheckbox || inputType === 'range' || inputType === 'color';
@@ -558,9 +534,6 @@ function wireHandlers(env) {
if (notifyRta) {
try { env?.notifyRtaConfig?.(); } catch (_) {}
}
if (notifyPpm) {
try { env?.notifyPpmConfig?.(); } catch (_) {}
}
try { env?.syncRealtimeGain?.(); } catch (_) {}
try { env?.syncWaveformWindow?.(); } catch (_) {}
try { env?.syncWaveformMode?.(); } catch (_) {}
@@ -789,29 +762,6 @@ function wireHandlers(env) {
CONFIG.PPM_DIN_HEADER_SHOW_VALUE = !!v;
return CONFIG.PPM_DIN_HEADER_SHOW_VALUE;
}, null, null, true);
h('opt_ppmDinFastAttack', v => {
const desired = !!v;
const cb = E('opt_ppmDinFastAttack');
const acked = (() => {
try { return localStorage.getItem(PPM_DIN_FAST_WARN_KEY) === '1'; }
catch (_) { return false; }
})();
if (desired && !acked) {
if (cb) cb.checked = false;
CONFIG.PPM_DIN_FAST_ATTACK = false;
showPpmDinFastAttackWarning(() => {
CONFIG.PPM_DIN_FAST_ATTACK = true;
if (cb) cb.checked = true;
try { saveConfig(); } catch (_) {}
try { env?.notifyPpmConfig?.(); } catch (_) {}
notifyPhoenixGlobalConfig();
});
return false;
}
CONFIG.PPM_DIN_FAST_ATTACK = desired;
notifyPhoenixGlobalConfig();
return desired;
}, null, null, true);
h('opt_ppmDinLoudnessBox', v => {
const desired = !!v;
const cb = E('opt_ppmDinLoudnessBox');
@@ -875,9 +825,9 @@ function wireHandlers(env) {
}, null, null, true);
h('opt_rmsTc', v => {
const mode = String(v).toLowerCase();
if (mode === 'impulse' || mode === 'fast' || mode === 'slow' || mode === 'none') {
if (mode === 'fast' || mode === 'slow' || mode === 'window') {
CONFIG.RMS_TC_MODE = mode;
CONFIG.RMS_TC_MS = null;
notifyPhoenixGlobalConfig();
}
return CONFIG.RMS_TC_MODE;
});
+24 -28
View File
@@ -2,6 +2,7 @@
// Nutzt die bestehenden VU-Werte (inkl. Offsets/Kalibrierung) und zeichnet
// zwei kompakte, horizontale Nadelinstrumente (L/R) mit Slot-Auswahl.
import { drawCachedStaticLayer } from './static_layer.js';
import { TRUE_PEAK_MINOR_TICKS, TRUE_PEAK_SCALE } from '../meters/true_peak_scale.js';
export const id = 'classic-needles';
@@ -47,22 +48,6 @@ const DIN_SCALE = [
{ db: +5, pos: 1.0000 },
];
const TP_PERCENT_SCALE = [
{ db: -60, frac: 0.0000 },
{ db: -50, frac: 0.0373 },
{ db: -40, frac: 0.1429 },
{ db: -35, frac: 0.2112 },
{ db: -30, frac: 0.2857 },
{ db: -25, frac: 0.3851 },
{ db: -20, frac: 0.4783 },
{ db: -15, frac: 0.6087 },
{ db: -10, frac: 0.7391 },
{ db: -5, frac: 0.8696 },
{ db: 0, frac: 1.0000 },
];
const TP_EXTRA_MINOR_TICKS = [-47, -19, -18, -17, -16, -14, -13, -12, -11, -9, -8, -7, -6, -4, -3, -2, -1];
const PPM_EBU_MAJOR_TICKS = [-12, -8, -4, 0, +4, +8, +12];
const PPM_EBU_MINOR_TICKS = [-10, -6, -2, +2, +6, +9, +10];
@@ -122,7 +107,19 @@ export async function render(env, state) {
const raw = readMeterDisplayLR(meterId, meterState, CONFIG, scale, audio);
const targets = { L: scale.valueToNorm(raw.L), R: scale.valueToNorm(raw.R) };
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
if (meterId === 'rms') {
// RMS ballistics are already measured sample-continuously in the backend.
// A second spring model here would add view-dependent delay and values.
state.smooth.L = targets.L;
state.smooth.R = targets.R;
state.goal.L = targets.L;
state.goal.R = targets.R;
state.vel.L = 0;
state.vel.R = 0;
state.lastTs = now;
} else {
smoothNeedle(state, targets, now);
}
// Box-Abmessungen an den Real-Time-Analyzer anlehnen (gleiche Offsets)
const BOX_LEFT = 0;
const BOX_TOP = Number.isFinite(env?.topInset) ? Number(env.topInset) : 70;
@@ -425,24 +422,24 @@ function getNeedleScaleDescriptor(meterId, CONFIG) {
if (meterId === 'tp') {
const bottom = -60;
const top = 0;
const top = 6;
const redStart = Number.isFinite(CONFIG?.TP_RED_START) ? CONFIG.TP_RED_START : -1;
const warnColor = CONFIG?.TP_COLOR_WARN || '#ff3b3b';
const normalColor = CONFIG?.TP_COLOR_NORMAL || '#ffe066';
const mapRaw = (db) => {
const clamped = clamp(db, bottom, top);
if (clamped <= TP_PERCENT_SCALE[0].db) return TP_PERCENT_SCALE[0].frac;
if (clamped >= TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].db) return TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].frac;
for (let i = 1; i < TP_PERCENT_SCALE.length; i++) {
const prev = TP_PERCENT_SCALE[i - 1];
const curr = TP_PERCENT_SCALE[i];
if (clamped <= TRUE_PEAK_SCALE[0].db) return TRUE_PEAK_SCALE[0].frac;
if (clamped >= TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].db) return TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].frac;
for (let i = 1; i < TRUE_PEAK_SCALE.length; i++) {
const prev = TRUE_PEAK_SCALE[i - 1];
const curr = TRUE_PEAK_SCALE[i];
if (clamped <= curr.db) {
const span = curr.db - prev.db || 1;
const t = (clamped - prev.db) / span;
return prev.frac + t * (curr.frac - prev.frac);
}
}
return TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].frac;
return TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].frac;
};
const valueToNorm = (v) => clamp01(mapRaw(v));
return {
@@ -454,11 +451,10 @@ function getNeedleScaleDescriptor(meterId, CONFIG) {
warnColor,
normalColor,
unitLabel: 'dBTP',
majorTicks: TP_PERCENT_SCALE.map((p) => p.db),
minorTicks: TP_EXTRA_MINOR_TICKS,
majorTicks: TRUE_PEAK_SCALE.map((p) => p.db),
minorTicks: TRUE_PEAK_MINOR_TICKS,
formatMajor: (db) => {
const v = Math.abs(db);
return Number.isInteger(v) ? String(v) : v.toFixed(1);
return db > 0 ? `+${db}` : String(db);
},
valueToNorm,
};
@@ -633,7 +629,7 @@ function readMeterDisplayLR(meterId, shared, CONFIG, scale, audio) {
const isDBU = (mode === 'dbu');
if (isDBU) {
const refDbfs = Number.isFinite(CONFIG?.RMS_REF_DBFS_FOR_REF_DBU) ? CONFIG.RMS_REF_DBFS_FOR_REF_DBU : -18;
const refDbu = Number.isFinite(CONFIG?.RMS_REF_DBU) ? CONFIG.RMS_REF_DBU : +4;
const refDbu = Number.isFinite(CONFIG?.RMS_REF_DBU) ? CONFIG.RMS_REF_DBU : 0;
rawL = (rawL - refDbfs) + refDbu;
rawR = (rawR - refDbfs) + refDbu;
}
+11 -57
View File
@@ -1,6 +1,7 @@
// views/peak_history.js - Meter-Verlauf (rechter Slot) mit RTA-Layout
import { FRAME_COLOR, GRID_MAJOR_COLOR, GRID_MINOR_COLOR, LABEL_COLOR, MID_COLOR, PANEL_BG, WARN_COLOR } from '../core/theme.js';
import { TRUE_PEAK_SCALE } from '../meters/true_peak_scale.js';
const PLOT = { left: 43, top: 70, right: 0, bottom: 18 };
const METER_WIDTH = 140;
@@ -47,24 +48,10 @@ const DIN_SCALE = [
{ db: +5, pos: 1.0000 },
];
const TP_PERCENT_SCALE = [
{ db: -60, frac: 0.0000 },
{ db: -50, frac: 0.0373 },
{ db: -40, frac: 0.1429 },
{ db: -35, frac: 0.2112 },
{ db: -30, frac: 0.2857 },
{ db: -25, frac: 0.3851 },
{ db: -20, frac: 0.4783 },
{ db: -15, frac: 0.6087 },
{ db: -10, frac: 0.7391 },
{ db: -5, frac: 0.8696 },
{ db: 0, frac: 1.0000 },
];
const VU_TICKS = [-20, -10, -7, -5, -3, 0, 1, 2, 3];
const PPM_DIN_TICKS = [-50, -40, -30, -20, -10, -5, 0, 5];
const PPM_EBU_TICKS = [-12, -8, -4, 0, 4, 8, 12];
const TP_TICKS = [-60, -50, -40, -30, -20, -10, 0];
const TP_TICKS = [-60, -50, -40, -30, -20, -10, 0, 3, 6];
const RMS_DBFS_TICKS = [0, -6, -12, -18, -24, -30, -40, -60];
const RMS_DBU_TICKS = [24, 20, 10, 0, -10, -20, -30, -40, -50, -60];
const LUFS_TICKS = [-50, -40, -30, -23, -18, -10, -5];
@@ -91,7 +78,6 @@ export function init() {
meterLabel: '',
neutralNorm: 0,
neutralValue: 0,
rmsSmooth: null,
staticLayer: null,
};
}
@@ -605,7 +591,6 @@ async function drawMeter(env, plotX, plotY, plotW, plotH, meterRects = null) {
function resolveMeterInfo(env, state, meterId) {
const cfg = env.config || {};
const shared = env.meters?.getState?.(meterId) || null;
if (meterId !== 'rms') state.rmsSmooth = null;
switch (meterId) {
case 'ppm-din':
@@ -615,7 +600,7 @@ function resolveMeterInfo(env, state, meterId) {
case 'tp':
return sampleTp(cfg, shared);
case 'rms':
return sampleRms(cfg, shared, state);
return sampleRms(cfg, shared);
case 'lufs':
return sampleLufs(cfg, shared);
case 'vu':
@@ -715,8 +700,8 @@ function samplePpmEbu(cfg, shared) {
}
function sampleTp(cfg, shared) {
const bottom = TP_PERCENT_SCALE[0].db;
const top = TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].db;
const bottom = TRUE_PEAK_SCALE[0].db;
const top = TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].db;
const effOff = Number(cfg?.TP_OFFSET_DB) || 0;
const offCorr = effOff - (shared?.offset || 0);
const warnVal = Number.isFinite(cfg?.TP_RED_START) ? cfg.TP_RED_START : -1;
@@ -740,21 +725,21 @@ function sampleTp(cfg, shared) {
});
}
function sampleRms(cfg, shared, state) {
function sampleRms(cfg, shared) {
const mode = String(cfg?.RMS_MODE || 'dbfs').toLowerCase();
const isDBU = mode === 'dbu';
const bottom = -60;
const top = isDBU ? 24 : 0;
const refDbfs = Number.isFinite(cfg?.RMS_REF_DBFS_FOR_REF_DBU) ? cfg.RMS_REF_DBFS_FOR_REF_DBU : -18;
const refDbu = Number.isFinite(cfg?.RMS_REF_DBU) ? cfg.RMS_REF_DBU : 0;
const offset = Number.isFinite(cfg?.RMS_OFFSET_DB) ? cfg.RMS_OFFSET_DB : 0;
const warnVal = Number.isFinite(cfg?.RMS_RED_START)
? cfg.RMS_RED_START
: (isDBU ? 20 : 0);
const vL = Number.isFinite(shared?.values?.L) ? shared.values.L : bottom;
const vR = Number.isFinite(shared?.values?.R) ? shared.values.R : bottom;
const base = Math.max(vL, vR);
const smoothed = applyRmsSmoothing(state, base, cfg);
const display = isDBU ? (smoothed - refDbfs + refDbu) : smoothed;
const base = Math.max(vL, vR) + offset;
const display = isDBU ? (base - refDbfs + refDbu) : base;
const val = clamp(display, bottom, top);
const toNorm = (db) => {
const c = clamp(db, bottom, top);
@@ -778,43 +763,12 @@ function sampleRms(cfg, shared, state) {
toNorm,
range: { bottom, top },
ticks: isDBU ? RMS_DBU_TICKS : RMS_DBFS_TICKS,
signature: ['rms', mode, refDbfs, refDbu, cfg?.RMS_TC_MODE, cfg?.RMS_TC_MS].join('|'),
signature: ['rms', mode, refDbfs, refDbu, offset, cfg?.RMS_TC_MODE].join('|'),
hasValue: !!shared,
warnValue: warnVal,
});
}
function applyRmsSmoothing(state, value, cfg) {
const tauMs = resolveRmsTau(cfg);
if (!Number.isFinite(tauMs) || tauMs <= 0) {
state.rmsSmooth = null;
return value;
}
const now = performance.now();
if (!state.rmsSmooth || !Number.isFinite(state.rmsSmooth.value)) {
state.rmsSmooth = { value, lastTs: now };
return value;
}
const dt = Math.max(1 / 240, (now - (state.rmsSmooth.lastTs || now)) / 1000);
state.rmsSmooth.lastTs = now;
const alpha = 1 - Math.exp(-dt / (tauMs / 1000));
state.rmsSmooth.value += alpha * (value - state.rmsSmooth.value);
return state.rmsSmooth.value;
}
function resolveRmsTau(cfg = {}) {
const mode = String(cfg.RMS_TC_MODE || 'fast').toLowerCase();
const custom = Number(cfg.RMS_TC_MS);
if (Number.isFinite(custom) && custom > 0) return custom;
switch (mode) {
case 'impulse': return 35;
case 'slow': return 1000;
case 'none': return 0;
case 'fast':
default: return 125;
}
}
function sampleLufs(cfg, shared) {
const bottom = -50;
const top = -5;
@@ -888,7 +842,7 @@ function interpolateScale(table, db) {
}
function interpolateTp(db) {
return interpolateScale(TP_PERCENT_SCALE, db);
return interpolateScale(TRUE_PEAK_SCALE, db);
}
function deflectionFrac(db) {
+68 -350
View File
@@ -13,30 +13,19 @@ const METER_SLOTS = 3;
const METER_PAD_TOP = 30;
const METER_PAD_BOTTOM = 20;
const METER_EXTRA_BOTTOM_PAD = 6;
const TARGET_POINTS = 1024;
const COLOR_STOPS = [
{ t: 0.0, color: [0, 0, 50] },
{ t: 0.3, color: [0, 120, 180] },
{ t: 0.6, color: [0, 205, 120] },
{ t: 0.8, color: [210, 220, 0] },
{ t: 1.0, color: [255, 120, 0] },
];
const RING_DBFS = [0, -8, -16, -24, -32, -40];
const RING_PPM_DIN = [+5, 0, -10, -20, -30, -40, -50];
const PHASE_GAIN_MIN_DB = -35;
const PHASE_GAIN_MAX_DB = 35;
const PHASE_ALIGN_TARGET = Math.pow(10, -15 / 20);
const PHASE_AGC_TARGET_DB = linearToDb(PHASE_ALIGN_TARGET);
const PHASE_AGC_BASE_GAIN = 1 / PHASE_ALIGN_TARGET;
const PHASE_AGC_ATTACK_S = 0.02;
const PHASE_AGC_RELEASE_DB_PER_S = 12;
const PHASE_LEVEL_THRESHOLD_DB = -60;
const PHASE_LEVEL_THRESHOLD = dbToLinear(PHASE_LEVEL_THRESHOLD_DB);
const PHASE_IDLE_DECAY = 0.85;
const PHASE_PHASE_SMOOTH_ALPHA = 0.08;
const PHASE_RADIUS_SMOOTH_ALPHA = 0.18;
const PHASE_BANDPASS_LOW_HZ = 300;
const PHASE_BANDPASS_HIGH_HZ = 5000;
const PHASE_IDLE_TAU_S = 0.103;
const PHASE_SMOOTH_TAU_S = 0.2;
const PHASE_RADIUS_TAU_S = 0.085;
const PHASE_TRAIL_FADE_MS = 2000;
const PHASE_TRAIL_MIN_STEP_MS = 1000 / 45;
const PHASE_TRAIL_MIN_DIST_PX = 1.5;
@@ -48,32 +37,10 @@ const PHASE_SECTORS = [
{ startDeg: -180, endDeg: -60, color: 'rgba(255,120,120,0.25)' },
];
// Numerisch stabilere Hilbert-Kernel-Erstellung
const HILBERT_KERNEL = buildHilbertKernel(33);
const HILBERT_HALF = (HILBERT_KERNEL.length - 1) / 2;
// Lookup-Tables für häufig verwendete Werte
const ANGLE_COS = new Float32Array(360);
const ANGLE_SIN = new Float32Array(360);
for (let i = 0; i < 360; i++) {
const rad = (i * Math.PI) / 180;
ANGLE_COS[i] = Math.cos(rad);
ANGLE_SIN[i] = Math.sin(rad);
}
export function init() {
return {
traceBuffer: new Float32Array(0),
ampBuffer: new Float32Array(0),
filteredL: new Float32Array(0),
filteredR: new Float32Array(0),
phaseAngleBuffer: new Float32Array(0),
phaseAmplitudeBuffer: new Float32Array(0),
phaseWeightBuffer: new Float32Array(0),
phaseAnalysisSeq: -1,
phaseAnalysisLength: 0,
phaseAnalysisSampleRate: 0,
phaseAnalysisCount: 0,
phaseDisplaySeq: -1,
phaseDisplayLastTs: 0,
phaseConfidence: 0,
currentPhase: null,
currentRadius: 0,
@@ -82,9 +49,7 @@ export function init() {
phaseAgcEnv: 1e-3,
phaseAgcGainDb: 0,
phaseAgcLastTs: 0,
bandpass: createBandpassState(),
bufferGrowthCount: 0,
maxBufferSize: 0,
phaseAgcSeq: -1,
phaseTrail: [],
staticLayerCanvas: null,
staticLayerKey: '',
@@ -107,13 +72,13 @@ export async function render(env, state) {
const now = getNow();
drawStaticLayer(g, state, rect, layout, CONFIG, slots.length);
const xyData = extractXYData(audio);
if (xyData.ready) {
const gainCtrl = resolvePhaseGain(state, xyData, CONFIG);
const trace = buildWheelTrace(state, xyData, layout.wheel, gainCtrl.gain, CONFIG, audio);
renderWheel(g, trace, layout.wheel, state, CONFIG, now);
const phaseData = extractPhaseData(audio);
if (phaseData.ready) {
const gainCtrl = resolvePhaseGain(state, phaseData, CONFIG);
const updated = updatePhasePointer(state, phaseData, gainCtrl.gain, CONFIG, audio, now);
renderWheel(g, updated, layout.wheel, state, CONFIG, now);
} else {
decayPhasePointer(state);
updatePhasePointer(state, phaseData, 1, CONFIG, audio, now);
trimPhaseTrail(state, CONFIG, now);
drawPhaseTrail(g, layout.wheel, state, CONFIG, now);
drawPhasePointer(g, layout.wheel, state);
@@ -327,77 +292,54 @@ function radToDeg(rad) {
return (rad * 180) / Math.PI;
}
function extractXYData(audio) {
const xyL = audio?.xyL;
const xyR = audio?.xyR;
const isVec = (v) => v && (Array.isArray(v) || ArrayBuffer.isView(v));
const ready = !!(audio?.alive && isVec(xyL) && isVec(xyR) && xyL.length && xyR.length);
function extractPhaseData(audio) {
const angle = Number(audio?.phaseAngleRad);
const level = Number(audio?.phaseLevel);
const peak = Number(audio?.phasePeak);
return {
ready,
xyL,
xyR,
length: ready ? Math.min(xyL.length, xyR.length) : 0,
sampleRate: audio?.sampleRate || 48000,
seq: Number(audio?.xySeq) || 0,
ready: !!audio?.alive,
angle: Number.isFinite(angle) ? angle : null,
coherence: clamp01(Number(audio?.phaseCoherence) || 0),
level: Number.isFinite(level) ? Math.max(0, level) : 0,
peak: Number.isFinite(peak) ? Math.max(0, peak) : 0,
seq: Number(audio?.phaseSeq) || 0,
};
}
function buildWheelTrace(state, xyData, wheel, gain = 1, CONFIG, audio) {
if (!xyData.ready || !xyData.length) {
decayPhasePointer(state);
return null;
}
const analysis = preparePhaseAnalysis(state, xyData);
function updatePhasePointer(state, phaseData, gain = 1, CONFIG, audio, nowMs) {
const seq = Number(phaseData?.seq) || 0;
if (seq > 0 && state.phaseDisplaySeq === seq) return false;
const now = Number.isFinite(nowMs) ? nowMs : getNow();
const previousTs = Number.isFinite(state.phaseDisplayLastTs) && state.phaseDisplayLastTs > 0
? state.phaseDisplayLastTs
: now - (1000 / 60);
const dt = Math.max(1 / 240, Math.min(0.25, (now - previousTs) / 1000));
state.phaseDisplayLastTs = now;
state.phaseDisplaySeq = seq;
const amplitudeMode = getPhaseAmplitudeMode(CONFIG);
const ringDbValues = getRingDbValues(CONFIG);
const ppmRadiusNorm = amplitudeMode === 'ppm-din'
? computePpmDinRadiusNorm(audio, CONFIG, ringDbValues)
: 0;
const radius = wheel.radius;
let ampIdx = 0;
let sumRadius = 0;
let levelAcc = 0;
const gainLinear = Number.isFinite(gain) ? gain : 1;
for (let i = 0; i < analysis.count; i++) {
const angle = analysis.angles[i];
const amp = analysis.amplitudes[i];
const ampScaled = Math.min(1, amp * gainLinear);
const radiusNorm = amplitudeMode === 'ppm-din'
const targetRadius = amplitudeMode === 'ppm-din'
? ppmRadiusNorm
: linearToRadiusNorm(ampScaled, ringDbValues);
ampIdx++;
sumRadius += radiusNorm;
levelAcc += amp * amp;
}
if (ampIdx > 0) {
const invCount = 1 / ampIdx;
const phaseSummary = summarizeWeightedPhase(
analysis.angles,
analysis.weights,
analysis.count,
);
const avgRadius = amplitudeMode === 'ppm-din' ? ppmRadiusNorm : (sumRadius * invCount);
const blockLevel = Math.sqrt(levelAcc * invCount);
if (blockLevel >= PHASE_LEVEL_THRESHOLD && phaseSummary) {
const avgAngle = phaseSummary.angle;
const prevPhase = Number.isFinite(state.smoothPhase) ? state.smoothPhase : avgAngle;
const prevRadius = Number.isFinite(state.smoothRadius) ? state.smoothRadius : avgRadius;
state.currentPhase = avgAngle;
state.currentRadius = avgRadius;
state.phaseConfidence = phaseSummary.coherence;
state.smoothPhase = smoothAngle(prevPhase, avgAngle, PHASE_PHASE_SMOOTH_ALPHA);
state.smoothRadius = lerp(prevRadius, avgRadius, PHASE_RADIUS_SMOOTH_ALPHA);
: linearToRadiusNorm(Math.min(1, phaseData.level * gainLinear), ringDbValues);
if (Number.isFinite(phaseData.angle) && phaseData.level >= PHASE_LEVEL_THRESHOLD) {
const targetPhase = wrapAngle(phaseData.angle - Math.PI / 2);
const prevPhase = Number.isFinite(state.smoothPhase) ? state.smoothPhase : targetPhase;
const prevRadius = Number.isFinite(state.smoothRadius) ? state.smoothRadius : targetRadius;
state.currentPhase = targetPhase;
state.currentRadius = targetRadius;
state.phaseConfidence = phaseData.coherence;
state.smoothPhase = smoothAngle(prevPhase, targetPhase, smoothingAlpha(dt, PHASE_SMOOTH_TAU_S));
state.smoothRadius = lerp(prevRadius, targetRadius, smoothingAlpha(dt, PHASE_RADIUS_TAU_S));
} else {
state.phaseConfidence = 0;
decayPhasePointer(state);
decayPhasePointer(state, dt);
}
} else {
state.phaseConfidence = 0;
decayPhasePointer(state);
}
return { count: ampIdx, radius };
return true;
}
function renderWheel(g, trace, wheel, state, CONFIG, timestamp) {
@@ -650,11 +592,6 @@ function trailColorForAngle(angle) {
return '#ff7a78';
}
function clamp1(value) {
if (!Number.isFinite(value)) return 0;
return Math.max(-1, Math.min(1, value));
}
function ringFraction(index, ringCount) {
const n = Math.max(1, ringCount | 0);
const idx = Math.max(0, Math.min(n - 1, index | 0));
@@ -722,205 +659,32 @@ function computePpmDinRadiusNorm(audio, cfg, ringDbValues) {
return dbToRadiusNorm(db, ringDbValues);
}
function createBandpassState() {
return {
sampleRate: 0,
hpAlpha: 0,
lpAlpha: 0,
channels: {
L: { hpX: 0, hpY: 0, lpY: 0 },
R: { hpX: 0, hpY: 0, lpY: 0 },
},
};
}
function ensureBandpassCoeffs(state, sampleRate) {
if (!state.bandpass) state.bandpass = createBandpassState();
const sr = Math.max(8000, Math.round(sampleRate) || 48000);
if (state.bandpass.sampleRate === sr) return;
state.bandpass.sampleRate = sr;
state.bandpass.hpAlpha = computeHighpassAlpha(sr, PHASE_BANDPASS_LOW_HZ);
state.bandpass.lpAlpha = computeLowpassAlpha(sr, PHASE_BANDPASS_HIGH_HZ);
}
function computeHighpassAlpha(sampleRate, cutoff) {
const rc = 1 / (2 * Math.PI * Math.max(1, cutoff));
const dt = 1 / Math.max(1, sampleRate);
return Math.max(0, Math.min(1, rc / (rc + dt)));
}
function computeLowpassAlpha(sampleRate, cutoff) {
const rc = 1 / (2 * Math.PI * Math.max(1, cutoff));
const dt = 1 / Math.max(1, sampleRate);
return Math.max(0, Math.min(1, dt / (rc + dt)));
}
function ensureFilteredBuffers(state, length) {
const neededLength = Math.ceil(length * 1.1); // 10% Puffer für Stabilität
if (!state.filteredL || state.filteredL.length < neededLength) {
state.filteredL = new Float32Array(neededLength);
}
if (!state.filteredR || state.filteredR.length < neededLength) {
state.filteredR = new Float32Array(neededLength);
}
return { L: state.filteredL, R: state.filteredR };
}
function applyBandpassSample(sample, channelState, bandpassState) {
const hpAlpha = bandpassState.hpAlpha;
const lpAlpha = bandpassState.lpAlpha;
if (!Number.isFinite(sample)) sample = 0;
const hpY = hpAlpha * (channelState.hpY + sample - channelState.hpX);
channelState.hpY = Number.isFinite(hpY) ? hpY : 0;
channelState.hpX = sample;
const lpY = lpAlpha * hpY + (1 - lpAlpha) * channelState.lpY;
channelState.lpY = Number.isFinite(lpY) ? lpY : 0;
return lpY;
}
function preparePhaseFilteredBuffers(state, xyData) {
ensureBandpassCoeffs(state, xyData.sampleRate || 48000);
const filtered = ensureFilteredBuffers(state, xyData.length);
// Reset channel states if they contain NaN/Infinity
if (!Number.isFinite(state.bandpass.channels.L.hpY)) {
state.bandpass.channels.L = { hpX: 0, hpY: 0, lpY: 0 };
}
if (!Number.isFinite(state.bandpass.channels.R.hpY)) {
state.bandpass.channels.R = { hpX: 0, hpY: 0, lpY: 0 };
}
for (let i = 0; i < xyData.length; i++) {
filtered.L[i] = applyBandpassSample(
xyData.xyL[i],
state.bandpass.channels.L,
state.bandpass
);
filtered.R[i] = applyBandpassSample(
xyData.xyR[i],
state.bandpass.channels.R,
state.bandpass
);
}
return filtered;
}
function ensurePhaseAnalysisBuffers(state, length) {
if (!state.phaseAngleBuffer || state.phaseAngleBuffer.length < length) {
state.phaseAngleBuffer = new Float32Array(length);
}
if (!state.phaseAmplitudeBuffer || state.phaseAmplitudeBuffer.length < length) {
state.phaseAmplitudeBuffer = new Float32Array(length);
}
if (!state.phaseWeightBuffer || state.phaseWeightBuffer.length < length) {
state.phaseWeightBuffer = new Float32Array(length);
}
}
export function summarizeWeightedPhase(angles, weights, count) {
const limit = Math.max(0, Math.min(
Number.isFinite(count) ? Math.floor(count) : 0,
angles?.length || 0,
weights?.length || 0,
));
let sumSin = 0;
let sumCos = 0;
let sumWeight = 0;
for (let i = 0; i < limit; i++) {
const angle = Number(angles[i]);
const weight = Number(weights[i]);
if (!Number.isFinite(angle) || !Number.isFinite(weight) || weight <= 0) continue;
sumSin += Math.sin(angle) * weight;
sumCos += Math.cos(angle) * weight;
sumWeight += weight;
}
if (!(sumWeight > 1e-12)) return null;
const resultant = Math.hypot(sumSin, sumCos);
return {
angle: Math.atan2(sumSin, sumCos),
coherence: Math.max(0, Math.min(1, resultant / sumWeight)),
weight: sumWeight,
};
}
function preparePhaseAnalysis(state, xyData) {
const sampleRate = Math.max(1, Math.round(xyData.sampleRate) || 48000);
const seq = Number(xyData.seq) || 0;
const canReuse = seq > 0
&& state.phaseAnalysisSeq === seq
&& state.phaseAnalysisLength === xyData.length
&& state.phaseAnalysisSampleRate === sampleRate;
if (canReuse) {
return {
angles: state.phaseAngleBuffer,
amplitudes: state.phaseAmplitudeBuffer,
weights: state.phaseWeightBuffer,
count: state.phaseAnalysisCount,
};
}
const filtered = preparePhaseFilteredBuffers(state, xyData);
const targetPoints = Math.min(TARGET_POINTS, xyData.length);
const step = Math.max(1, Math.floor(xyData.length / targetPoints));
const sampleCount = Math.ceil(xyData.length / step);
ensurePhaseAnalysisBuffers(state, sampleCount);
let count = 0;
for (let i = 0; i < xyData.length; i += step) {
const lRe = clamp1(filtered.L[i]);
const rRe = clamp1(filtered.R[i]);
const lIm = hilbertAt(filtered.L, i);
const rIm = hilbertAt(filtered.R, i);
const phaseL = Math.atan2(lIm, lRe);
const phaseR = Math.atan2(rIm, rRe);
let phaseDiff = phaseL - phaseR;
if (!Number.isFinite(phaseDiff)) continue;
phaseDiff = wrapAngle(phaseDiff);
const magL = Math.min(1, Math.hypot(lRe, lIm));
const magR = Math.min(1, Math.hypot(rRe, rIm));
state.phaseAngleBuffer[count] = phaseDiff - Math.PI / 2;
state.phaseAmplitudeBuffer[count] = Math.min(1, 0.5 * (magL + magR));
// The phase of a strong component must contribute more than the phase of
// a quiet or one-sided component. magL * magR is the magnitude of the
// complex L/R cross power for this analytic sample.
state.phaseWeightBuffer[count] = magL * magR;
count++;
}
state.phaseAnalysisSeq = seq;
state.phaseAnalysisLength = xyData.length;
state.phaseAnalysisSampleRate = sampleRate;
state.phaseAnalysisCount = count;
return {
angles: state.phaseAngleBuffer,
amplitudes: state.phaseAmplitudeBuffer,
weights: state.phaseWeightBuffer,
count,
};
}
function decayPhasePointer(state) {
function decayPhasePointer(state, dt = 1 / 60) {
const prevPhase = Number.isFinite(state.currentPhase) ? state.currentPhase : 0;
const prevRadius = Number.isFinite(state.currentRadius) ? state.currentRadius : 0;
const decayedRadius = prevRadius * PHASE_IDLE_DECAY;
const decayedRadius = prevRadius * Math.exp(-Math.max(0, dt) / PHASE_IDLE_TAU_S);
state.currentPhase = prevPhase;
state.currentRadius = decayedRadius;
const prevSmoothPhase = Number.isFinite(state.smoothPhase) ? state.smoothPhase : prevPhase;
const prevSmoothRadius = Number.isFinite(state.smoothRadius) ? state.smoothRadius : decayedRadius;
state.smoothPhase = smoothAngle(prevSmoothPhase, prevPhase, PHASE_PHASE_SMOOTH_ALPHA * 0.5);
state.smoothRadius = lerp(prevSmoothRadius, decayedRadius, PHASE_RADIUS_SMOOTH_ALPHA);
state.smoothPhase = prevSmoothPhase;
state.smoothRadius = lerp(prevSmoothRadius, decayedRadius, smoothingAlpha(dt, PHASE_RADIUS_TAU_S));
}
function resolvePhaseGain(state, xyData, CONFIG) {
function resolvePhaseGain(state, phaseData, CONFIG) {
const gainDb = clampPhaseGain(CONFIG?.PHASE_DISPLAY_GAIN_DB ?? 0);
const allowAgc = CONFIG?.PHASE_AGC_ENABLED && getPhaseAmplitudeMode(CONFIG) !== 'ppm-din';
if (allowAgc && xyData?.ready) {
const auto = computePhaseAgcGain(state, xyData);
return { gainDb: auto.gainDb, gain: auto.gain * PHASE_AGC_BASE_GAIN };
if (allowAgc && phaseData?.ready) {
const seq = Number(phaseData.seq) || 0;
if (seq > 0 && state.phaseAgcSeq === seq) {
return { gainDb: state.phaseAgcGainDb, gain: dbToLinear(state.phaseAgcGainDb) };
}
const auto = computePhaseAgcGain(state, phaseData);
state.phaseAgcSeq = seq;
return { gainDb: auto.gainDb, gain: auto.gain };
}
state.phaseAgcGainDb = gainDb;
state.phaseAgcSeq = Number(phaseData?.seq) || 0;
return { gainDb, gain: dbToLinear(gainDb) };
}
@@ -949,51 +713,22 @@ function lerp(a, b, t) {
return a + (b - a) * clampedT;
}
function hilbertAt(buffer, idx) {
if (!buffer || idx < 0 || idx >= buffer.length) return 0;
let acc = 0;
for (let k = 0; k < HILBERT_KERNEL.length; k++) {
const src = idx + k - HILBERT_HALF;
if (src < 0 || src >= buffer.length) continue;
const sample = buffer[src];
const kernel = HILBERT_KERNEL[k];
if (Number.isFinite(sample) && Number.isFinite(kernel)) {
acc += sample * kernel;
}
}
return Number.isFinite(acc) ? acc : 0;
export function smoothingAlpha(dt, tau) {
const seconds = Number.isFinite(dt) ? Math.max(0, dt) : 0;
const timeConstant = Number.isFinite(tau) ? Math.max(1e-6, tau) : 1e-6;
return 1 - Math.exp(-seconds / timeConstant);
}
function buildHilbertKernel(size = 33) {
const taps = size % 2 === 0 ? size + 1 : size;
const mid = (taps - 1) / 2;
const kernel = new Float32Array(taps);
for (let n = 0; n < taps; n++) {
const k = n - mid;
// Verbesserte numerische Stabilität + even k = 0 wie idealer Hilbert-Kernel
if (Math.abs(k) < 1e-10 || k % 2 === 0) {
kernel[n] = 0;
continue;
function clamp01(value) {
return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;
}
const window = 0.54 - 0.46 * Math.cos((2 * Math.PI * n) / Math.max(1, taps - 1));
const value = (2 / (Math.PI * k)) * window;
// Sicherstellen, dass der Wert finite ist
kernel[n] = Number.isFinite(value) ? value : 0;
}
return kernel;
}
function computePhaseAgcGain(state, xyData) {
function computePhaseAgcGain(state, phaseData) {
const now = getNow();
const dt = state.phaseAgcLastTs ? Math.max(0, (now - state.phaseAgcLastTs) / 1000) : 0;
state.phaseAgcLastTs = now;
const peak = measurePhasePeak(xyData);
const peak = Math.max(1e-8, Number(phaseData?.peak) || 0);
let env = Number.isFinite(state.phaseAgcEnv) && state.phaseAgcEnv > 0 ? state.phaseAgcEnv : 1e-3;
if (peak >= env) {
@@ -1017,23 +752,6 @@ function computePhaseAgcGain(state, xyData) {
return { gainDb, gain: dbToLinear(gainDb) };
}
function measurePhasePeak(xyData) {
if (!xyData || !xyData.ready) return 1e-4; // Verbesserter Default-Wert
let peak = 1e-8; // Höhere Präzision
const len = Math.min(xyData.length, 1000); // Begrenzung für Performance
for (let i = 0; i < len; i++) {
const sample = Math.max(
Math.abs(xyData.xyL[i] || 0),
Math.abs(xyData.xyR[i] || 0)
);
if (sample > peak) peak = sample;
}
return Math.max(1e-8, peak); // Sicherstellen, dass nicht 0 zurückgegeben wird
}
function clampPhaseGain(db) {
let val = Number(db);
if (!Number.isFinite(val)) val = 0;
+6 -3
View File
@@ -79,7 +79,7 @@ export async function render(env, state) {
const useNativeFftEngine = !useIirEngine && rtaData && rtaData.engine === 'fft';
const nativeRtaPacket = (useIirEngine || useNativeFftEngine) ? rtaData : null;
const displayRtaPacket = (nativeRtaPacket && CONFIG.RTA_BAR_LAYOUT === 'rtw')
? selectLocalRtwPacket(nativeRtaPacket, nativeRtaPacket.bpo || '1_3')
? selectLocalRtwPacket(nativeRtaPacket, CONFIG.RTA_BPO_MODE || nativeRtaPacket.bpo || '1_3')
: nativeRtaPacket;
const topInset = Number.isFinite(env?.topInset) ? Number(env.topInset) : DEFAULT_TOP_INSET;
@@ -824,11 +824,13 @@ function buildFixedRtwBands(bpoMode, freqBounds, nyq, overrideCenters) {
return bands;
}
function selectLocalRtwPacket(packet, bpoMode) {
export function selectLocalRtwPacket(packet, bpoMode) {
if (!packet || !isVectorLike(packet.centers) || !packet.centers.length) return packet;
const desiredCenters = getRtwCenters(bpoMode);
if (!desiredCenters.length) return packet;
if (packet.centers.length === desiredCenters.length) return packet;
if (packet.centers.length === desiredCenters.length) {
return packet.bpo === bpoMode ? packet : { ...packet, bpo: bpoMode };
}
const keyFor = (value) => Number(value).toFixed(1);
const indexByCenter = new Map();
@@ -873,6 +875,7 @@ function selectLocalRtwPacket(packet, bpoMode) {
return {
...packet,
bpo: bpoMode,
centers: selectedCenters,
bands_avg: pickVector(packet.bands_avg || packet.bands),
bands_peak: pickVector(packet.bands_peak),