From 91aeccb938bca910adae0eb7872691b4b7d6db8b Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:57:52 +0200 Subject: [PATCH] Recover analyzer DSP and realtime pipeline corrections --- TODO.md | 171 +++++ .../phoenix-global-config.analyzer.json | 8 +- .../phoenix-global-config.volumio.json | 8 +- scripts/test_binary_protocol.mjs | 61 ++ scripts/test_rta_profile.mjs | 41 + scripts/test_spectrogram.mjs | 113 +++ scripts/test_websocket_runtime.mjs | 36 + src/audio.rs | 699 ++++++++++-------- src/config.rs | 7 +- src/correlation.rs | 151 ++++ src/main.rs | 60 +- src/model.rs | 34 +- src/ppm.rs | 342 +++++++++ src/routes.rs | 547 ++++++++++++-- src/rta.rs | 551 ++++++++++++++ src/state.rs | 274 +++++-- www/core/audio.js | 309 +++++++- www/core/binary_protocol.js | 68 ++ www/core/config.js | 85 ++- www/index.html | 8 +- www/meters/ppm_din.js | 4 +- www/meters/ppm_ebu.js | 16 +- www/ui/options.js | 52 +- www/views/goniometer_rtw.js | 133 +--- www/views/realtime.js | 18 +- www/views/spectrogram.js | 157 +++- www/workers/spectrogram.worker.js | 381 ++++------ 27 files changed, 3410 insertions(+), 924 deletions(-) create mode 100644 TODO.md create mode 100644 scripts/test_binary_protocol.mjs create mode 100644 scripts/test_rta_profile.mjs create mode 100644 scripts/test_spectrogram.mjs create mode 100644 scripts/test_websocket_runtime.mjs create mode 100644 src/correlation.rs create mode 100644 src/ppm.rs create mode 100644 src/rta.rs create mode 100644 www/core/binary_protocol.js diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..e3a2cf7 --- /dev/null +++ b/TODO.md @@ -0,0 +1,171 @@ +# Phoenix Analyzer - Soll-/Ist-To-do + +## Verbindliches Ziel + +- Referenz ist der RTW PortaMonitor 1064X/1064X-PLUS, insbesondere dessen RTA-, PPM-, Peakmeter-, Goniometer- und Korrelationsverhalten. +- Der primäre RTW-nahe RTA arbeitet als IIR-Fractional-Octave-Filterbank. FFT bleibt eine optionale, getrennt gekennzeichnete Spektrumsansicht. +- Der RTW-RTA-Modus soll 31 Bänder in 1/3-Oktaven von 20 Hz bis 20 kHz sowie ein Verhalten entsprechend IEC 225/ANSI Class 2 beziehungsweise der passenden aktuellen Nachfolgenorm bieten. +- Vorgesehene RTA-Modi: Fast, Medium, Slow, Average und Peak; Peak Hold 2,5 s, 4 s oder manuell. +- Zwischen hörbarem Signal und Anzeige soll nur die unvermeidbare Mess- und Bildschirmlatenz liegen. Alte Messframes dürfen niemals eine anwachsende Verzögerung erzeugen. +- Normbedingte Ballistiken werden nicht künstlich verkürzt. Technische Transportlatenz und gewollte Instrumententrägheit werden getrennt behandelt. +- LUFS und LRA bleiben vorerst außerhalb dieser Aufgabenliste. + +## Was bereits vorhanden und grundsätzlich brauchbar ist + +- Direkte ALSA-Aufnahme mit kleiner Standardperiode von 128 Samples. +- Ein IIR-RTA ist bereits vorhanden und in Frontend und Backend der Standardmodus. +- Die 31 RTW-Mittenfrequenzen für 1/3-Oktaven von 20 Hz bis 20 kHz sind bereits hinterlegt. +- Zusätzlich existieren 1/6- und 1/12-Oktav-Modi als Phoenix-Erweiterungen. +- RTA-Bandwerte und Peakwerte werden getrennt geführt. +- DIN-PPM, EBU-PPM, True Peak, VU, RMS, Goniometer, Korrelation, Waveform und Spektrogramm sind grundsätzlich vorhanden. +- Das Frontend zeichnet mit bis zu 60 Hz und verwendet für Waveform und Spektrogramm teilweise Worker. +- Konfigurierbare Eingangspegelkorrektur und ein L/R-Laufzeitausgleich existieren. + +Diese vorhandenen Funktionen sind nicht automatisch messtechnisch korrekt. Die folgenden Punkte beschreiben den jeweiligen Soll-/Ist-Unterschied. + +## Priorität 0 - Anwachsende Anzeigeverzögerung verhindern + +- [x] **1. WebSocket auf "latest value wins" umstellen** + - **Soll:** Ein langsamer Client erhält immer den neuesten Messzustand; alte Zustände werden verworfen. + - **Ist:** Der interne Kanal ist auf 32 Frames begrenzt; der WebSocket sendet im 16-ms-Takt und leert vor jedem Versand bis zum neuesten Zustand. Spektrogrammdaten laufen getrennt. + - **Abnahme:** Lokaler Laufzeittest liefert 68 aktuelle Messframes in 1,1 s; alte Zustände werden beim Leeren verworfen. + +- [x] **2. Mess-, Visualisierungs- und Konfigurationsdaten trennen** + - **Soll:** DSP läuft samplegenau; übertragen wird nur so häufig und so umfangreich wie für die jeweilige Anzeige nötig. + - **Ist:** Skalare Messzustände laufen mit etwa 60 JSON-Paketen/s. Spektrogramm sowie Goniometer/Waveform besitzen getrennte, kompakte Binär-WebSockets. Roh-Waveformsamples werden nicht mehr in jedem Capture-Frame vervielfacht; Waveform-Hüllkurven werden beim serverseitigen Leeren lückenlos zusammengeführt. Das doppelte RTA-Bandfeld wurde vollständig entfernt; Konfiguration wird nur separat bei Änderungen synchronisiert. + - **Interne Last:** Der Verteiler reicht Messframes als gemeinsam genutzte Referenz weiter. Beim Verwerfen alter Frames werden deshalb keine kompletten Spektrogramm-, XY- und Waveformvektoren mehr kopiert. + - **Geprüft:** Protokolltests prüfen Header, Nutzdaten und beschädigte Paketlängen. Ein Servertest stellt sicher, dass große Visualisierungsfelder nicht wieder im JSON-Messstrom landen. + - **Abnahme:** Datenwege sind softwareseitig getrennt; die Lastmessung auf der Zielhardware bleibt Bestandteil der End-to-End-Abnahme unter Punkt 17. + +- [x] **3. Browser-Verarbeitung auf den neuesten Zustand begrenzen** + - **Soll:** Pro Bildschirmframe wird höchstens der neueste vollständige Messzustand verarbeitet. + - **Ist:** Der Browser besitzt für Messwerte, Spektrogramm und Visualisierungsdaten jeweils einen Single-Slot-Puffer und verarbeitet höchstens einen aktuellen Zustand pro Animation Frame. Während einer laufenden Verarbeitung ersetzt ein neuer Zustand den wartenden alten. Nicht mehr steigende Sequenznummern werden verworfen. + - **Abnahme:** Es existiert kein unbeschränktes Paket- oder Promise-Backlog mehr. + +- [x] **3a. Spektrogramm dauerhaft echtzeitfähig machen** + - **Ist:** Inkrementelles Spaltenzeichnen statt Vollbild-Neuberechnung, Worker-ACK/Single-Slot, 750-ms-Watchdog mit sauberem Neustart, begrenzter Sprung statt nachträglichem Aufholen sowie eigener Binärstrom mit Quellsequenz. + - **Geschwindigkeit:** Feste Zeitbasis von 60 CSS-Pixeln/s bei 1×; 0,5×, 2×, 4× und 6× skalieren exakt und bleiben unabhängig von FFT-Größe und Display-DPI. Gamma und Geschwindigkeit werden global gespeichert. + - **Abnahme:** Automatische Tests prüfen alle Geschwindigkeiten, FFT-Größenunabhängigkeit, DPI-Skalierung, Worker-ACK, ausschließlich inkrementelles Hochladen der neuen Bildspalte und 60.000 Spalten als synthetischen Zehn-Minuten-Lauf. + +## Priorität 1 - RTW-naher IIR-RTA + +- [x] **4. IIR verbindlich als RTW-RTA-Modus behandeln** + - **Soll:** RTW-Modus bedeutet eindeutig IIR-Filterbank; FFT ist eine gesonderte Zusatzansicht. + - **Ist:** Backend, gespeicherte Konfiguration, Optionsdialog und Laufzeitprofil erzwingen im RTW-Layout jetzt IIR, 31 Dritteloktavbänder, Normalbereich und Filterordnung 6. Alte Browserkonfigurationen können den Motor nicht mehr heimlich auf FFT zurückstellen; das Backend-Paket ist für die Anzeige maßgeblich. + - **Geprüft:** Ein automatischer Profiltest prüft sowohl das gesperrte RTW-Profil als auch die weiterhin freie IEC-/Phoenix-Erweiterung. + - **Abnahme:** Das RTW-Profil startet reproduzierbar immer mit der validierten IIR-Filterbank. + +- [ ] **5. IIR-Filterbank fachgerecht für 31 Dritteloktavbänder auslegen** + - **Soll:** 31 Bänder, 20 Hz bis 20 kHz, korrekte Mittenfrequenzen, Bandkanten und Class-2-Toleranzen. + - **Ist:** Der RTW-Kern verwendet jetzt je Band einen vollständigen Butterworth-Bandpass sechster Ordnung aus drei unterschiedlichen SOS-Sektionen mit vorverzerrten Bandgrenzen und Normierung auf die Bandmitte. Die Anzeige behält die gerundeten RTW-Nominalwerte, während die Filter mit den exakten IEC-Basis-10-Mitten und -Bandkanten rechnen. Koeffizienten, Zustände und Leistungsrechnung laufen intern in `f64`, damit insbesondere das 20-Hz-Band stabil und genau bleibt. + - **Geprüft:** Automatische Frequenzgang- und Zeitbereichstests prüfen alle 31 Mitten, beide -3-dB-Bandkanten, Nachbarbandunterdrückung sowie 44,1, 48 und 96 kHz. A-, C- und Z-Bewertung werden sampleweise vor der Filterbank angewandt und gegen Normformeln geprüft. + - **Noch offen:** Der vollständige Class-2-Toleranzmasken-Nachweis und eine formelle Geräte-/Laborvalidierung fehlen; deshalb bleibt der Gesamtpunkt offen. + - **Abnahme:** Jedes der 31 Bänder besteht automatisierte Sweep- und Pegeltests innerhalb der festgelegten Toleranzen. + +- [ ] **6. RTW-Integrationsmodi vollständig und energetisch korrekt implementieren** + - **Soll:** Fast, Medium, Slow, Average und Peak mit dokumentiertem RTW-nahem Verhalten. + - **Ist:** Fast, Medium, Slow und Impulse integrieren jetzt blockgrößenunabhängig im Leistungsbereich; Average bildet das kumulative Energiemittel, Peak den höchsten gefilterten Samplewert. Erst danach erfolgt die dB-Umrechnung. + - **Geprüft:** Ein Regressionstest bestätigt identische Integration bei unterschiedlichen Blockgrößen. + - **Noch offen:** Die gewählte Medium-Zeitkonstante von 0,5 s und die übrigen Profile müssen noch mit vollständigem RTW-Handbuch oder Referenzgerät abgeglichen werden; deshalb bleibt der Gesamtpunkt offen. + - **Abnahme:** Sprung-, Burst- und Rauschtests zeigen für jeden Modus reproduzierbares RTW-nahes Verhalten. + +- [ ] **7. Peak Hold und Bandspeicher wie beim PortaMonitor ergänzen** + - **Soll:** Peak Hold 2,5 s, 4 s oder manuell; Speicher für acht Bänder plus Hold. + - **Ist:** Die Anzeige unterstützt jetzt Peak Hold 2,5 s, 4 s und manuell. Der ältere kontinuierliche Backend-Peak sowie der achtbandige RTW-Speicher sind noch nicht vollständig ersetzt beziehungsweise ergänzt. + - **Falsch/unvollständig:** Ein kontinuierlich fallender Peak ist funktional nicht dasselbe wie Peak Hold. + - **Aufgabe:** Hold-Zeit, manuellen Hold, Reset, Rücklauf nach Hold-Ende und acht auswählbare Speicherbänder implementieren. Aktuellwert und Holdwert visuell eindeutig trennen. + - **Abnahme:** Hold-Zeiten und Reset-Verhalten stimmen zeitlich und visuell mit der Referenz überein. + +- [ ] **8. RTA-Anzeigeoptionen am PortaMonitor-Profil ausrichten** + - **Soll:** 31 Dritteloktavbänder, wählbarer Mess-/Anzeigebereich von 15, 30 oder 45 dB und optionales zweikanaliges Peakmeter. + - **Ist:** Mehrere BPO-Modi, RTW-/IEC-Layouts, frei konfigurierte Skalierung und Peak-Overlay sind vorhanden. + - **Unstimmig:** Phoenix-Erweiterungen und originale RTW-Funktionen sind nicht getrennt; die Skalen entsprechen nicht zwingend den drei RTW-Bereichen. + - **Aufgabe:** Ein gesperrtes RTW-1064X-Profil mit den Originaloptionen anbieten. 1/6 und 1/12 Oktave sowie andere Bereiche ausdrücklich als Phoenix-Erweiterung kennzeichnen. + - **Abnahme:** Das RTW-Profil lässt sich direkt anhand des Datenblatts und Referenzgeräts nachvollziehen. + +## Priorität 2 - Gemeinsame Messgrundlage + +- [x] **9. Tatsächliche ALSA-Parameter verwenden** + - **Soll:** Alle Filter, Zeitkonstanten, Frequenzachsen und Aufnahmen verwenden die tatsächlich ausgehandelte Hardwarekonfiguration. + - **Ist:** Phoenix liest Rate, Periode und Puffergröße nach der ALSA-Aushandlung zurück. DSP, RTA-Filter, Zeitkonstanten, Aufnahme-Metadaten, Status und Browserfrequenzachse verwenden die tatsächliche Rate; die tatsächliche Periode wird ebenfalls im Messpaket übertragen. + - **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** + - **Soll:** Intersample-Peaks werden unabhängig von ihrer Lage zum ALSA-Block zuverlässig erkannt. + - **Ist:** Eine 4-fache Sinc-Interpolation mit kurzer Historie existiert. + - **Falsch/kaputt:** Ungefähr die letzten acht Intervalle jedes Capture-Blocks werden nicht interpoliert und können zu niedrige dBTP-Werte liefern. + - **Aufgabe:** Kontinuierlichen Oversampling-Filter mit vollständiger Historie verwenden und gegen ITU-Testmaterial sowie synthetische Grenzfälle prüfen. + - **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. + - **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. + +- [ ] **12. VU und RMS eindeutig und reproduzierbar definieren** + - **Soll VU:** RTW-artige Moving-Coil-Ballistik mit richtigem Einschwingen, Rücklauf und Überschwingen. + - **Ist VU:** 300-ms-Rechteckmittel der gleichgerichteten Samples. + - **Falsch VU:** Boxcar-Mittelung entspricht nicht der mechanischen VU-Ballistik. + - **Soll RMS:** Dokumentiertes gleitendes Messfenster mit eindeutigem dBFS-/Kalibrierbezug. + - **Ist RMS:** RMS nur über den aktuellen ALSA-Block, bei 128 Samples etwa 2,67 ms. + - **Falsch RMS:** Wert und Unruhe hängen von der Periodengröße ab. + - **Aufgabe:** Beide Detektoren unabhängig von der Capture-Blockgröße implementieren und separat testen. + - **Abnahme:** Identische Werte und Ballistiken bei verschiedenen ALSA-Perioden. + +- [ ] **13. FFT-Modus als optionale Spektrumsansicht fachlich korrigieren** + - **Soll:** FFT ist eine korrekte Zusatzansicht, aber nicht die RTW-IIR-Referenz. + - **Ist:** FFT-RTA und Spektrogramm sind vorhanden. + - **Falsch:** FFT-Bins werden innerhalb eines Bandes normiert gemittelt statt zur Bandenergie summiert. Rauschsignale und unterschiedlich breite Bänder werden dadurch falsch bewertet. + - **Aufgabe:** Binleistungen energetisch integrieren, Fensterleistung korrekt kompensieren und FFT-Ergebnisse gegen die IIR-Referenz testen. + - **Abnahme:** Konsistente Pegel bei FFT-Größenwechseln und eindeutig getrennte Kennzeichnung im UI. + +## Priorität 3 - Stereoanzeigen und visuelles RTW-Verhalten + +- [ ] **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, Gain, AGC, Linien/Punkte und eine 300-ms-Spur sind vorhanden. + - **Datenweg erledigt:** XY-Punkte werden mit begrenzter Rate über den eigenen Binärstrom übertragen; JSON-Kopien und unbeschränkte Warteschlangen entfallen. + - **Noch offen:** RTW-Persistenzmodi sind nicht verbindlich abgeglichen. + - **Aufgabe:** Fast/Medium/Slow-Persistenz am Referenzgerät abstimmen und die vorhandene Ringpufferdarstellung darauf kalibrieren. + - **Abnahme:** Geringe Reaktionslatenz ohne unnötige Allokationen, bei vergleichbarem visuellen Nachleuchten. + +- [ ] **15. Korrelation vollständig am RTW-Verhalten prüfen** + - **Soll:** Bereich -1 bis +1, passende Farben, wählbare Ansprechzeiten von 1,0 s und 2,5 s sowie Negative-Peak-Memory. + - **Ist:** Korrelations- und Phasendarstellungen sind grundsätzlich vorhanden. + - **Ungeklärt:** Zeitkonstanten, Speicherverhalten und Genauigkeit sind nicht systematisch gegen das PortaMonitor geprüft. + - **Aufgabe:** Testsignale für +1, 0 und -1 sowie gleitende Phasenlagen verwenden; Memory und Reset ergänzen beziehungsweise validieren. + - **Abnahme:** Statische und dynamische Testsignale stimmen innerhalb der festgelegten Toleranz mit der Referenz überein. + +## Priorität 4 - Nachweis und dauerhafte Absicherung + +- [ ] **16. RTW-Referenzprofil vervollständigen** + - **Soll:** Modell, Firmware, Eingangsart, Skala, Referenzpegel und jedes nachzubildende Preset sind dokumentiert. + - **Ist:** Das Datenblatt des 1063X-/1064X-PLUS liegt vor und definiert viele Optionen, aber nicht alle internen Zeitkonstanten und Toleranzdetails. + - **Fehlt:** Vollständiges Bedienhandbuch und/oder reale Vergleichsmessungen. + - **Aufgabe:** Handbuchdaten und Messprotokolle ergänzen; exakt nachzubildende Funktionen von bewussten Phoenix-Erweiterungen trennen. + - **Abnahme:** Jede RTW-nahe Option besitzt eine nachvollziehbare Quelle oder ein Referenzmessprotokoll. + +- [ ] **17. End-to-End-Latenz messen und begrenzen** + - **Soll:** Bei 60-Hz-Display typisch unter 25 ms zusätzliche Anzeigeverzögerung, normal unter 35 ms; bei 120 Hz möglichst 10 bis 20 ms. Keine anwachsende Warteschlange. + - **Ist:** 128-Sample-Capture und 60-Hz-Rendering sind grundsätzlich schnell, aber es gibt keine durchgängige Latenzmessung. + - **Fehlt:** Zeitstempel für Capture, DSP, Versand, Browserempfang und tatsächlichen Paint. + - **Aufgabe:** Monotone Zeitstempel, Median/P95/Maximum sowie sicht-/hörbaren Impulstest einführen. Instrumentenballistik separat ausweisen. + - **Abnahme:** Ziele werden unter realistischer Volllast eingehalten und Regressionen automatisch erkannt. + +- [ ] **18. Automatisierte DSP- und Darstellungsregressionstests aufbauen** + - **Soll:** Keine Änderung kann unbemerkt Pegel, Frequenzgang, Ballistik, Latenz oder RTW-Darstellung verschlechtern. + - **Ist:** DIN-/EBU-PPM, Spektrogramm-Zeitbasis und Langlauf, Worker-Verhalten sowie die Binärprotokolle sind automatisiert abgesichert. Servertests prüfen außerdem das Zusammenführen der Waveform-Hüllkurve und die Trennung großer Nutzdaten vom JSON-Messstrom. + - **Noch offen:** Der RTA besitzt nun Tests für alle Bandmitten und -kanten, Nachbarbandunterdrückung, Zeitbereich, A/C/Z, mehrere Sampleraten und blockgrößenunabhängige Integration. True Peak, VU/RMS, Korrelation, reale Periodenvariation und End-to-End-Latenz besitzen noch keine vollständige automatische Regression. 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. + +## Empfohlene Bearbeitungsreihenfolge + +1. Punkte 1 bis 3: anwachsende Transport- und Browserlatenz beseitigen. +2. Punkt 9: korrekte gemeinsame Zeit- und Frequenzbasis herstellen. +3. Punkte 4 bis 8: IIR-RTA als RTW-Kern verbindlich und korrekt auslegen. +4. Punkte 10 bis 15: weitere Messinstrumente korrigieren und abstimmen. +5. Punkte 16 bis 18: Referenz, Latenz und Messrichtigkeit dauerhaft nachweisen. diff --git a/reference-config/phoenix-global-config.analyzer.json b/reference-config/phoenix-global-config.analyzer.json index 843fbd8..483a1b1 100644 --- a/reference-config/phoenix-global-config.analyzer.json +++ b/reference-config/phoenix-global-config.analyzer.json @@ -9,17 +9,19 @@ "alMarkersEnabled": false, "meterBarThin": 0.55, "panelDividersEnabled": true, - "ppmDinAttackMs": 5.0, - "ppmDinDecayDbPerS": 11.8, + "ppmDinAttackMs": 10.0, + "ppmDinDecayDbPerS": 13.333333, "ppmDinFastAttack": false, "ppmDinLoudnessBoxes": true, "ppmEbuAttackMs": 10.0, - "ppmEbuDecayDbPerS": 8.6, + "ppmEbuDecayDbPerS": 8.571429, "lufsIWindowMin": 4, "lufsINormEnabled": false, "ppmDinLoudnessOffsetDb": 0.0, "xyPoints": 1024, "gonioDisplayGainDb": -5.0, + "spectroGamma": 0.9, + "spectroScrollMode": 1.0, "phaseAmplitudeMode": "ppm-din", "recordOutputFormat": "wav", "recordMp3BitrateKbps": 192, diff --git a/reference-config/phoenix-global-config.volumio.json b/reference-config/phoenix-global-config.volumio.json index f80c2ac..7ecb3f1 100644 --- a/reference-config/phoenix-global-config.volumio.json +++ b/reference-config/phoenix-global-config.volumio.json @@ -9,17 +9,19 @@ "alMarkersEnabled": false, "meterBarThin": 0.55, "panelDividersEnabled": true, - "ppmDinAttackMs": 5.0, - "ppmDinDecayDbPerS": 11.8, + "ppmDinAttackMs": 10.0, + "ppmDinDecayDbPerS": 13.333333, "ppmDinFastAttack": false, "ppmDinLoudnessBoxes": true, "ppmEbuAttackMs": 10.0, - "ppmEbuDecayDbPerS": 8.6, + "ppmEbuDecayDbPerS": 8.571429, "lufsIWindowMin": 4, "lufsINormEnabled": false, "ppmDinLoudnessOffsetDb": 0.0, "xyPoints": 1024, "gonioDisplayGainDb": -5.0, + "spectroGamma": 0.9, + "spectroScrollMode": 1.0, "phaseAmplitudeMode": "ppm-din", "recordOutputFormat": "wav", "recordMp3BitrateKbps": 192, diff --git a/scripts/test_binary_protocol.mjs b/scripts/test_binary_protocol.mjs new file mode 100644 index 0000000..767848b --- /dev/null +++ b/scripts/test_binary_protocol.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; + +const source = fs.readFileSync(new URL('../www/core/binary_protocol.js', import.meta.url), 'utf8'); +const context = vm.createContext({ ArrayBuffer, DataView, Float32Array, Number }); +vm.runInContext(source.replaceAll('export function', 'function'), context, { + filename: 'binary_protocol.js', +}); + +function spectroPacket() { + const buffer = new ArrayBuffer(28); + const view = new DataView(buffer); + view.setUint32(0, 0x50585350, true); + view.setUint32(4, 42, true); + view.setUint32(8, 48000, true); + view.setUint32(12, 8192, true); + view.setUint32(16, 2, true); + view.setFloat32(20, -80, true); + view.setFloat32(24, -12.5, true); + return buffer; +} + +function visualsPacket() { + const buffer = new ArrayBuffer(64); + const view = new DataView(buffer); + view.setUint32(0, 0x50585653, true); + view.setUint16(4, 1, true); + view.setUint16(6, 3, true); + view.setUint32(8, 7, true); + view.setUint32(12, 2, true); + view.setUint32(16, 2, true); + view.setUint16(20, 1, true); + view.setUint32(24, 5, true); + view.setUint32(28, 48000, true); + [-0.5, 0.25, 0.5, -0.25, -0.5, 0.5, -0.25, 0.25] + .forEach((value, index) => view.setFloat32(32 + index * 4, value, true)); + return buffer; +} + +const spectro = context.decodePhoenixSpectroBuffer(spectroPacket()); +assert.equal(spectro.seq, 42); +assert.equal(spectro.sampleRate, 48000); +assert.equal(spectro.fftSize, 8192); +assert.deepEqual(Array.from(spectro.bins), [-80, -12.5]); + +const visual = context.decodePhoenixVisualsBuffer(visualsPacket()); +assert.equal(visual.seq, 7); +assert.deepEqual(Array.from(visual.left), [-0.5, 0.5]); +assert.deepEqual(Array.from(visual.right), [0.25, -0.25]); +assert.equal(visual.wave.columns, 2); +assert.equal(visual.wave.channels, 1); +assert.deepEqual(Array.from(visual.wave.data), [-0.5, 0.5, -0.25, 0.25]); + +assert.equal(context.decodePhoenixSpectroBuffer(spectroPacket().slice(0, 24)), null); +assert.equal(context.decodePhoenixVisualsBuffer(visualsPacket().slice(0, 60)), null); +const wrongVersion = visualsPacket(); +new DataView(wrongVersion).setUint16(4, 2, true); +assert.equal(context.decodePhoenixVisualsBuffer(wrongVersion), null); + +console.log('binary protocol regression tests passed'); diff --git a/scripts/test_rta_profile.mjs b/scripts/test_rta_profile.mjs new file mode 100644 index 0000000..e816687 --- /dev/null +++ b/scripts/test_rta_profile.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; + +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]; +assert.ok(functionSource, 'buildRtaRuntimeConfig must remain testable'); +const context = vm.createContext({ + Number, + getRtwCenters(mode) { + return mode === '1_3' ? new Array(31).fill(0) : []; + }, +}); +vm.runInContext(functionSource.replace('export function', 'function'), context); + +const forced = context.buildRtaRuntimeConfig({ + RTA_BAR_LAYOUT: 'rtw', + RTA_ENGINE: 'fft', + RTA_BPO_MODE: '1_12', + RTA_FREQ_RANGE: 'lf', + RTA_IIR_ORDER: 2, +}); +assert.equal(forced.engine, 'iir'); +assert.equal(forced.bpo, '1_3'); +assert.equal(forced.freqRange, 'norm'); +assert.equal(forced.order, 6); +assert.equal(forced.rtwCenters.length, 31); + +const extension = context.buildRtaRuntimeConfig({ + RTA_BAR_LAYOUT: 'iec', + RTA_ENGINE: 'fft', + RTA_BPO_MODE: '1_12', + RTA_FREQ_RANGE: 'lf', + RTA_IIR_ORDER: 8, +}); +assert.equal(extension.engine, 'fft'); +assert.equal(extension.bpo, '1_12'); +assert.equal(extension.freqRange, 'lf'); +assert.equal(extension.order, 8); + +console.log('RTA profile regression tests passed'); diff --git a/scripts/test_spectrogram.mjs b/scripts/test_spectrogram.mjs new file mode 100644 index 0000000..c1595fa --- /dev/null +++ b/scripts/test_spectrogram.mjs @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; + +const viewSource = fs.readFileSync(new URL('../www/views/spectrogram.js', import.meta.url), 'utf8'); +const timingSource = viewSource.match(/export function stepScrollAccumulator[\s\S]*?\n\}/)?.[0]; +assert.ok(timingSource, 'stepScrollAccumulator must remain testable'); +const timingContext = vm.createContext({ Number, Math }); +vm.runInContext(timingSource.replace('export function', 'function'), timingContext); +const { stepScrollAccumulator } = timingContext; +const pixelsSource = viewSource.match(/export function spectrogramPixelsPerSourceFrame[\s\S]*?\n\}/)?.[0]; +assert.ok(pixelsSource, 'spectrogramPixelsPerSourceFrame must remain testable'); +timingContext.BASE_SCROLL_CSS_PX_PER_SECOND = 60; +vm.runInContext(pixelsSource.replace('export function', 'function'), timingContext); +const { spectrogramPixelsPerSourceFrame } = timingContext; + +function emittedColumns(rate, frames, pixelScale = 1) { + let accumulator = 0; + let columns = 0; + for (let frame = 0; frame < frames; frame++) { + const step = stepScrollAccumulator(accumulator, rate * pixelScale, 1); + accumulator = step.accumulator; + columns += step.columns; + } + return columns; +} + +assert.equal(emittedColumns(0.5, 20), 10, '0.5x must emit every second source frame'); +assert.equal(emittedColumns(1, 20), 20); +assert.equal(emittedColumns(2, 20), 40); +assert.equal(emittedColumns(4, 20), 80); +assert.equal(emittedColumns(6, 20), 120); +assert.equal(emittedColumns(1, 10, 1.7), 17, 'DPR scaling must preserve CSS scroll speed'); + +function emittedInOneSecond(rate, fftSize) { + const sampleRate = 48000; + const hop = Math.max(128, Math.floor(fftSize / 8)); + const sourceFrames = sampleRate / hop; + const pixelsPerFrame = spectrogramPixelsPerSourceFrame(rate, sampleRate, fftSize); + return emittedColumns(pixelsPerFrame, sourceFrames); +} + +assert.equal(emittedInOneSecond(0.5, 4096), 30); +assert.equal(emittedInOneSecond(1, 4096), 60); +assert.equal(emittedInOneSecond(1, 8192), 60, 'FFT size must not alter real-time scroll speed'); +assert.equal(emittedInOneSecond(2, 8192), 120); +assert.equal(emittedInOneSecond(4, 8192), 240); +assert.equal(emittedInOneSecond(6, 8192), 360); + +const messages = []; +const calls = { drawImage: 0, putImageData: [] }; +const context2d = { + imageSmoothingEnabled: true, + globalCompositeOperation: 'source-over', + fillStyle: '#000', + save() {}, + restore() {}, + fillRect() {}, + drawImage() { calls.drawImage += 1; }, + createImageData(width, height) { + return { width, height, data: new Uint8ClampedArray(width * height * 4) }; + }, + putImageData(image, x, y) { calls.putImageData.push({ image, x, y }); }, +}; +const canvas = { + width: 0, + height: 0, + getContext() { return context2d; }, +}; +const workerContext = vm.createContext({ + self: {}, + postMessage(message) { messages.push(message); }, + performance: { now: (() => { let now = 0; return () => ++now; })() }, + Float32Array, + Uint8ClampedArray, + Number, + Math, +}); +const workerSource = fs.readFileSync(new URL('../www/workers/spectrogram.worker.js', import.meta.url), 'utf8'); +vm.runInContext(workerSource, workerContext, { filename: 'spectrogram.worker.js' }); + +workerContext.self.onmessage({ + data: { type: 'init', canvas, width: 8, height: 4, topDb: 0, bottomDb: -80, gamma: 1 }, +}); +assert.equal(messages.at(-1)?.type, 'ready'); + +workerContext.self.onmessage({ + data: { type: 'column', id: 7, repeat: 2, data: new Float32Array([-80, -40, -20, 0]) }, +}); +const ack = messages.at(-1); +assert.equal(ack.type, 'drawn'); +assert.equal(ack.id, 7); +assert.equal(ack.repeat, 2); +assert.equal(calls.drawImage, 1, 'existing canvas should be shifted once'); +assert.equal(calls.putImageData.length, 1, 'only the new stripe should be uploaded'); +assert.equal(calls.putImageData[0].image.width, 2); +assert.equal(calls.putImageData[0].x, 6); + +// Roughly ten minutes at 100 source columns/s. The worker has no history or +// pending-message collection that can grow with runtime. +messages.length = 0; +context2d.putImageData = () => {}; +const longRunColumn = new Float32Array([-80, -40, -20, 0]); +for (let index = 0; index < 60_000; index++) { + workerContext.self.onmessage({ + data: { type: 'column', id: 100 + index, repeat: 1, data: longRunColumn }, + }); +} +assert.equal(messages.length, 60_000); +assert.equal(messages.at(-1)?.type, 'drawn'); +assert.equal(messages.at(-1)?.id, 60_099); + +console.log('spectrogram timing and worker tests passed'); diff --git a/scripts/test_websocket_runtime.mjs b/scripts/test_websocket_runtime.mjs new file mode 100644 index 0000000..a08ad89 --- /dev/null +++ b/scripts/test_websocket_runtime.mjs @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; + +const base = process.env.PHOENIX_TEST_WS_BASE || 'ws://127.0.0.1:8789'; + +function openSocket(path) { + return new Promise((resolve, reject) => { + const socket = new WebSocket(`${base}${path}`); + socket.addEventListener('open', () => resolve(socket), { once: true }); + socket.addEventListener('error', () => reject(new Error(`failed to open ${path}`)), { once: true }); + }); +} + +const sockets = await Promise.all([ + openSocket('/api/v1/metrics/ws'), + openSocket('/api/v1/spectro/ws'), + openSocket('/api/v1/visuals/ws'), +]); +const [metrics] = sockets; +let count = 0; +let lastSeq = 0; +let invalidPayload = false; +metrics.addEventListener('message', (event) => { + const frame = JSON.parse(String(event.data)); + const seq = Number(frame.seq); + if (seq <= lastSeq || 'spectro' in frame || 'wave_env' in frame || 'xy_l' in frame || 'wave_l' in frame) { + invalidPayload = true; + } + lastSeq = seq; + count += 1; +}); + +await new Promise((resolve) => setTimeout(resolve, 1100)); +sockets.forEach((socket) => socket.close()); +assert.equal(invalidPayload, false, 'metrics must be ordered and contain no large visual payloads'); +assert.ok(count >= 50 && count <= 75, `expected about 60 metrics/s, received ${count}`); +console.log(`runtime websocket test passed (${count} metrics in 1.1 s; all three streams opened)`); diff --git a/src/audio.rs b/src/audio.rs index bac6ddb..b5b52b2 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -1,29 +1,38 @@ +#[cfg(target_os = "linux")] +use std::collections::VecDeque; use std::{ collections::HashMap, sync::{ atomic::{AtomicU64, Ordering}, - Arc, - Mutex, + Arc, Mutex, }, time::{Duration, SystemTime, UNIX_EPOCH}, }; -#[cfg(target_os = "linux")] -use std::collections::VecDeque; use tracing::warn; +#[cfg(target_os = "linux")] +use crate::model::{RtaFrame, SpectroFrame, WaveEnvFrame}; +#[cfg(target_os = "linux")] +use crate::correlation::CorrelationMeter; +#[cfg(target_os = "linux")] +use crate::ppm::{PpmDetector, PpmStandard}; +#[cfg(target_os = "linux")] +use crate::rta::{ + design_fractional_octave_band, exact_fractional_octave_center, fractional_octave_edges, + integrate_power, normalize_weighting, weighting_power_correction, weighting_reference_db, + FrequencyWeighting, StereoCascade, RTW_THIRD_OCTAVE_CENTERS, +}; +use crate::state::NativeWavRecorder; use crate::{ config::PhoenixConfig, model::{InputSource, MeterFrame, PhoenixRtaConfig}, }; -#[cfg(target_os = "linux")] -use crate::model::{RtaFrame, SpectroFrame, WaveEnvFrame}; -use crate::state::NativeWavRecorder; #[cfg(target_os = "linux")] -const XY_SEND_INTERVAL_FRAMES: usize = 2; -#[cfg(target_os = "linux")] -const XY_HISTORY_SAMPLES: usize = 2048; +// 128-sample periods at 48 kHz -> 62.5 visual updates/s. The DSP history is +// still updated sample-by-sample; only transport snapshots are rate-limited. +const XY_TARGET_UPDATES_PER_SECOND: u64 = 60; #[cfg(target_os = "linux")] const RTA_PEAK_DECAY_DB_PER_S: f32 = 10.0; #[cfg(target_os = "linux")] @@ -51,43 +60,31 @@ const VU_WINDOW_MS: f32 = 300.0; #[cfg(target_os = "linux")] const VU_RECT_TO_RMS_GAIN: f32 = std::f32::consts::PI / (2.0 * std::f32::consts::SQRT_2); #[cfg(target_os = "linux")] -const PPM_RECT_TO_PEAK_GAIN: f32 = std::f32::consts::PI / 2.0; -#[cfg(target_os = "linux")] const TRUE_PEAK_OVERSAMPLE: usize = 4; #[cfg(target_os = "linux")] const TRUE_PEAK_INTERP_RADIUS: usize = 8; #[cfg(target_os = "linux")] const NATIVE_RECORDER_DISCONTINUITY_BLEND_FRAMES: usize = 256; #[cfg(target_os = "linux")] -#[cfg(target_os = "linux")] -const RTW_CENTERS_1_3: &[f32] = &[ - 20.0, 25.0, 31.5, 40.0, 50.0, 63.0, 80.0, 100.0, 125.0, 160.0, - 200.0, 250.0, 315.0, 400.0, 500.0, 630.0, 800.0, 1000.0, 1250.0, 1600.0, - 2000.0, 2500.0, 3150.0, 4000.0, 5000.0, 6300.0, 8000.0, 10_000.0, 12_500.0, 16_000.0, 20_000.0, -]; -#[cfg(target_os = "linux")] const RTW_CENTERS_1_6: &[f32] = &[ - 20.0, 22.4, 25.0, 28.0, 31.5, 35.5, 40.0, 45.0, 50.0, 56.0, - 63.0, 70.8, 80.0, 90.0, 100.0, 112.0, 125.0, 141.0, 160.0, 180.0, - 200.0, 224.0, 250.0, 282.0, 315.0, 355.0, 400.0, 450.0, 500.0, 560.0, - 630.0, 708.0, 800.0, 900.0, 1000.0, 1120.0, 1250.0, 1410.0, 1600.0, 1800.0, - 2000.0, 2240.0, 2500.0, 2820.0, 3150.0, 3550.0, 4000.0, 4500.0, 5000.0, 5600.0, - 6300.0, 7080.0, 8000.0, 9000.0, 10000.0, 11200.0, 12500.0, 14100.0, 16000.0, 18000.0, 20000.0, + 20.0, 22.4, 25.0, 28.0, 31.5, 35.5, 40.0, 45.0, 50.0, 56.0, 63.0, 70.8, 80.0, 90.0, 100.0, + 112.0, 125.0, 141.0, 160.0, 180.0, 200.0, 224.0, 250.0, 282.0, 315.0, 355.0, 400.0, 450.0, + 500.0, 560.0, 630.0, 708.0, 800.0, 900.0, 1000.0, 1120.0, 1250.0, 1410.0, 1600.0, 1800.0, + 2000.0, 2240.0, 2500.0, 2820.0, 3150.0, 3550.0, 4000.0, 4500.0, 5000.0, 5600.0, 6300.0, 7080.0, + 8000.0, 9000.0, 10000.0, 11200.0, 12500.0, 14100.0, 16000.0, 18000.0, 20000.0, ]; #[cfg(target_os = "linux")] const RTW_CENTERS_1_12: &[f32] = &[ - 20.0, 21.2, 22.4, 23.8, 25.0, 26.5, 28.0, 29.8, 31.5, 33.4, - 35.5, 37.6, 40.0, 42.4, 45.0, 47.5, 50.0, 53.0, 56.0, 59.5, - 63.0, 67.0, 71.0, 75.0, 80.0, 85.0, 90.0, 95.0, 100.0, 106.0, - 112.0, 118.0, 125.0, 132.0, 140.0, 150.0, 160.0, 170.0, 180.0, 190.0, - 200.0, 212.0, 224.0, 238.0, 250.0, 265.0, 280.0, 298.0, 315.0, 334.0, - 355.0, 376.0, 400.0, 424.0, 450.0, 475.0, 500.0, 530.0, 560.0, 595.0, - 630.0, 670.0, 710.0, 750.0, 800.0, 850.0, 900.0, 950.0, 1000.0, 1060.0, - 1120.0, 1180.0, 1250.0, 1320.0, 1400.0, 1500.0, 1600.0, 1700.0, 1800.0, 1900.0, - 2000.0, 2120.0, 2240.0, 2380.0, 2500.0, 2650.0, 2800.0, 2980.0, 3150.0, 3340.0, - 3550.0, 3760.0, 4000.0, 4240.0, 4500.0, 4750.0, 5000.0, 5300.0, 5600.0, 5950.0, - 6300.0, 6700.0, 7100.0, 7500.0, 8000.0, 8500.0, 9000.0, 9500.0, 10_000.0, 10_600.0, - 11_200.0, 11_800.0, 12_500.0, 13_200.0, 14_000.0, 15_000.0, 16_000.0, 17_000.0, 18_000.0, 19_000.0, 20_000.0, + 20.0, 21.2, 22.4, 23.8, 25.0, 26.5, 28.0, 29.8, 31.5, 33.4, 35.5, 37.6, 40.0, 42.4, 45.0, 47.5, + 50.0, 53.0, 56.0, 59.5, 63.0, 67.0, 71.0, 75.0, 80.0, 85.0, 90.0, 95.0, 100.0, 106.0, 112.0, + 118.0, 125.0, 132.0, 140.0, 150.0, 160.0, 170.0, 180.0, 190.0, 200.0, 212.0, 224.0, 238.0, + 250.0, 265.0, 280.0, 298.0, 315.0, 334.0, 355.0, 376.0, 400.0, 424.0, 450.0, 475.0, 500.0, + 530.0, 560.0, 595.0, 630.0, 670.0, 710.0, 750.0, 800.0, 850.0, 900.0, 950.0, 1000.0, 1060.0, + 1120.0, 1180.0, 1250.0, 1320.0, 1400.0, 1500.0, 1600.0, 1700.0, 1800.0, 1900.0, 2000.0, 2120.0, + 2240.0, 2380.0, 2500.0, 2650.0, 2800.0, 2980.0, 3150.0, 3340.0, 3550.0, 3760.0, 4000.0, 4240.0, + 4500.0, 4750.0, 5000.0, 5300.0, 5600.0, 5950.0, 6300.0, 6700.0, 7100.0, 7500.0, 8000.0, 8500.0, + 9000.0, 9500.0, 10_000.0, 10_600.0, 11_200.0, 11_800.0, 12_500.0, 13_200.0, 14_000.0, 15_000.0, + 16_000.0, 17_000.0, 18_000.0, 19_000.0, 20_000.0, ]; #[derive(Clone)] @@ -101,7 +98,8 @@ pub struct AudioWorkerDeps { #[cfg_attr(not(target_os = "linux"), allow(dead_code))] pub native_wav_recorders: Arc>>, pub seq: Arc, - pub metrics_tx: tokio::sync::broadcast::Sender, + pub actual_sample_rate: Arc, + pub metrics_tx: tokio::sync::broadcast::Sender>, #[cfg_attr(not(target_os = "linux"), allow(dead_code))] pub restart_token: Arc, } @@ -109,47 +107,30 @@ pub struct AudioWorkerDeps { #[cfg(target_os = "linux")] #[derive(Clone, Copy)] struct RtaBandDef { + /// Nominal center used for the familiar instrument label (for example 31.5 Hz). center: f32, + /// Exact center used by the measurement filter. + measurement_center: f32, f_lo: f32, f_hi: f32, } -#[cfg(target_os = "linux")] -#[derive(Clone, Copy)] -struct BiquadCoeffs { - b0: f32, - b1: f32, - b2: f32, - a1: f32, - a2: f32, -} - -#[cfg(target_os = "linux")] -#[derive(Clone, Copy)] -struct RtaSectionState { - b0: f32, - b1: f32, - b2: f32, - a1: f32, - a2: f32, - z1_l: f32, - z2_l: f32, - z1_r: f32, - z2_r: f32, -} - #[cfg(target_os = "linux")] struct RtaBank { layout: &'static str, bpo_key: &'static str, freq_min: f32, freq_max: f32, - band_defs: Vec, centers: Vec, - filters: Vec>, - acc_l: Vec, - acc_r: Vec, - energies: Vec, + filters: Vec, + weighting: FrequencyWeighting, + weighting_corrections: Vec, + acc_l: Vec, + acc_r: Vec, + block_peaks: Vec, + average_acc: Vec, + average_samples: u64, + energies: Vec, levels: Vec, peaks: Vec, } @@ -195,6 +176,7 @@ struct FftRtaState { #[cfg(target_os = "linux")] struct SpectroState { + seq: u64, fft_size: usize, fft_step_samples: usize, ring_l: Vec, @@ -301,7 +283,10 @@ pub fn spawn_audio_capture_worker(deps: AudioWorkerDeps) { loop { let generation = deps.restart_token.load(Ordering::SeqCst); let deps_for_run = deps.clone(); - let join = tokio::task::spawn_blocking(move || capture_until_restart(deps_for_run, generation)).await; + let join = tokio::task::spawn_blocking(move || { + capture_until_restart(deps_for_run, generation) + }) + .await; match join { Ok(Ok(())) => { @@ -325,6 +310,8 @@ pub fn spawn_audio_capture_worker(deps: AudioWorkerDeps) { tokio::spawn(async move { warn!("Phoenix ALSA capture is only available on Linux; emitting placeholder frames on this host"); let mut ticker = tokio::time::interval(Duration::from_millis(16)); + deps.actual_sample_rate + .store(deps.config.sample_rate as u64, Ordering::SeqCst); loop { ticker.tick().await; let input = *deps.input.read().await; @@ -334,6 +321,10 @@ pub fn spawn_audio_capture_worker(deps: AudioWorkerDeps) { .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis(), + sample_rate: deps.config.sample_rate, + period_size: deps.config.period_size, + correlation: 0.0, + correlation_negative_peak: 0.0, rms_l: -120.0, rms_r: -120.0, vu_l: -120.0, @@ -366,7 +357,7 @@ pub fn spawn_audio_capture_worker(deps: AudioWorkerDeps) { input, source: "non-linux-placeholder", }; - let _ = deps.metrics_tx.send(frame); + let _ = deps.metrics_tx.send(Arc::new(frame)); } }); } @@ -377,7 +368,7 @@ fn capture_until_restart(deps: AudioWorkerDeps, generation: u64) -> anyhow::Resu use std::thread; use alsa::{ - pcm::{Access, Format, Frames, HwParams, PCM, State as PcmState}, + pcm::{Access, Format, Frames, HwParams, State as PcmState, PCM}, Direction, ValueOr, }; use tracing::info; @@ -387,16 +378,14 @@ fn capture_until_restart(deps: AudioWorkerDeps, generation: u64) -> anyhow::Resu let pcm = PCM::new(&device, Direction::Capture, false)?; let hwp = HwParams::any(&pcm)?; - let period_size: Frames = deps - .config - .period_size - .try_into() - .map_err(|_| anyhow::anyhow!("invalid ALSA period size: {}", deps.config.period_size))?; - let buffer_size: Frames = deps - .config - .buffer_size - .try_into() - .map_err(|_| anyhow::anyhow!("invalid ALSA buffer size: {}", deps.config.buffer_size))?; + let period_size: Frames = + deps.config.period_size.try_into().map_err(|_| { + anyhow::anyhow!("invalid ALSA period size: {}", deps.config.period_size) + })?; + let buffer_size: Frames = + deps.config.buffer_size.try_into().map_err(|_| { + anyhow::anyhow!("invalid ALSA buffer size: {}", deps.config.buffer_size) + })?; hwp.set_channels(2)?; hwp.set_rate(deps.config.sample_rate, ValueOr::Nearest)?; hwp.set_format(Format::s16())?; @@ -404,10 +393,21 @@ fn capture_until_restart(deps: AudioWorkerDeps, generation: u64) -> anyhow::Resu hwp.set_period_size(period_size, ValueOr::Nearest)?; hwp.set_buffer_size(buffer_size)?; pcm.hw_params(&hwp)?; + let actual_sample_rate = hwp.get_rate()?; + let actual_period_size = hwp.get_period_size()?; + let actual_buffer_size = hwp.get_buffer_size()?; + info!( + "Phoenix ALSA negotiated rate={} Hz, period={} frames, buffer={} frames", + actual_sample_rate, actual_period_size, actual_buffer_size + ); + deps.actual_sample_rate + .store(actual_sample_rate as u64, Ordering::SeqCst); + drop(hwp); pcm.prepare()?; let io = pcm.io_i16()?; - let frame_samples = deps.config.period_size as usize; + let frame_samples = usize::try_from(actual_period_size) + .map_err(|_| anyhow::anyhow!("invalid negotiated ALSA period: {actual_period_size}"))?; let mut buffer = vec![0i16; frame_samples * 2]; let mut ppm = PpmState::default(); @@ -425,17 +425,21 @@ fn capture_until_restart(deps: AudioWorkerDeps, generation: u64) -> anyhow::Resu } let input = *deps.input.blocking_read(); let rta_config = deps.rta_config.blocking_read().clone(); - append_native_wav_frames(&deps.native_wav_recorders, &buffer[..frames * 2], deps.config.sample_rate); + append_native_wav_frames( + &deps.native_wav_recorders, + &buffer[..frames * 2], + actual_sample_rate, + ); let frame = build_meter_frame( &buffer[..frames * 2], - deps.config.sample_rate, + actual_sample_rate, input, deps.global_config_rev.load(Ordering::SeqCst), &deps.seq, &mut ppm, &rta_config, ); - let _ = deps.metrics_tx.send(frame); + let _ = deps.metrics_tx.send(Arc::new(frame)); } Err(err) => match pcm.state() { PcmState::XRun | PcmState::Suspended => { @@ -452,14 +456,9 @@ fn capture_until_restart(deps: AudioWorkerDeps, generation: u64) -> anyhow::Resu #[cfg(target_os = "linux")] struct PpmState { - env_l: f32, - env_r: f32, - ebu_env_l: f32, - ebu_env_r: f32, - din_window: MovingAverageWindow, - ebu_window: MovingAverageWindow, + din_ppm: PpmDetector, + ebu_ppm: PpmDetector, vu_window: MovingAverageWindow, - frame_counter: usize, last_rta: Option, last_spectro: Option, rta_signature: String, @@ -479,22 +478,19 @@ struct PpmState { lr_delay_y1_r: f32, tp_history_l: Vec, tp_history_r: Vec, - xy_history_l: VecDeque, - xy_history_r: VecDeque, + correlation: CorrelationMeter, + xy_pending_l: Vec, + xy_pending_r: Vec, + xy_emit_phase: u64, } #[cfg(target_os = "linux")] impl Default for PpmState { fn default() -> Self { Self { - env_l: 0.0, - env_r: 0.0, - ebu_env_l: 0.0, - ebu_env_r: 0.0, - din_window: create_moving_average_window(48_000, 5.0), - ebu_window: create_moving_average_window(48_000, 10.0), + din_ppm: PpmDetector::new(48_000, PpmStandard::Din), + ebu_ppm: PpmDetector::new(48_000, PpmStandard::EbuTypeIib), vu_window: create_moving_average_window(48_000, VU_WINDOW_MS), - frame_counter: 0, last_rta: None, last_spectro: None, rta_signature: String::new(), @@ -514,8 +510,10 @@ impl Default for PpmState { lr_delay_y1_r: 0.0, tp_history_l: Vec::new(), tp_history_r: Vec::new(), - xy_history_l: VecDeque::with_capacity(XY_HISTORY_SAMPLES), - xy_history_r: VecDeque::with_capacity(XY_HISTORY_SAMPLES), + correlation: CorrelationMeter::new(48_000, 1.0, 0), + xy_pending_l: Vec::with_capacity(1024), + xy_pending_r: Vec::with_capacity(1024), + xy_emit_phase: 0, } } } @@ -538,7 +536,9 @@ fn append_native_wav_frames( capture.pcm_bytes.reserve(interleaved.len() * 2); let needs_blend = capture.pending_discontinuity && capture.total_frames > 0; let blend_frames = if needs_blend { - NATIVE_RECORDER_DISCONTINUITY_BLEND_FRAMES.min(interleaved.len() / 2).max(1) + NATIVE_RECORDER_DISCONTINUITY_BLEND_FRAMES + .min(interleaved.len() / 2) + .max(1) } else { 0 }; @@ -565,9 +565,7 @@ fn append_native_wav_frames( } #[cfg(target_os = "linux")] -fn mark_native_wav_discontinuity( - sessions: &Arc>>, -) { +fn mark_native_wav_discontinuity(sessions: &Arc>>) { let Ok(mut guard) = sessions.lock() else { return; }; @@ -679,7 +677,9 @@ fn create_moving_average_window(sample_rate: u32, window_ms: f32) -> MovingAvera #[cfg(target_os = "linux")] 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; + 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; } @@ -700,7 +700,10 @@ fn moving_average_push(state: &mut MovingAverageWindow, l: f32, r: f32) -> (f32, 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) + ( + (state.sum_l / denom as f64) as f32, + (state.sum_r / denom as f64) as f32, + ) } #[cfg(target_os = "linux")] @@ -780,22 +783,14 @@ fn create_bs1770_prefilter(sample_rate: u32) -> LoudnessBiquadState { #[cfg(target_os = "linux")] fn create_bs1770_rlb_filter(sample_rate: u32) -> LoudnessBiquadState { if sample_rate == 48_000 { - return create_biquad_from_coeffs( - 1.0, - -2.0, - 1.0, - -1.99004745483398, - 0.99007225036621, - ); + return create_biquad_from_coeffs(1.0, -2.0, 1.0, -1.99004745483398, 0.99007225036621); } biquad_highpass(sample_rate, 38.13547087602444, 0.5003270373238773) } #[cfg(target_os = "linux")] fn process_loudness_biquad(state: &mut LoudnessBiquadState, x: f32) -> f32 { - let y = state.b0 * x - + state.b1 * state.x1 - + state.b2 * state.x2 + let y = state.b0 * x + state.b1 * state.x1 + state.b2 * state.x2 - state.a1 * state.y1 - state.a2 * state.y2; state.x2 = state.x1; @@ -811,8 +806,12 @@ fn create_lufs_state(sample_rate: u32) -> LufsState { let block_samples = ((LUFS_BLOCK_MS / 1000.0) * sr as f32).round().max(1.0) as usize; let short_blocks = ((LUFS_SHORT_WINDOW_MS / LUFS_BLOCK_MS).round().max(1.0)) as usize; let lra_blocks = ((LUFS_LRA_HISTORY_MS / LUFS_BLOCK_MS).round().max(2.0)) as usize; - let int_blocks = ((LUFS_INT_WINDOW_MIN as f32 * 60_000.0) / LUFS_BLOCK_MS).round().max(1.0) as usize; - let box_window_len = ((RTW_LOUDNESS_WINDOW_MS / 1000.0) * sr as f32).round().max(1.0) as usize; + let int_blocks = ((LUFS_INT_WINDOW_MIN as f32 * 60_000.0) / LUFS_BLOCK_MS) + .round() + .max(1.0) as usize; + let box_window_len = ((RTW_LOUDNESS_WINDOW_MS / 1000.0) * sr as f32) + .round() + .max(1.0) as usize; let mut state = LufsState { sample_rate: sr, block_samples, @@ -965,8 +964,10 @@ fn process_lufs_sample(state: &mut LufsState, l: f32, r: f32, config: &PhoenixRt state.ring_sr.pop_front(); } let avg_short = state.ring_s.iter().copied().sum::() / state.ring_s.len().max(1) as f32; - let avg_short_l = state.ring_sl.iter().copied().sum::() / state.ring_sl.len().max(1) as f32; - let avg_short_r = state.ring_sr.iter().copied().sum::() / state.ring_sr.len().max(1) as f32; + let avg_short_l = + state.ring_sl.iter().copied().sum::() / state.ring_sl.len().max(1) as f32; + let avg_short_r = + state.ring_sr.iter().copied().sum::() / state.ring_sr.len().max(1) as f32; state.lu_s = lufs_from_stereo_power(avg_short); state.lu_s_l = lufs_from_stereo_power(avg_short_l); state.lu_s_r = lufs_from_stereo_power(avg_short_r); @@ -990,7 +991,10 @@ fn process_lufs_sample(state: &mut LufsState, l: f32, r: f32, config: &PhoenixRt state.int_powers.push_back(p_block); state.int_block_ids.push_back(state.block_index); if !norm_mode { - let int_cap = (((config.lufs_i_window_min.max(1).min(10)) as f32 * 60_000.0) / LUFS_BLOCK_MS).round().max(1.0) as usize; + let int_cap = (((config.lufs_i_window_min.max(1).min(10)) as f32 * 60_000.0) + / LUFS_BLOCK_MS) + .round() + .max(1.0) as usize; while state.int_powers.len() > int_cap { state.int_powers.pop_front(); state.int_block_ids.pop_front(); @@ -1103,7 +1107,10 @@ fn interpolate_true_peak_sample(samples: &[f32], pos: f32) -> f32 { fn estimate_true_peak(history: &mut Vec, block: &[f32]) -> f32 { let radius = TRUE_PEAK_INTERP_RADIUS; let hist_len = history.len(); - let mut peak = block.iter().copied().fold(0.0f32, |acc, v| acc.max(v.abs())); + let mut peak = block + .iter() + .copied() + .fold(0.0f32, |acc, v| acc.max(v.abs())); if block.is_empty() { return peak; } @@ -1132,11 +1139,29 @@ fn estimate_true_peak(history: &mut Vec, block: &[f32]) -> f32 { } #[cfg(target_os = "linux")] -fn push_xy_history(history: &mut VecDeque, sample: f32) { - if history.len() >= XY_HISTORY_SAMPLES { - let _ = history.pop_front(); +fn take_goniometer_samples(state: &mut PpmState, target_points: usize) -> (Vec, Vec) { + let available = state.xy_pending_l.len().min(state.xy_pending_r.len()); + if available == 0 { + return (Vec::new(), Vec::new()); } - history.push_back(sample); + let count = available.min(target_points.max(2)); + let mut left = Vec::with_capacity(count); + let mut right = Vec::with_capacity(count); + if count == available { + left.extend_from_slice(&state.xy_pending_l[..available]); + right.extend_from_slice(&state.xy_pending_r[..available]); + } else { + // Preserve chronological sample pairs while reducing transport to the + // selected display density. Endpoints are always retained. + for index in 0..count { + let source = index * (available - 1) / (count - 1); + left.push(state.xy_pending_l[source]); + right.push(state.xy_pending_r[source]); + } + } + state.xy_pending_l.clear(); + state.xy_pending_r.clear(); + (left, right) } #[cfg(target_os = "linux")] @@ -1159,20 +1184,25 @@ fn build_meter_frame( ensure_wave_env_state(&mut ppm_state.wave_env, sample_rate); ensure_lufs_state(&mut ppm_state.lufs, sample_rate); - let din_release_coeff = make_release_coeff(sample_rate, db_per_s_to_tau(rta_config.ppm_din_decay_db_per_s)); - let ebu_release_coeff = make_release_coeff(sample_rate, db_per_s_to_tau(rta_config.ppm_ebu_decay_db_per_s)); - ensure_moving_average_window( - &mut ppm_state.din_window, - sample_rate, - if rta_config.ppm_din_fast_attack { 0.1 } else { rta_config.ppm_din_attack_ms.max(0.1) }, - ); - ensure_moving_average_window(&mut ppm_state.ebu_window, sample_rate, rta_config.ppm_ebu_attack_ms.max(0.1)); + 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 + .ebu_ppm + .ensure_profile(sample_rate, PpmStandard::EbuTypeIib); ensure_moving_average_window(&mut ppm_state.vu_window, sample_rate, VU_WINDOW_MS); - ppm_state.frame_counter = ppm_state.frame_counter.wrapping_add(1); - let emit_xy = (ppm_state.frame_counter % XY_SEND_INTERVAL_FRAMES) == 0; 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)); + ppm_state.correlation.configure( + sample_rate, + rta_config.correlation_response_s, + rta_config.correlation_reset_token, + ); + 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); @@ -1195,36 +1225,15 @@ fn build_meter_frame( sum_sq_r += f64::from(r * r); let abs_l = l.abs(); let abs_r = r.abs(); - let (din_avg_l, din_avg_r) = moving_average_push(&mut ppm_state.din_window, abs_l, abs_r); - let (ebu_avg_l, ebu_avg_r) = moving_average_push(&mut ppm_state.ebu_window, abs_l, abs_r); + ppm_state.din_ppm.process(l, r); + ppm_state.ebu_ppm.process(l, r); let _ = moving_average_push(&mut ppm_state.vu_window, abs_l, abs_r); peak_l = peak_l.max(abs_l); peak_r = peak_r.max(abs_r); - ppm_state.env_l = if din_avg_l > ppm_state.env_l { - din_avg_l - } else { - ppm_state.env_l * din_release_coeff - }; - ppm_state.env_r = if din_avg_r > ppm_state.env_r { - din_avg_r - } else { - ppm_state.env_r * din_release_coeff - }; - - ppm_state.ebu_env_l = if ebu_avg_l > ppm_state.ebu_env_l { - ebu_avg_l - } else { - ppm_state.ebu_env_l * ebu_release_coeff - }; - ppm_state.ebu_env_r = if ebu_avg_r > ppm_state.ebu_env_r { - ebu_avg_r - } else { - ppm_state.ebu_env_r * ebu_release_coeff - }; - - push_xy_history(&mut ppm_state.xy_history_l, l); - push_xy_history(&mut ppm_state.xy_history_r, r); + ppm_state.correlation.process(l, r); + ppm_state.xy_pending_l.push(l); + ppm_state.xy_pending_r.push(r); if let Some(state) = ppm_state.rta_state.as_mut() { match state { @@ -1268,16 +1277,19 @@ fn build_meter_frame( update_box_meter(&mut ppm_state.lufs); let tp_l = dbfs(estimate_true_peak(&mut ppm_state.tp_history_l, &wave_l).max(peak_l)); let tp_r = dbfs(estimate_true_peak(&mut ppm_state.tp_history_r, &wave_r).max(peak_r)); - let ppm_din_l = dbfs(ppm_state.env_l * PPM_RECT_TO_PEAK_GAIN); - let ppm_din_r = dbfs(ppm_state.env_r * PPM_RECT_TO_PEAK_GAIN); - let ppm_ebu_l = dbfs(ppm_state.ebu_env_l * PPM_RECT_TO_PEAK_GAIN); - let ppm_ebu_r = dbfs(ppm_state.ebu_env_r * PPM_RECT_TO_PEAK_GAIN); + let (ppm_din_amp_l, ppm_din_amp_r) = ppm_state.din_ppm.levels(); + let (ppm_ebu_amp_l, ppm_ebu_amp_r) = ppm_state.ebu_ppm.levels(); + let ppm_din_l = dbfs(ppm_din_amp_l); + let ppm_din_r = dbfs(ppm_din_amp_r); + let ppm_ebu_l = dbfs(ppm_ebu_amp_l); + 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) = if emit_xy { - ( - ppm_state.xy_history_l.iter().copied().collect::>(), - ppm_state.xy_history_r.iter().copied().collect::>(), - ) + ppm_state.xy_emit_phase = ppm_state.xy_emit_phase.saturating_add( + (frames as u64).saturating_mul(XY_TARGET_UPDATES_PER_SECOND), + ); + let (xy_l, xy_r) = if ppm_state.xy_emit_phase >= u64::from(sample_rate.max(1)) { + ppm_state.xy_emit_phase %= u64::from(sample_rate.max(1)); + take_goniometer_samples(ppm_state, rta_config.xy_points as usize) } else { (Vec::new(), Vec::new()) }; @@ -1288,6 +1300,10 @@ fn build_meter_frame( .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis(), + sample_rate, + period_size: frames.min(u32::MAX as usize) as u32, + correlation: ppm_state.correlation.value(), + correlation_negative_peak: ppm_state.correlation.negative_peak(), rms_l, rms_r, vu_l, @@ -1308,9 +1324,11 @@ fn build_meter_frame( lufs_sr: Some(ppm_state.lufs.lu_s_r), ppm_box_l: Some(ppm_state.lufs.ppm_box_l), ppm_box_r: Some(ppm_state.lufs.ppm_box_r), - wave_l, - wave_r, - wave_channels: 2, + // Full-rate raw waveform samples are intentionally not transported. + // The compact min/max envelope preserves the visible waveform. + wave_l: Vec::new(), + wave_r: Vec::new(), + wave_channels: 0, xy_l, xy_r, rta: ppm_state.last_rta.clone(), @@ -1325,12 +1343,13 @@ fn build_meter_frame( #[cfg(target_os = "linux")] fn ensure_rta_state(state: &mut PpmState, sample_rate: u32, config: &PhoenixRtaConfig) { let signature = format!( - "{}|{}|{}|{}|{}|{}|{}", + "{}|{}|{}|{}|{}|{}|{}|{}", normalize_engine(&config.engine), normalize_layout(&config.layout), normalize_bpo_key(&config.bpo), normalize_freq_range(&config.freq_range), config.order.clamp(2, 8), + normalize_weighting(&config.weighting), normalize_fft_size(config.fft_size), sample_rate ); @@ -1353,7 +1372,11 @@ fn create_rta_state(sample_rate: u32, config: &PhoenixRtaConfig) -> RtaEngineSta fn ensure_lr_delay_state(state: &mut PpmState, config: &PhoenixRtaConfig) { let signature = format!( "{}|{:.6}", - if config.lr_fractional_delay_enabled { 1 } else { 0 }, + if config.lr_fractional_delay_enabled { + 1 + } else { + 0 + }, config.lr_fractional_delay_samples ); if state.lr_delay_signature == signature { @@ -1376,7 +1399,11 @@ fn ensure_lr_delay_state(state: &mut PpmState, config: &PhoenixRtaConfig) { state.lr_delay_a = 0.0; state.lr_delay_channel = 0; } else { - let sign = if config.lr_fractional_delay_samples < 0.0 { -1.0 } else { 1.0 }; + let sign = if config.lr_fractional_delay_samples < 0.0 { + -1.0 + } else { + 1.0 + }; let denom = 1.0 + d; let a = if denom != 0.0 { (d - 1.0) / denom } else { 0.0 }; state.lr_delay_enabled = true; @@ -1393,11 +1420,7 @@ fn ensure_lr_delay_state(state: &mut PpmState, config: &PhoenixRtaConfig) { #[cfg(target_os = "linux")] fn ensure_spectro_state(state: &mut PpmState, sample_rate: u32, config: &PhoenixRtaConfig) { - let signature = format!( - "{}|{}", - normalize_fft_size(config.fft_size), - sample_rate - ); + let signature = format!("{}|{}", normalize_fft_size(config.fft_size), sample_rate); if state.spectro_state.is_some() && state.spectro_signature == signature { return; } @@ -1414,7 +1437,11 @@ fn build_active_rta_bands( let requested_layout = normalize_layout(&config.layout); let bpo_key = normalize_bpo_key(&config.bpo); let bands = build_rta_bands(sample_rate, requested_layout, bpo_key, &config.freq_range); - let active_layout = if requested_layout == "rtw" && !bands.is_empty() { "rtw" } else { "iec" }; + let active_layout = if requested_layout == "rtw" && !bands.is_empty() { + "rtw" + } else { + "iec" + }; let active_bands = if bands.is_empty() { build_rta_bands(sample_rate, "iec", bpo_key, &config.freq_range) } else { @@ -1426,20 +1453,36 @@ fn build_active_rta_bands( #[cfg(target_os = "linux")] fn create_rta_bank(sample_rate: u32, config: &PhoenixRtaConfig) -> RtaBank { let (active_layout, bpo_key, active_bands) = build_active_rta_bands(sample_rate, config); - let q = q_for_bpo(bpo_value(bpo_key) as f32); - let order = config.order.clamp(2, 8); - let section_count = ((order as usize) / 2).max(1); + // The RTW profile uses a third-order Butterworth prototype, resulting in + // a sixth-order bandpass. Phoenix extension profiles retain selectable + // even total orders. + let prototype_order = if active_layout == "rtw" { + 3 + } else { + ((config.order.clamp(2, 8) as usize) / 2).max(1) + }; let centers: Vec = active_bands.iter().map(|band| band.center).collect(); + let weighting_corrections = active_bands + .iter() + .map(|band| { + weighting_power_correction(&config.weighting, band.measurement_center, sample_rate) + }) + .collect(); let freq_min = active_bands.first().map(|band| band.f_lo).unwrap_or(20.0); - let freq_max = active_bands.last().map(|band| band.f_hi).unwrap_or(20_000.0); + let freq_max = active_bands + .last() + .map(|band| band.f_hi) + .unwrap_or(20_000.0); let mut filters = Vec::with_capacity(active_bands.len()); for band in &active_bands { - let coeffs = compute_bandpass_coeffs(band.center, q, sample_rate as f32); - let mut sections = Vec::with_capacity(section_count); - for _ in 0..section_count { - sections.push(create_section(coeffs)); - } - filters.push(sections); + let coefficients = design_fractional_octave_band( + band.measurement_center, + band.f_lo, + band.f_hi, + sample_rate, + prototype_order, + ); + filters.push(StereoCascade::new(coefficients)); } let len = active_bands.len(); RtaBank { @@ -1447,11 +1490,15 @@ fn create_rta_bank(sample_rate: u32, config: &PhoenixRtaConfig) -> RtaBank { bpo_key, freq_min, freq_max, - band_defs: active_bands, centers, filters, + weighting: FrequencyWeighting::new(&config.weighting, sample_rate), + weighting_corrections, acc_l: vec![0.0; len], acc_r: vec![0.0; len], + block_peaks: vec![0.0; len], + average_acc: vec![0.0; len], + average_samples: 0, energies: vec![0.0; len], levels: vec![-120.0; len], peaks: vec![-120.0; len], @@ -1465,7 +1512,10 @@ fn create_fft_state(sample_rate: u32, config: &PhoenixRtaConfig) -> FftRtaState let fft_step_samples = (fft_size / 4).max(1); let centers: Vec = active_bands.iter().map(|band| band.center).collect(); let freq_min = active_bands.first().map(|band| band.f_lo).unwrap_or(20.0); - let freq_max = active_bands.last().map(|band| band.f_hi).unwrap_or(20_000.0); + let freq_max = active_bands + .last() + .map(|band| band.f_hi) + .unwrap_or(20_000.0); let mapping = build_fft_band_mapping(&active_bands, sample_rate as f32 * 0.5, fft_size / 2); let len = active_bands.len(); FftRtaState { @@ -1497,6 +1547,7 @@ fn create_spectro_state(_sample_rate: u32, config: &PhoenixRtaConfig) -> Spectro let fft_size = normalize_fft_size(config.fft_size); let fft_step_samples = (fft_size / 8).max(128); SpectroState { + seq: 0, fft_size, fft_step_samples, ring_l: vec![0.0; fft_size], @@ -1537,15 +1588,16 @@ fn apply_lr_fractional_delay(state: &mut PpmState, l: &mut f32, r: &mut f32) { #[cfg(target_os = "linux")] fn process_rta_sample(bank: &mut RtaBank, in_l: f32, in_r: f32) { - for (band_index, sections) in bank.filters.iter_mut().enumerate() { - let mut out_l = in_l; - let mut out_r = in_r; - for section in sections.iter_mut() { - out_l = process_section(section, out_l, 0); - out_r = process_section(section, out_r, 1); - } + let (weighted_l, weighted_r) = bank.weighting.process(in_l, in_r); + for (band_index, filter) in bank.filters.iter_mut().enumerate() { + let (out_l, out_r) = filter.process(weighted_l, weighted_r); + let out_l = f64::from(out_l); + let out_r = f64::from(out_r); bank.acc_l[band_index] += out_l * out_l; bank.acc_r[band_index] += out_r * out_r; + bank.block_peaks[band_index] = bank.block_peaks[band_index] + .max(out_l * out_l) + .max(out_r * out_r); } } @@ -1576,35 +1628,52 @@ fn finalize_rta_bank( config: &PhoenixRtaConfig, ) { let dt = block_size as f32 / sample_rate as f32; + let integration = normalize_rta_integration(&config.integration); let tau = integration_tau(config); - let k = 1.0 - (-dt / tau.max(0.001)).exp(); let release_step = RTA_PEAK_DECAY_DB_PER_S * dt; - let weighting = normalize_weighting(&config.weighting); - let denom = (channel_count.max(1) * block_size.max(1)) as f32; + let denom = (channel_count.max(1) * block_size.max(1)) as f64; + bank.average_samples = bank.average_samples.saturating_add(block_size as u64); for i in 0..bank.energies.len() { - let power = (bank.acc_l[i] + if channel_count > 1 { bank.acc_r[i] } else { 0.0 }) / denom; - let new_energy = bank.energies[i] + k * (power - bank.energies[i]); - bank.energies[i] = new_energy.max(1.0e-12); - let band = &bank.band_defs[i]; - let weighted = bank.energies[i] * band_weighting_gain(band.f_lo, band.center, band.f_hi, weighting); - let level_db = 10.0 * weighted.max(1.0e-12).log10(); + let block_sum = bank.acc_l[i] + + if channel_count > 1 { + bank.acc_r[i] + } else { + 0.0 + }; + let power = block_sum / denom; + bank.average_acc[i] += block_sum / channel_count.max(1) as f64; + bank.energies[i] = match integration { + "peak" => bank.block_peaks[i].max(1.0e-12), + "average" => bank.average_acc[i] / bank.average_samples.max(1) as f64, + _ => integrate_power(bank.energies[i], power, block_size, sample_rate, tau), + } + .max(1.0e-12); + let corrected_energy = bank.energies[i] * bank.weighting_corrections[i]; + let level_db = (10.0 * corrected_energy.max(1.0e-12).log10()) as f32; bank.levels[i] = level_db; let prev_peak = bank.peaks[i]; if !prev_peak.is_finite() || level_db >= prev_peak { bank.peaks[i] = level_db; } else { - bank.peaks[i] = (prev_peak - release_step).max(level_db).max(RTA_PEAK_FLOOR_DB); + bank.peaks[i] = (prev_peak - release_step) + .max(level_db) + .max(RTA_PEAK_FLOOR_DB); } bank.acc_l[i] = 0.0; bank.acc_r[i] = 0.0; + bank.block_peaks[i] = 0.0; } } #[cfg(target_os = "linux")] -fn finalize_fft_state(state: &mut FftRtaState, sample_rate: u32, config: &PhoenixRtaConfig) -> bool { +fn finalize_fft_state( + state: &mut FftRtaState, + sample_rate: u32, + config: &PhoenixRtaConfig, +) -> bool { if state.ring_fill < state.fft_size || state.samples_since < state.fft_step_samples { return false; } @@ -1633,7 +1702,8 @@ fn finalize_fft_state(state: &mut FftRtaState, sample_rate: u32, config: &Phoeni band_power += *power * seg.weight; } } - let weighted = band_power.max(1.0e-12) * band_weighting_gain(band.f_lo, band.center, band.f_hi, weighting); + let weighted = band_power.max(1.0e-12) + * band_weighting_gain(band.f_lo, band.center, band.f_hi, weighting); let db = 10.0 * weighted.max(1.0e-12).log10(); let prev = state.levels[i]; state.levels[i] = prev + k * (db - prev); @@ -1641,7 +1711,9 @@ fn finalize_fft_state(state: &mut FftRtaState, sample_rate: u32, config: &Phoeni if !prev_peak.is_finite() || state.levels[i] >= prev_peak { state.peaks[i] = state.levels[i]; } else { - state.peaks[i] = (prev_peak - release_step).max(state.levels[i]).max(RTA_PEAK_FLOOR_DB); + state.peaks[i] = (prev_peak - release_step) + .max(state.levels[i]) + .max(RTA_PEAK_FLOOR_DB); } } true @@ -1672,6 +1744,7 @@ fn finalize_spectro_state(state: &mut SpectroState, sample_rate: u32) -> bool { *bin = floor; } } + state.seq = state.seq.wrapping_add(1); let _ = sample_rate; true } @@ -1682,7 +1755,6 @@ fn build_iir_rta_frame(bank: &RtaBank, sample_rate: u32, config: &PhoenixRtaConf engine: "iir".to_string(), bands_avg: bank.levels.clone(), bands_peak: bank.peaks.clone(), - bands: bank.levels.clone(), centers: bank.centers.clone(), freq_min: bank.freq_min, freq_max: bank.freq_max, @@ -1694,12 +1766,15 @@ fn build_iir_rta_frame(bank: &RtaBank, sample_rate: u32, config: &PhoenixRtaConf } #[cfg(target_os = "linux")] -fn build_fft_rta_frame(state: &FftRtaState, sample_rate: u32, config: &PhoenixRtaConfig) -> RtaFrame { +fn build_fft_rta_frame( + state: &FftRtaState, + sample_rate: u32, + config: &PhoenixRtaConfig, +) -> RtaFrame { RtaFrame { engine: "fft".to_string(), bands_avg: state.levels.clone(), bands_peak: state.peaks.clone(), - bands: state.levels.clone(), centers: state.centers.clone(), freq_min: state.freq_min, freq_max: state.freq_max, @@ -1713,6 +1788,7 @@ fn build_fft_rta_frame(state: &FftRtaState, sample_rate: u32, config: &PhoenixRt #[cfg(target_os = "linux")] fn build_spectro_frame(state: &SpectroState, sample_rate: u32) -> SpectroFrame { SpectroFrame { + seq: state.seq, bins: state.bins.clone(), sample_rate, fft_size: state.fft_size as u32, @@ -1763,15 +1839,6 @@ fn normalize_layout(value: &str) -> &'static str { } } -#[cfg(target_os = "linux")] -fn normalize_weighting(value: &str) -> &'static str { - match value.trim().to_ascii_lowercase().as_str() { - "a" => "a", - "c" => "c", - _ => "z", - } -} - #[cfg(target_os = "linux")] fn normalize_freq_range(value: &str) -> &'static str { match value.trim().to_ascii_lowercase().as_str() { @@ -1791,55 +1858,91 @@ fn bpo_value(bpo_key: &str) -> usize { #[cfg(target_os = "linux")] fn freq_range_bounds(sample_rate: u32, freq_range: &str) -> (f32, f32) { - let max: f32 = if normalize_freq_range(freq_range) == "lf" { 5000.0 } else { 20_000.0 }; - let min: f32 = if normalize_freq_range(freq_range) == "lf" { 5.0 } else { 20.0 }; + let max: f32 = if normalize_freq_range(freq_range) == "lf" { + 5000.0 + } else { + 20_000.0 + }; + let min: f32 = if normalize_freq_range(freq_range) == "lf" { + 5.0 + } else { + 20.0 + }; (min, max.min(sample_rate as f32 * 0.5)) } #[cfg(target_os = "linux")] fn integration_tau(config: &PhoenixRtaConfig) -> f32 { - if config.integration.trim().eq_ignore_ascii_case("slow") { - config.tau_slow.max(0.05) - } else { - config.tau_fast.max(0.01) + match normalize_rta_integration(&config.integration) { + "impulse" => 0.035, + "medium" => 0.5, + "slow" => config.tau_slow.max(0.05), + _ => config.tau_fast.max(0.01), } } #[cfg(target_os = "linux")] -fn q_for_bpo(n: f32) -> f32 { - let up = 2.0f32.powf(1.0 / (2.0 * n)); - let down = 2.0f32.powf(-1.0 / (2.0 * n)); - 1.0 / (up - down) +fn normalize_rta_integration(value: &str) -> &'static str { + match value.trim().to_ascii_lowercase().as_str() { + "impulse" => "impulse", + "medium" => "medium", + "slow" => "slow", + "average" => "average", + "peak" => "peak", + _ => "fast", + } } #[cfg(target_os = "linux")] -fn build_rta_bands(sample_rate: u32, layout: &'static str, bpo_key: &'static str, freq_range: &str) -> Vec { +fn build_rta_bands( + sample_rate: u32, + layout: &'static str, + bpo_key: &'static str, + freq_range: &str, +) -> Vec { let (freq_min, freq_max) = freq_range_bounds(sample_rate, freq_range); if layout == "rtw" { - return build_rtw_bands(freq_min, freq_max, bpo_key); + return build_rtw_bands(freq_min, freq_max, sample_rate as f32 * 0.5, bpo_key); } make_fractional_bands(freq_min, freq_max, bpo_value(bpo_key) as f32) } #[cfg(target_os = "linux")] -fn build_rtw_bands(freq_min: f32, freq_max: f32, bpo_key: &'static str) -> Vec { +fn build_rtw_bands( + freq_min: f32, + freq_max: f32, + nyquist: f32, + bpo_key: &'static str, +) -> Vec { let centers = match bpo_key { - "1_3" => RTW_CENTERS_1_3, + "1_3" => RTW_THIRD_OCTAVE_CENTERS, "1_12" => RTW_CENTERS_1_12, _ => RTW_CENTERS_1_6, }; - let factor = 2.0f32.powf(1.0 / (2.0 * bpo_value(bpo_key) as f32)); + let bands_per_octave = bpo_value(bpo_key) as f32; let mut bands = Vec::new(); - for &fc in centers { - if !fc.is_finite() || fc < freq_min || fc > freq_max { + for &nominal_center in centers { + if !nominal_center.is_finite() || nominal_center < freq_min || nominal_center > freq_max { continue; } - let f_lo = (fc / factor).max(freq_min); - let f_hi = (fc * factor).min(freq_max); + // IEC fractional-octave centers use the exact base-ten sequence. The + // rounded preferred values remain display labels only. + let measurement_center = + exact_fractional_octave_center(nominal_center, bands_per_octave as usize); + let (f_lo, natural_f_hi) = + fractional_octave_edges(measurement_center, bands_per_octave as usize); + // Range limits select center frequencies; they must not truncate the + // outer filters or the 20 Hz / 20 kHz bands cease to be symmetric. + let f_hi = natural_f_hi.min(nyquist * 0.999_9); if f_hi <= f_lo { continue; } - bands.push(RtaBandDef { center: fc, f_lo, f_hi }); + bands.push(RtaBandDef { + center: nominal_center, + measurement_center, + f_lo, + f_hi, + }); } bands } @@ -1853,66 +1956,21 @@ fn make_fractional_bands(freq_min: f32, freq_max: f32, bpo: f32) -> Vec freq_max || f_hi <= f_lo { continue; } - bands.push(RtaBandDef { center, f_lo, f_hi }); + bands.push(RtaBandDef { + center, + measurement_center: center, + f_lo, + f_hi, + }); } bands } -#[cfg(target_os = "linux")] -fn compute_bandpass_coeffs(fc: f32, q: f32, sr: f32) -> BiquadCoeffs { - let w0 = 2.0 * std::f32::consts::PI * fc / sr; - let cos_w0 = w0.cos(); - let sin_w0 = w0.sin(); - let alpha = sin_w0 / (2.0 * q); - let b0 = alpha; - let b1 = 0.0; - let b2 = -alpha; - let a0 = 1.0 + alpha; - let a1 = -2.0 * cos_w0; - let a2 = 1.0 - alpha; - BiquadCoeffs { - b0: b0 / a0, - b1: b1 / a0, - b2: b2 / a0, - a1: a1 / a0, - a2: a2 / a0, - } -} - -#[cfg(target_os = "linux")] -fn create_section(coeffs: BiquadCoeffs) -> RtaSectionState { - RtaSectionState { - b0: coeffs.b0, - b1: coeffs.b1, - b2: coeffs.b2, - a1: coeffs.a1, - a2: coeffs.a2, - z1_l: 0.0, - z2_l: 0.0, - z1_r: 0.0, - z2_r: 0.0, - } -} - -#[cfg(target_os = "linux")] -fn process_section(section: &mut RtaSectionState, x: f32, channel: usize) -> f32 { - if channel == 1 { - let y = section.b0 * x + section.z1_r; - section.z1_r = section.b1 * x - section.a1 * y + section.z2_r; - section.z2_r = section.b2 * x - section.a2 * y; - return y; - } - let y = section.b0 * x + section.z1_l; - section.z1_l = section.b1 * x - section.a1 * y + section.z2_l; - section.z2_l = section.b2 * x - section.a2 * y; - y -} - #[cfg(target_os = "linux")] fn build_fft_band_mapping(bands: &[RtaBandDef], nyq: f32, bin_count: usize) -> Vec { let mut result = Vec::with_capacity(bands.len()); @@ -1932,7 +1990,10 @@ fn build_fft_band_mapping(bands: &[RtaBandDef], nyq: f32, bin_count: usize) -> V let bin_end = bin_start + bin_width; let overlap = (bin_end.min(band.f_hi) - bin_start.max(band.f_lo)).max(0.0); if overlap > 0.0 { - bins.push(FftBandSeg { index, weight: overlap }); + bins.push(FftBandSeg { + index, + weight: overlap, + }); weight_sum += overlap; } } @@ -1940,7 +2001,10 @@ fn build_fft_band_mapping(bands: &[RtaBandDef], nyq: f32, bin_count: usize) -> V let idx = ((band.center / nyq) * bin_count as f32) .round() .clamp(0.0, (bin_count.saturating_sub(1)) as f32) as usize; - bins.push(FftBandSeg { index: idx, weight: 1.0 }); + bins.push(FftBandSeg { + index: idx, + weight: 1.0, + }); weight_sum = 1.0; } for seg in &mut bins { @@ -1993,7 +2057,11 @@ fn compute_fft_power_bins( #[cfg(target_os = "linux")] fn configured_input_offset_db(value: f32) -> f32 { - if value.is_finite() { value.clamp(-60.0, 20.0) } else { -5.0 } + if value.is_finite() { + value.clamp(-60.0, 20.0) + } else { + -5.0 + } } #[cfg(target_os = "linux")] @@ -2055,26 +2123,7 @@ fn fft_in_place(re: &mut [f32], im: &mut [f32]) { #[cfg(target_os = "linux")] fn weighting_db(freq: f32, mode: &str) -> f32 { - if !freq.is_finite() || freq <= 0.0 { - return 0.0; - } - let f2 = freq * freq; - match normalize_weighting(mode) { - "a" => { - let ra_num = (12_194.0f32 * 12_194.0f32) * f2 * f2; - let ra_den = - (f2 + 20.6f32 * 20.6f32) - * ((f2 + 107.7f32 * 107.7f32) * (f2 + 737.9f32 * 737.9f32)).sqrt() - * (f2 + 12_194.0f32 * 12_194.0f32); - 20.0 * (ra_num / ra_den).max(1.0e-12).log10() + 2.0 - } - "c" => { - let rc_num = (12_194.0f32 * 12_194.0f32) * f2; - let rc_den = (f2 + 20.6f32 * 20.6f32) * (f2 + 12_194.0f32 * 12_194.0f32); - 20.0 * (rc_num / rc_den).max(1.0e-12).log10() + 0.06 - } - _ => 0.0, - } + weighting_reference_db(mode, freq) } #[cfg(target_os = "linux")] diff --git a/src/config.rs b/src/config.rs index b873d6e..c09a27d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -80,7 +80,12 @@ impl PhoenixConfig { .unwrap_or(2 * 1024 * 1024 * 1024), lr_fractional_delay_enabled: std::env::var("PHOENIX_LR_FRAC_DELAY_ENABLED") .ok() - .map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")) + .map(|v| { + matches!( + v.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) .unwrap_or(true), lr_fractional_delay_samples: std::env::var("PHOENIX_LR_FRAC_DELAY_SAMPLES") .ok() diff --git a/src/correlation.rs b/src/correlation.rs new file mode 100644 index 0000000..503d44a --- /dev/null +++ b/src/correlation.rs @@ -0,0 +1,151 @@ +//! Continuous stereo correlation meter. +//! +//! The detector integrates L², R² and L·R with the same time constant and +//! derives the normalized correlation only afterwards. Its timing therefore +//! does not depend on ALSA period size or browser frame rate. + +#[derive(Clone, Debug)] +pub struct CorrelationMeter { + sample_rate: u32, + response_seconds: f32, + alpha: f64, + power_l: f64, + power_r: f64, + cross_power: f64, + value: f32, + negative_peak: f32, + reset_token: u64, +} + +impl CorrelationMeter { + pub fn new(sample_rate: u32, response_seconds: f32, reset_token: u64) -> Self { + let mut meter = Self { + sample_rate: 0, + response_seconds: 0.0, + alpha: 1.0, + power_l: 0.0, + power_r: 0.0, + cross_power: 0.0, + value: 0.0, + negative_peak: 1.0, + reset_token, + }; + meter.configure(sample_rate, response_seconds, reset_token); + meter + } + + pub fn configure(&mut self, sample_rate: u32, response_seconds: f32, reset_token: u64) { + let sample_rate = sample_rate.max(8_000); + let response_seconds = normalize_response_seconds(response_seconds); + if self.sample_rate != sample_rate || self.response_seconds != response_seconds { + self.sample_rate = sample_rate; + self.response_seconds = response_seconds; + self.alpha = 1.0 + - (-1.0 / (f64::from(sample_rate) * f64::from(response_seconds))).exp(); + } + if self.reset_token != reset_token { + self.reset_token = reset_token; + self.negative_peak = 1.0; + } + } + + pub fn process(&mut self, left: f32, right: f32) { + let l = f64::from(left); + let r = f64::from(right); + self.power_l += self.alpha * (l * l - self.power_l); + self.power_r += self.alpha * (r * r - self.power_r); + self.cross_power += self.alpha * (l * r - self.cross_power); + + // Below roughly -100 dBFS RMS per channel the quotient is no longer a + // useful phase measurement and should settle at the neutral position. + const MIN_POWER: f64 = 1.0e-10; + let denominator = (self.power_l * self.power_r).sqrt(); + self.value = if self.power_l > MIN_POWER && self.power_r > MIN_POWER && denominator > 0.0 { + (self.cross_power / denominator).clamp(-1.0, 1.0) as f32 + } else { + 0.0 + }; + if self.value < self.negative_peak { + self.negative_peak = self.value; + } + } + + pub fn value(&self) -> f32 { + self.value + } + + pub fn negative_peak(&self) -> f32 { + self.negative_peak + } +} + +pub fn normalize_response_seconds(value: f32) -> f32 { + if value.is_finite() && value >= 1.75 { + 2.5 + } else { + 1.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run_signal(meter: &mut CorrelationMeter, seconds: usize, mut signal: F) + where + F: FnMut(usize) -> (f32, f32), + { + let count = meter.sample_rate as usize * seconds; + for index in 0..count { + let (left, right) = signal(index); + meter.process(left, right); + } + } + + #[test] + fn detects_positive_negative_and_quadrature_signals() { + let sample_rate = 48_000; + let phase_step = 2.0 * std::f32::consts::PI * 1_000.0 / sample_rate as f32; + for (phase, expected) in [(0.0, 1.0), (std::f32::consts::PI, -1.0), (std::f32::consts::FRAC_PI_2, 0.0)] { + let mut meter = CorrelationMeter::new(sample_rate, 1.0, 0); + run_signal(&mut meter, 5, |index| { + let angle = phase_step * index as f32; + (angle.sin(), (angle + phase).sin()) + }); + assert!((meter.value() - expected).abs() < 0.002, "phase {phase}: {}", meter.value()); + } + } + + #[test] + fn response_time_and_peak_reset_are_deterministic() { + let sample_rate = 48_000; + let mut fast = CorrelationMeter::new(sample_rate, 1.0, 0); + let mut slow = CorrelationMeter::new(sample_rate, 2.5, 0); + for index in 0..sample_rate as usize { + let sample = if index & 1 == 0 { 0.5 } else { -0.5 }; + fast.process(sample, sample); + slow.process(sample, sample); + } + assert!(fast.value() > 0.999); + assert!(slow.value() > 0.999); + run_signal(&mut fast, 2, |index| { + let sample = if index & 1 == 0 { 0.5 } else { -0.5 }; + (sample, -sample) + }); + assert!(fast.negative_peak() < -0.7); + fast.configure(sample_rate, 1.0, 1); + assert_eq!(fast.negative_peak(), 1.0); + } + + #[test] + fn silence_and_single_channel_are_neutral() { + let mut meter = CorrelationMeter::new(48_000, 1.0, 0); + run_signal(&mut meter, 2, |_| (0.0, 0.0)); + assert_eq!(meter.value(), 0.0); + run_signal(&mut meter, 2, |index| { + let sample = if index & 1 == 0 { 0.5 } else { -0.5 }; + (sample, 0.0) + }); + assert_eq!(meter.value(), 0.0); + } +} diff --git a/src/main.rs b/src/main.rs index 11e4a0c..bafa470 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,19 @@ mod audio; mod config; +mod correlation; mod model; +mod ppm; mod routes; +mod rta; mod state; use std::net::SocketAddr; use anyhow::Context; -use axum::{routing::{get, post}, Router}; +use axum::{ + routing::{get, post}, + Router, +}; use config::PhoenixConfig; use state::AppState; use tower_http::{cors::CorsLayer, trace::TraceLayer}; @@ -30,18 +36,50 @@ async fn main() -> anyhow::Result<()> { let app = Router::new() .route("/health", get(routes::health)) .route("/api/v1/status", get(routes::status)) - .route("/api/v1/global-config", get(routes::get_global_config).post(routes::set_global_config)) - .route("/api/v1/frontend-presets", get(routes::get_frontend_presets).post(routes::set_frontend_preset)) - .route("/api/v1/frontend-layouts", get(routes::get_frontend_layouts).post(routes::set_frontend_layout)) - .route("/api/v1/update/download", post(routes::download_online_update)) + .route( + "/api/v1/global-config", + get(routes::get_global_config).post(routes::set_global_config), + ) + .route( + "/api/v1/frontend-presets", + get(routes::get_frontend_presets).post(routes::set_frontend_preset), + ) + .route( + "/api/v1/frontend-layouts", + get(routes::get_frontend_layouts).post(routes::set_frontend_layout), + ) + .route( + "/api/v1/update/download", + post(routes::download_online_update), + ) .route("/api/v1/update/log", get(routes::get_online_update_log)) - .route("/api/v1/update/restart", post(routes::restart_after_online_update)) - .route("/api/v1/rta-config", get(routes::get_rta_config).post(routes::set_rta_config)) - .route("/api/v1/recordings/wav/start/:session_id", post(routes::start_wav_recording)) - .route("/api/v1/recordings/wav/stop/:session_id", post(routes::stop_wav_recording)) - .route("/api/v1/recordings/stop/:session_id/:format", post(routes::stop_recording_with_format)) - .route("/api/v1/recordings/save/:target/:filename", post(routes::save_recording_file)) + .route( + "/api/v1/update/restart", + post(routes::restart_after_online_update), + ) + .route( + "/api/v1/rta-config", + get(routes::get_rta_config).post(routes::set_rta_config), + ) + .route( + "/api/v1/recordings/wav/start/:session_id", + post(routes::start_wav_recording), + ) + .route( + "/api/v1/recordings/wav/stop/:session_id", + post(routes::stop_wav_recording), + ) + .route( + "/api/v1/recordings/stop/:session_id/:format", + post(routes::stop_recording_with_format), + ) + .route( + "/api/v1/recordings/save/:target/:filename", + post(routes::save_recording_file), + ) .route("/api/v1/metrics/ws", get(routes::metrics_ws)) + .route("/api/v1/spectro/ws", get(routes::spectro_ws)) + .route("/api/v1/visuals/ws", get(routes::visuals_ws)) .layer(CorsLayer::permissive()) .layer(TraceLayer::new_for_http()) .with_state(state); diff --git a/src/model.rs b/src/model.rs index dd61d7a..d20c797 100644 --- a/src/model.rs +++ b/src/model.rs @@ -21,7 +21,6 @@ pub struct RtaFrame { pub engine: String, pub bands_avg: Vec, pub bands_peak: Vec, - pub bands: Vec, pub centers: Vec, pub freq_min: f32, pub freq_max: f32, @@ -33,6 +32,7 @@ pub struct RtaFrame { #[derive(Clone, Debug, Serialize)] pub struct SpectroFrame { + pub seq: u64, pub bins: Vec, pub sample_rate: u32, pub fft_size: u32, @@ -72,6 +72,9 @@ pub struct PhoenixRtaConfig { pub ppm_ebu_decay_db_per_s: f32, pub lufs_i_window_min: u32, pub lufs_i_norm_enabled: bool, + pub correlation_response_s: f32, + pub correlation_reset_token: u64, + pub xy_points: u32, } impl Default for PhoenixRtaConfig { @@ -82,23 +85,26 @@ impl Default for PhoenixRtaConfig { mono_input: false, lr_fractional_delay_enabled: true, lr_fractional_delay_samples: 0.05, - bpo: "1_6".to_string(), + bpo: "1_3".to_string(), freq_range: "norm".to_string(), weighting: "z".to_string(), - order: 4, + order: 6, tau_fast: 0.12, tau_slow: 1.0, integration: "fast".to_string(), layout: "rtw".to_string(), input_offset_db_l: -5.0, input_offset_db_r: -5.0, - ppm_din_attack_ms: 5.0, - ppm_din_decay_db_per_s: 11.8, + 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: 8.6, + ppm_ebu_decay_db_per_s: 24.0 / 2.8, lufs_i_window_min: 4, lufs_i_norm_enabled: false, + correlation_response_s: 1.0, + correlation_reset_token: 0, + xy_points: 1024, } } } @@ -136,6 +142,8 @@ pub struct PhoenixGlobalConfig { pub clock_led_color: String, pub header_text_color: String, pub rta_bar_base_color: String, + pub spectro_gamma: f32, + pub spectro_scroll_mode: f32, pub peak_history_scroll_mode: f32, pub peak_history_fill_enabled: bool, pub peak_history_fill_invert: bool, @@ -175,7 +183,7 @@ impl Default for PhoenixGlobalConfig { Self { fft_size: 8192, input_source: InputSource::Line, - rta_bpo_mode: "1_6".to_string(), + rta_bpo_mode: "1_3".to_string(), input_offset_db_l: -5.0, input_offset_db_r: -5.0, mono_input: false, @@ -184,11 +192,11 @@ impl Default for PhoenixGlobalConfig { al_markers_enabled: false, meter_bar_thin: 0.55, panel_dividers_enabled: true, - ppm_din_attack_ms: 5.0, - ppm_din_decay_db_per_s: 11.8, + 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: 8.6, + ppm_ebu_decay_db_per_s: 24.0 / 2.8, lufs_i_window_min: 4, lufs_i_norm_enabled: false, ppm_din_loudness_boxes: true, @@ -203,6 +211,8 @@ impl Default for PhoenixGlobalConfig { clock_led_color: "#ff0000".to_string(), header_text_color: "#ffe066".to_string(), rta_bar_base_color: "#ffe066".to_string(), + spectro_gamma: 0.9, + spectro_scroll_mode: 1.0, peak_history_scroll_mode: 1.0, peak_history_fill_enabled: false, peak_history_fill_invert: false, @@ -250,6 +260,10 @@ pub struct PhoenixGlobalConfigEnvelope { pub struct MeterFrame { pub seq: u64, pub timestamp_ms: u128, + pub sample_rate: u32, + pub period_size: u32, + pub correlation: f32, + pub correlation_negative_peak: f32, pub rms_l: f32, pub rms_r: f32, pub vu_l: f32, diff --git a/src/ppm.rs b/src/ppm.rs new file mode 100644 index 0000000..9a025c0 --- /dev/null +++ b/src/ppm.rs @@ -0,0 +1,342 @@ +//! Continuous quasi-peak detectors for the Phoenix DIN/EBU programme meters. +//! +//! 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. + +#![cfg_attr(not(target_os = "linux"), allow(dead_code))] + +const ATTACK_FAST_TAU_S: f32 = 0.001_616_75; +const ATTACK_SLOW_TAU_S: f32 = 0.011_369_98; +const ATTACK_FAST_MIX: f32 = 0.818_535_6; +const STATIC_CALIBRATION_GAIN: f32 = 1.024_6; +const OVERSAMPLE: usize = 8; +const INTERP_RADIUS: usize = 8; +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::EbuTypeIib => 24.0 / 2.8, + } + } + + fn uses_sample_attack(self) -> bool { + matches!(self, Self::DinSample) + } +} + +#[derive(Clone, Copy, Debug, Default)] +struct PpmChannel { + fast: f32, + slow: f32, + output: f32, +} + +#[derive(Clone, Debug)] +pub struct PpmDetector { + sample_rate: u32, + standard: PpmStandard, + fast_coeff: f32, + slow_coeff: f32, + release_coeff: f32, + interp_weights: [[f32; INTERP_TAPS]; OVERSAMPLE], + raw_l: [f32; INTERP_TAPS], + raw_r: [f32; INTERP_TAPS], + raw_pos: usize, + left: PpmChannel, + right: PpmChannel, +} + +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 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 { + sample_rate: sr, + standard, + fast_coeff: coeff(ATTACK_FAST_TAU_S), + slow_coeff: coeff(ATTACK_SLOW_TAU_S), + release_coeff, + interp_weights: Self::create_interp_weights(), + raw_l: [0.0; INTERP_TAPS], + raw_r: [0.0; INTERP_TAPS], + raw_pos: 0, + left: PpmChannel::default(), + right: PpmChannel::default(), + } + } + + pub fn ensure_profile(&mut self, sample_rate: u32, standard: PpmStandard) { + let sr = sample_rate.max(8_000); + if self.sample_rate != sr || self.standard != standard { + *self = Self::new(sr, standard); + } + } + + 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; + for phase in 0..OVERSAMPLE { + let mut l = 0.0f32; + let mut r = 0.0f32; + for tap in 0..INTERP_TAPS { + let ring_index = (self.raw_pos + tap) % INTERP_TAPS; + let weight = self.interp_weights[phase][tap]; + l += self.raw_l[ring_index] * weight; + r += self.raw_r[ring_index] * weight; + } + Self::process_channel( + &mut self.left, + l.abs(), + standard, + fast_coeff, + slow_coeff, + release_coeff, + ); + Self::process_channel( + &mut self.right, + r.abs(), + standard, + fast_coeff, + slow_coeff, + release_coeff, + ); + } + self.levels() + } + + pub fn levels(&self) -> (f32, f32) { + (self.left.output, self.right.output) + } + + fn create_interp_weights() -> [[f32; INTERP_TAPS]; OVERSAMPLE] { + let mut weights = [[0.0; INTERP_TAPS]; OVERSAMPLE]; + for (phase, phase_weights) in weights.iter_mut().enumerate() { + let position = INTERP_RADIUS as f32 + phase as f32 / OVERSAMPLE as f32; + let mut sum = 0.0f32; + for (tap, weight) in phase_weights.iter_mut().enumerate() { + let distance = position - tap as f32; + *weight = Self::sinc(distance) * Self::sinc(distance / INTERP_RADIUS as f32); + sum += *weight; + } + if sum.abs() > 1.0e-9 { + for weight in phase_weights { + *weight /= sum; + } + } + } + weights + } + + fn sinc(value: f32) -> f32 { + if value.abs() < 1.0e-7 { + 1.0 + } else { + let x = std::f32::consts::PI * value; + x.sin() / x + } + } + + 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 { + state.fast * release_coeff + }; + state.slow = if input > state.slow { + slow_coeff * state.slow + (1.0 - slow_coeff) * input + } else { + state.slow * release_coeff + }; + + let attack = (ATTACK_FAST_MIX * state.fast + (1.0 - ATTACK_FAST_MIX) * state.slow) + * STATIC_CALIBRATION_GAIN; + state.output = attack.max(state.output * release_coeff); + state.output + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SR: u32 = 48_000; + + fn sine_sample(index: usize, frequency: f32, amplitude: f32) -> f32 { + let phase = 2.0 * std::f32::consts::PI * frequency * index as f32 / SR as f32; + phase.sin() * amplitude + } + + fn run_tone( + detector: &mut PpmDetector, + frequency: f32, + amplitude: f32, + duration_ms: f32, + ) -> f32 { + let samples = (duration_ms * SR as f32 / 1000.0).round() as usize; + let mut peak = 0.0f32; + for index in 0..samples { + let value = sine_sample(index, frequency, amplitude); + peak = peak.max(detector.process(value, value).0); + } + // The band-limited interpolator uses eight samples of look-ahead. + // Silence advances its final interval without materially changing PPM return. + for _ in 0..INTERP_RADIUS + 1 { + peak = peak.max(detector.process(0.0, 0.0).0); + } + peak + } + + fn db(value: f32) -> f32 { + 20.0 * value.max(1.0e-12).log10() + } + + #[test] + fn ebu_type_iib_matches_official_tone_burst_table() { + // EBU Tech 3205-E table 2, expressed relative to the continuous-tone + // indication: 100/10/5/1.5/0.5 ms -> 0/-2/-4/-9/-17 dB. + let cases = [ + (100.0, 0.0, 0.5), + (10.0, -2.0, 0.5), + (5.0, -4.0, 0.75), + (1.5, -9.0, 1.0), + (0.5, -17.0, 2.0), + ]; + let mut continuous = PpmDetector::new(SR, PpmStandard::EbuTypeIib); + let reference = run_tone(&mut continuous, 5_000.0, 0.5, 500.0); + + for (duration_ms, expected_db, tolerance_db) in cases { + let mut detector = PpmDetector::new(SR, PpmStandard::EbuTypeIib); + let measured = run_tone(&mut detector, 5_000.0, 0.5, duration_ms); + let relative_db = db(measured / reference); + assert!( + (relative_db - expected_db).abs() <= tolerance_db, + "{duration_ms} ms: measured {relative_db:.3} dB, expected {expected_db:.3} +/- {tolerance_db:.3} dB" + ); + } + } + + #[test] + fn ebu_return_time_is_24_db_in_2_8_seconds() { + assert_return_time(PpmStandard::EbuTypeIib, 24.0, 2.8, 0.02); + } + + #[test] + fn din_return_time_is_20_db_in_1_5_seconds() { + assert_return_time(PpmStandard::Din, 20.0, 1.5, 0.02); + } + + #[test] + fn fresh_detector_does_not_bypass_attack_integration() { + let mut detector = PpmDetector::new(SR, PpmStandard::Din); + let first = detector.process(1.0, 1.0).0; + assert!(first < 0.02, "first sample unexpectedly reached {first}"); + } + + #[test] + fn polarity_is_reversible() { + let mut positive = PpmDetector::new(SR, PpmStandard::EbuTypeIib); + let mut negative = PpmDetector::new(SR, PpmStandard::EbuTypeIib); + for index in 0..SR as usize { + let value = sine_sample(index, 1_000.0, 0.5); + positive.process(value, value); + negative.process(-value, -value); + } + assert!((positive.levels().0 - negative.levels().0).abs() < 1.0e-6); + } + + #[test] + fn ebu_frequency_response_is_flat_through_16_khz() { + let frequencies = [31.5, 1_000.0, 5_000.0, 10_000.0, 12_500.0, 16_000.0]; + let mut reference_detector = PpmDetector::new(SR, PpmStandard::EbuTypeIib); + let reference = run_tone(&mut reference_detector, 1_000.0, 0.5, 1_000.0); + for frequency in frequencies { + let mut detector = PpmDetector::new(SR, PpmStandard::EbuTypeIib); + let measured = run_tone(&mut detector, frequency, 0.5, 1_000.0); + let relative_db = db(measured / reference); + assert!( + relative_db.abs() <= 0.3, + "{frequency} Hz: measured {relative_db:.3} dB relative to 1 kHz" + ); + } + } + + fn assert_return_time( + standard: PpmStandard, + drop_db: f32, + expected_seconds: f32, + tolerance_seconds: f32, + ) { + let mut detector = PpmDetector::new(SR, standard); + run_tone(&mut detector, 1_000.0, 0.5, 1_000.0); + let start = detector.levels().0; + let target = start * 10.0f32.powf(-drop_db / 20.0); + let mut elapsed = 0usize; + while detector.levels().0 > target && elapsed < SR as usize * 10 { + detector.process(0.0, 0.0); + elapsed += 1; + } + let seconds = elapsed as f32 / SR as f32; + assert!( + (seconds - expected_seconds).abs() <= tolerance_seconds, + "return took {seconds:.4} s, expected {expected_seconds:.4} +/- {tolerance_seconds:.4} s" + ); + } +} diff --git a/src/routes.rs b/src/routes.rs index 858238f..2444742 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,3 +1,4 @@ +use anyhow::Context; use axum::{ body::Body, extract::{Path, Query, Request, State, WebSocketUpgrade}, @@ -5,19 +6,19 @@ use axum::{ response::{IntoResponse, Response}, Json, }; -use anyhow::Context; use http_body_util::BodyExt; use serde::Deserialize; use std::{ path::PathBuf, process::Stdio, + sync::Arc, time::{SystemTime, UNIX_EPOCH}, }; use tokio::{fs, io::AsyncWriteExt, process::Command}; use tracing::warn; use crate::{ - model::{PhoenixGlobalConfig, PhoenixRtaConfig}, + model::{MeterFrame, PhoenixGlobalConfig, PhoenixRtaConfig, WaveEnvFrame}, state::AppState, }; @@ -56,7 +57,9 @@ pub async fn get_rta_config(State(state): State) -> Json) -> Json { +pub async fn get_global_config( + State(state): State, +) -> Json { Json(state.global_config_envelope().await) } @@ -209,7 +212,9 @@ pub async fn download_online_update() -> Response { } }; let log_path = build_online_update_log_path(&phoenix_root); - let script = phoenix_root.join("scripts").join("update_phoenix_from_zip.sh"); + let script = phoenix_root + .join("scripts") + .join("update_phoenix_from_zip.sh"); if !script.is_file() { return ( StatusCode::INTERNAL_SERVER_ERROR, @@ -303,7 +308,9 @@ pub async fn restart_after_online_update() -> Response { .into_response(); } }; - let script = phoenix_root.join("scripts").join("restart_phoenix_services.sh"); + let script = phoenix_root + .join("scripts") + .join("restart_phoenix_services.sh"); if !script.is_file() { return ( StatusCode::INTERNAL_SERVER_ERROR, @@ -317,9 +324,7 @@ pub async fn restart_after_online_update() -> Response { tokio::spawn(async move { tokio::time::sleep(tokio::time::Duration::from_millis(750)).await; - let _ = stable_script_command("bash") - .arg(script) - .spawn(); + let _ = stable_script_command("bash").arg(script).spawn(); }); ( @@ -419,6 +424,14 @@ pub async fn metrics_ws(ws: WebSocketUpgrade, State(state): State) -> ws.on_upgrade(move |socket| metrics_ws_inner(socket, state)) } +pub async fn spectro_ws(ws: WebSocketUpgrade, State(state): State) -> Response { + ws.on_upgrade(move |socket| spectro_ws_inner(socket, state)) +} + +pub async fn visuals_ws(ws: WebSocketUpgrade, State(state): State) -> Response { + ws.on_upgrade(move |socket| visuals_ws_inner(socket, state)) +} + pub async fn start_wav_recording( State(state): State, Path(session_id): Path, @@ -458,7 +471,12 @@ pub async fn stop_recording_with_format( stop_recording_inner(state, session_id, &format, query.bitrate_kbps).await } -async fn stop_recording_inner(state: AppState, session_id: u64, format: &str, bitrate_kbps: Option) -> Response { +async fn stop_recording_inner( + state: AppState, + session_id: u64, + format: &str, + bitrate_kbps: Option, +) -> Response { let normalized_format = normalize_recording_format(format); if normalized_format.is_empty() { return ( @@ -474,10 +492,7 @@ async fn stop_recording_inner(state: AppState, session_id: u64, format: &str, bi Ok(capture) => match build_capture_bytes(normalized_format, &capture, bitrate_kbps).await { Ok((mime_type, payload)) => ( StatusCode::OK, - [ - ("content-type", mime_type), - ("cache-control", "no-store"), - ], + [("content-type", mime_type), ("cache-control", "no-store")], Body::from(payload), ) .into_response(), @@ -514,7 +529,8 @@ async fn build_capture_bytes( capture.channels, &capture.pcm_bytes, normalize_mp3_bitrate_kbps(bitrate_kbps), - ).await?, + ) + .await?, )), "webm" => Ok(( "audio/webm", @@ -527,7 +543,12 @@ async fn build_capture_bytes( } } -async fn encode_mp3_bytes(sample_rate: u32, channels: u16, pcm_bytes: &[u8], bitrate_kbps: u32) -> anyhow::Result> { +async fn encode_mp3_bytes( + sample_rate: u32, + channels: u16, + pcm_bytes: &[u8], + bitrate_kbps: u32, +) -> anyhow::Result> { let wav = build_wav_bytes(sample_rate, channels, pcm_bytes); let token = format!( "{}-{}-{}", @@ -557,7 +578,11 @@ async fn encode_mp3_bytes(sample_rate: u32, channels: u16, pcm_bytes: &[u8], bit Ok(payload) } -async fn try_encode_mp3_with_commands(input_path: &PathBuf, output_path: &PathBuf, bitrate_kbps: u32) -> anyhow::Result<()> { +async fn try_encode_mp3_with_commands( + input_path: &PathBuf, + output_path: &PathBuf, + bitrate_kbps: u32, +) -> anyhow::Result<()> { let ffmpeg = Command::new("ffmpeg") .arg("-y") .arg("-hide_banner") @@ -602,7 +627,11 @@ async fn try_encode_mp3_with_commands(input_path: &PathBuf, output_path: &PathBu } } -async fn encode_webm_bytes(sample_rate: u32, channels: u16, pcm_bytes: &[u8]) -> anyhow::Result> { +async fn encode_webm_bytes( + sample_rate: u32, + channels: u16, + pcm_bytes: &[u8], +) -> anyhow::Result> { let wav = build_wav_bytes(sample_rate, channels, pcm_bytes); let token = format!( "{}-{}-{}", @@ -735,27 +764,53 @@ pub async fn save_recording_file( } } -async fn read_frontend_presets(config: &crate::config::PhoenixConfig) -> anyhow::Result> { +async fn read_frontend_presets( + config: &crate::config::PhoenixConfig, +) -> anyhow::Result> { let path = config.frontend_presets_path.clone(); let raw = match fs::read_to_string(&path).await { Ok(raw) => raw, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(serde_json::Map::new()), - Err(err) => return Err(anyhow::anyhow!("failed to read frontend presets {} ({})", path.display(), err)), + Err(err) => { + return Err(anyhow::anyhow!( + "failed to read frontend presets {} ({})", + path.display(), + err + )) + } }; - let parsed = serde_json::from_str::(&raw) - .map_err(|err| anyhow::anyhow!("failed to parse frontend presets {} ({})", path.display(), err))?; + let parsed = serde_json::from_str::(&raw).map_err(|err| { + anyhow::anyhow!( + "failed to parse frontend presets {} ({})", + path.display(), + err + ) + })?; Ok(parsed.as_object().cloned().unwrap_or_default()) } -async fn read_frontend_layouts(config: &crate::config::PhoenixConfig) -> anyhow::Result> { +async fn read_frontend_layouts( + config: &crate::config::PhoenixConfig, +) -> anyhow::Result> { let path = config.frontend_layouts_path.clone(); let raw = match fs::read_to_string(&path).await { Ok(raw) => raw, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(serde_json::Map::new()), - Err(err) => return Err(anyhow::anyhow!("failed to read frontend layouts {} ({})", path.display(), err)), + Err(err) => { + return Err(anyhow::anyhow!( + "failed to read frontend layouts {} ({})", + path.display(), + err + )) + } }; - let parsed = serde_json::from_str::(&raw) - .map_err(|err| anyhow::anyhow!("failed to parse frontend layouts {} ({})", path.display(), err))?; + let parsed = serde_json::from_str::(&raw).map_err(|err| { + anyhow::anyhow!( + "failed to parse frontend layouts {} ({})", + path.display(), + err + ) + })?; Ok(parsed.as_object().cloned().unwrap_or_default()) } @@ -765,9 +820,9 @@ async fn persist_frontend_presets( ) -> anyhow::Result<()> { let path = config.frontend_presets_path.clone(); if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .await - .with_context(|| format!("failed to create frontend preset dir {}", parent.display()))?; + fs::create_dir_all(parent).await.with_context(|| { + format!("failed to create frontend preset dir {}", parent.display()) + })?; } let json = serde_json::to_vec_pretty(&serde_json::Value::Object(presets.clone()))?; fs::write(&path, json) @@ -782,9 +837,9 @@ async fn persist_frontend_layouts( ) -> anyhow::Result<()> { let path = config.frontend_layouts_path.clone(); if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .await - .with_context(|| format!("failed to create frontend layout dir {}", parent.display()))?; + fs::create_dir_all(parent).await.with_context(|| { + format!("failed to create frontend layout dir {}", parent.display()) + })?; } let json = serde_json::to_vec_pretty(&serde_json::Value::Object(layouts.clone()))?; fs::write(&path, json) @@ -835,37 +890,269 @@ fn normalize_frontend_layout_id(raw: &str) -> String { async fn metrics_ws_inner(mut socket: axum::extract::ws::WebSocket, state: AppState) { let mut rx = state.subscribe_metrics(); + let mut ticker = tokio::time::interval(std::time::Duration::from_millis(16)); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); loop { - match rx.recv().await { - Ok(frame) => { - let payload = match serde_json::to_string(&frame) { - Ok(payload) => payload, - Err(err) => { - warn!("failed to encode metrics frame: {}", err); - continue; - } - }; - if socket - .send(axum::extract::ws::Message::Text(payload)) - .await - .is_err() - { - break; - } - } + ticker.tick().await; + let Some(mut latest) = recv_latest_meter_frame(&mut rx).await else { + break; + }; + drain_latest_meter_frame(&mut rx, &mut latest); + let mut frame = (*latest).clone(); + strip_visual_payloads(&mut frame); + + let payload = match serde_json::to_string(&frame) { + Ok(payload) => payload, Err(err) => { - match err { - tokio::sync::broadcast::error::RecvError::Lagged(skipped) => { - warn!("metrics channel lagged by {}; dropping stale frames", skipped); - continue; - } - tokio::sync::broadcast::error::RecvError::Closed => { - warn!("metrics channel closed"); - break; + warn!("failed to encode metrics frame: {}", err); + continue; + } + }; + if socket + .send(axum::extract::ws::Message::Text(payload)) + .await + .is_err() + { + break; + } + } +} + +fn strip_visual_payloads(frame: &mut MeterFrame) { + // Large visual payloads have bounded binary streams of their own. + frame.spectro = None; + frame.wave_l.clear(); + frame.wave_r.clear(); + frame.wave_channels = 0; + frame.xy_l.clear(); + frame.xy_r.clear(); + frame.wave_env = None; +} + +async fn visuals_ws_inner(mut socket: axum::extract::ws::WebSocket, state: AppState) { + let mut rx = state.subscribe_metrics(); + let mut ticker = tokio::time::interval(std::time::Duration::from_millis(16)); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + ticker.tick().await; + let Some(mut latest) = recv_latest_meter_frame(&mut rx).await else { + return; + }; + let (wave_env, xy) = drain_visual_meter_frames(&mut rx, &mut latest); + let mut frame = (*latest).clone(); + frame.wave_env = wave_env; + if let Some((left, right)) = xy { + frame.xy_l = left; + frame.xy_r = right; + } + let Some(payload) = encode_visual_frame(&frame) else { + continue; + }; + if socket + .send(axum::extract::ws::Message::Binary(payload)) + .await + .is_err() + { + return; + } + } +} + +async fn spectro_ws_inner(mut socket: axum::extract::ws::WebSocket, state: AppState) { + let mut rx = state.subscribe_metrics(); + loop { + let mut latest = loop { + match rx.recv().await { + Ok(frame) => { + if frame.spectro.is_some() { + break frame; } } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => return, + } + }; + + loop { + match rx.try_recv() { + Ok(frame) => { + if frame.spectro.is_some() { + latest = frame; + } + } + Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::TryRecvError::Empty) => break, + Err(tokio::sync::broadcast::error::TryRecvError::Closed) => return, } } + + let payload = encode_spectro_frame(latest.spectro.as_ref().expect("checked above")); + if socket + .send(axum::extract::ws::Message::Binary(payload)) + .await + .is_err() + { + return; + } + } +} + +fn encode_spectro_frame(frame: &crate::model::SpectroFrame) -> Vec { + const MAGIC: u32 = 0x5058_5350; // "PXSP" + let count = frame.bins.len().min(u32::MAX as usize) as u32; + let mut output = Vec::with_capacity(20 + count as usize * 4); + output.extend_from_slice(&MAGIC.to_le_bytes()); + output.extend_from_slice(&(frame.seq as u32).to_le_bytes()); + output.extend_from_slice(&frame.sample_rate.to_le_bytes()); + output.extend_from_slice(&frame.fft_size.to_le_bytes()); + output.extend_from_slice(&count.to_le_bytes()); + for value in frame.bins.iter().take(count as usize) { + output.extend_from_slice(&value.to_le_bytes()); + } + output +} + +fn encode_visual_frame(frame: &MeterFrame) -> Option> { + const MAGIC: u32 = 0x5058_5653; // Phoenix visual stream + const FLAG_XY: u16 = 1; + const FLAG_WAVE_ENV: u16 = 2; + + let xy_count = frame + .xy_l + .len() + .min(frame.xy_r.len()) + .min(u32::MAX as usize); + let wave = frame.wave_env.as_ref().filter(|value| { + matches!(value.channels, 1 | 2) + && value.columns > 0 + && value.data.len() + == value + .columns + .saturating_mul(value.channels as usize) + .saturating_mul(2) + }); + let wave_values = wave.map(|value| value.data.len()).unwrap_or(0); + let mut flags = 0u16; + if xy_count > 0 { + flags |= FLAG_XY; + } + if wave_values > 0 { + flags |= FLAG_WAVE_ENV; + } + if flags == 0 { + return None; + } + + let wave_columns = wave + .map(|value| value.columns) + .unwrap_or(0) + .min(u32::MAX as usize) as u32; + let wave_channels = wave.map(|value| value.channels).unwrap_or(0) as u16; + let wave_column_samples = wave + .map(|value| value.column_samples) + .unwrap_or(0) + .min(u32::MAX as usize) as u32; + let wave_sample_rate = wave.map(|value| value.sample_rate).unwrap_or(0); + let mut output = Vec::with_capacity(32 + xy_count * 8 + wave_values * 4); + output.extend_from_slice(&MAGIC.to_le_bytes()); + output.extend_from_slice(&1u16.to_le_bytes()); + output.extend_from_slice(&flags.to_le_bytes()); + output.extend_from_slice(&(frame.seq as u32).to_le_bytes()); + output.extend_from_slice(&(xy_count as u32).to_le_bytes()); + output.extend_from_slice(&wave_columns.to_le_bytes()); + output.extend_from_slice(&wave_channels.to_le_bytes()); + output.extend_from_slice(&0u16.to_le_bytes()); + output.extend_from_slice(&wave_column_samples.to_le_bytes()); + output.extend_from_slice(&wave_sample_rate.to_le_bytes()); + for index in 0..xy_count { + output.extend_from_slice(&frame.xy_l[index].to_le_bytes()); + output.extend_from_slice(&frame.xy_r[index].to_le_bytes()); + } + if let Some(wave) = wave { + for value in &wave.data { + output.extend_from_slice(&value.to_le_bytes()); + } + } + Some(output) +} + +async fn recv_latest_meter_frame( + rx: &mut tokio::sync::broadcast::Receiver>, +) -> Option> { + loop { + match rx.recv().await { + Ok(frame) => return Some(frame), + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { + warn!("metrics channel skipped {} stale frames", skipped); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => return None, + } + } +} + +fn drain_latest_meter_frame( + rx: &mut tokio::sync::broadcast::Receiver>, + latest: &mut Arc, +) { + loop { + match rx.try_recv() { + Ok(newer) => *latest = newer, + Err(tokio::sync::broadcast::error::TryRecvError::Empty) => break, + Err(tokio::sync::broadcast::error::TryRecvError::Lagged(skipped)) => { + warn!("metrics drain skipped {} stale frames", skipped); + } + Err(tokio::sync::broadcast::error::TryRecvError::Closed) => break, + } + } +} + +fn drain_visual_meter_frames( + rx: &mut tokio::sync::broadcast::Receiver>, + latest: &mut Arc, +) -> (Option, Option<(Vec, Vec)>) { + let mut combined_wave_env = latest.wave_env.clone(); + let mut latest_xy = if latest.xy_l.is_empty() || latest.xy_r.is_empty() { + None + } else { + Some((latest.xy_l.clone(), latest.xy_r.clone())) + }; + + loop { + match rx.try_recv() { + Ok(newer) => { + merge_wave_env(&mut combined_wave_env, newer.wave_env.clone()); + if !newer.xy_l.is_empty() && !newer.xy_r.is_empty() { + latest_xy = Some((newer.xy_l.clone(), newer.xy_r.clone())); + } + *latest = newer; + } + Err(tokio::sync::broadcast::error::TryRecvError::Empty) => break, + Err(tokio::sync::broadcast::error::TryRecvError::Lagged(skipped)) => { + warn!("metrics drain skipped {} stale frames", skipped); + } + Err(tokio::sync::broadcast::error::TryRecvError::Closed) => break, + } + } + + (combined_wave_env, latest_xy) +} + +fn merge_wave_env(target: &mut Option, incoming: Option) { + let Some(mut incoming) = incoming else { + return; + }; + let Some(current) = target.as_mut() else { + *target = Some(incoming); + return; + }; + let compatible = current.channels == incoming.channels + && current.column_samples == incoming.column_samples + && current.sample_rate == incoming.sample_rate; + if compatible { + current.columns = current.columns.saturating_add(incoming.columns); + current.data.append(&mut incoming.data); + } else { + *current = incoming; } } @@ -982,3 +1269,151 @@ async fn write_recording_body( tokio::fs::rename(&tmp_path, &path).await?; Ok(path.display().to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn meter_frame() -> MeterFrame { + MeterFrame { + seq: 7, + timestamp_ms: 123, + sample_rate: 48_000, + period_size: 128, + correlation: 0.25, + correlation_negative_peak: -0.5, + rms_l: -20.0, + rms_r: -21.0, + vu_l: -20.0, + vu_r: -21.0, + tp_l: -18.0, + tp_r: -19.0, + ppm_din_l: -18.5, + ppm_din_r: -19.5, + ppm_ebu_l: -18.5, + ppm_ebu_r: -19.5, + lufs_m: None, + lufs_s: None, + lufs_i: None, + lra: None, + lufs_ml: None, + lufs_mr: None, + lufs_sl: None, + lufs_sr: None, + ppm_box_l: None, + ppm_box_r: None, + wave_l: vec![0.1, 0.2], + wave_r: vec![0.3, 0.4], + wave_channels: 2, + xy_l: vec![-0.5, 0.5], + xy_r: vec![0.25, -0.25], + rta: None, + spectro: None, + wave_env: Some(WaveEnvFrame { + data: vec![-0.5, 0.5, -0.25, 0.25], + columns: 2, + channels: 1, + column_samples: 5, + sample_rate: 48_000, + }), + global_config_rev: 0, + input: crate::model::InputSource::Line, + source: "test", + } + } + + #[test] + fn spectro_binary_frame_has_stable_header_and_samples() { + let frame = crate::model::SpectroFrame { + seq: 42, + bins: vec![-80.0, -12.5, 0.0], + sample_rate: 48_000, + fft_size: 8_192, + }; + let encoded = encode_spectro_frame(&frame); + assert_eq!(encoded.len(), 20 + frame.bins.len() * 4); + assert_eq!( + u32::from_le_bytes(encoded[0..4].try_into().unwrap()), + 0x5058_5350 + ); + assert_eq!(u32::from_le_bytes(encoded[4..8].try_into().unwrap()), 42); + assert_eq!( + u32::from_le_bytes(encoded[8..12].try_into().unwrap()), + 48_000 + ); + assert_eq!( + u32::from_le_bytes(encoded[12..16].try_into().unwrap()), + 8_192 + ); + assert_eq!(u32::from_le_bytes(encoded[16..20].try_into().unwrap()), 3); + for (index, expected) in frame.bins.iter().enumerate() { + let offset = 20 + index * 4; + let actual = f32::from_le_bytes(encoded[offset..offset + 4].try_into().unwrap()); + assert_eq!(actual, *expected); + } + } + + #[test] + fn wave_envelopes_merge_without_losing_columns() { + let mut target = Some(WaveEnvFrame { + data: vec![-0.5, 0.5], + columns: 1, + channels: 1, + column_samples: 5, + sample_rate: 48_000, + }); + merge_wave_env( + &mut target, + Some(WaveEnvFrame { + data: vec![-0.25, 0.25, -0.1, 0.1], + columns: 2, + channels: 1, + column_samples: 5, + sample_rate: 48_000, + }), + ); + let merged = target.unwrap(); + assert_eq!(merged.columns, 3); + assert_eq!(merged.data, vec![-0.5, 0.5, -0.25, 0.25, -0.1, 0.1]); + } + + #[test] + fn visual_binary_contains_xy_and_wave_envelope() { + let frame = meter_frame(); + let encoded = encode_visual_frame(&frame).unwrap(); + assert_eq!( + u32::from_le_bytes(encoded[0..4].try_into().unwrap()), + 0x5058_5653 + ); + assert_eq!(u16::from_le_bytes(encoded[4..6].try_into().unwrap()), 1); + assert_eq!(u16::from_le_bytes(encoded[6..8].try_into().unwrap()), 3); + assert_eq!(u32::from_le_bytes(encoded[8..12].try_into().unwrap()), 7); + assert_eq!(u32::from_le_bytes(encoded[12..16].try_into().unwrap()), 2); + assert_eq!(u32::from_le_bytes(encoded[16..20].try_into().unwrap()), 2); + assert_eq!(u16::from_le_bytes(encoded[20..22].try_into().unwrap()), 1); + assert_eq!(u32::from_le_bytes(encoded[24..28].try_into().unwrap()), 5); + assert_eq!( + u32::from_le_bytes(encoded[28..32].try_into().unwrap()), + 48_000 + ); + assert_eq!(encoded.len(), 32 + 2 * 8 + 4 * 4); + } + + #[test] + fn json_metrics_do_not_repeat_large_visual_payloads() { + let mut frame = meter_frame(); + strip_visual_payloads(&mut frame); + let json = serde_json::to_string(&frame).unwrap(); + assert!(!json.contains("wave_l")); + assert!(!json.contains("wave_r")); + assert!(!json.contains("xy_l")); + assert!(!json.contains("xy_r")); + assert!(!json.contains("wave_env")); + assert!(!json.contains("spectro")); + assert!( + json.len() < 1_000, + "scalar metrics JSON grew to {} bytes", + json.len() + ); + } +} diff --git a/src/rta.rs b/src/rta.rs new file mode 100644 index 0000000..d8aa8e9 --- /dev/null +++ b/src/rta.rs @@ -0,0 +1,551 @@ +//! Realtime-analyzer filter primitives. +//! +//! Fractional-octave bands are designed as complete Butterworth bandpasses: +//! an analog low-pass prototype is transformed to a bandpass, pre-warped and +//! mapped with the bilinear transform, then emitted as distinct SOS sections. + +pub const RTW_THIRD_OCTAVE_CENTERS: &[f32] = &[ + 20.0, 25.0, 31.5, 40.0, 50.0, 63.0, 80.0, 100.0, 125.0, 160.0, 200.0, 250.0, 315.0, 400.0, + 500.0, 630.0, 800.0, 1_000.0, 1_250.0, 1_600.0, 2_000.0, 2_500.0, 3_150.0, 4_000.0, 5_000.0, + 6_300.0, 8_000.0, 10_000.0, 12_500.0, 16_000.0, 20_000.0, +]; + +/// Maps a rounded preferred label to its exact IEC base-ten center frequency. +pub fn exact_fractional_octave_center(nominal_hz: f32, bands_per_octave: usize) -> f32 { + let bpo = bands_per_octave.max(1) as f32; + let step = (bpo * (nominal_hz / 1_000.0).log10() / 0.3).round(); + 1_000.0 * 10.0f32.powf(0.3 * step / bpo) +} + +pub fn fractional_octave_edges(center_hz: f32, bands_per_octave: usize) -> (f32, f32) { + let factor = 10.0f32.powf(3.0 / (20.0 * bands_per_octave.max(1) as f32)); + (center_hz / factor, center_hz * factor) +} + +#[derive(Clone, Copy, Debug)] +pub struct BiquadCoeffs { + pub b0: f64, + pub b1: f64, + pub b2: f64, + pub a1: f64, + pub a2: f64, +} + +#[derive(Clone, Copy, Debug)] +struct Complex { + re: f64, + im: f64, +} + +impl Complex { + fn new(re: f64, im: f64) -> Self { + Self { re, im } + } + + fn add(self, other: Self) -> Self { + Self::new(self.re + other.re, self.im + other.im) + } + + fn sub(self, other: Self) -> Self { + Self::new(self.re - other.re, self.im - other.im) + } + + fn mul(self, other: Self) -> Self { + Self::new( + self.re * other.re - self.im * other.im, + self.re * other.im + self.im * other.re, + ) + } + + fn scale(self, value: f64) -> Self { + Self::new(self.re * value, self.im * value) + } + + fn div(self, other: Self) -> Self { + let denom = other.re * other.re + other.im * other.im; + Self::new( + (self.re * other.re + self.im * other.im) / denom, + (self.im * other.re - self.re * other.im) / denom, + ) + } + + fn abs(self) -> f64 { + (self.re * self.re + self.im * self.im).sqrt() + } + + fn sqrt(self) -> Self { + let magnitude = self.abs(); + let re = ((magnitude + self.re) * 0.5).max(0.0).sqrt(); + let im = ((magnitude - self.re) * 0.5) + .max(0.0) + .sqrt() + .copysign(self.im); + Self::new(re, im) + } +} + +fn prewarp(freq_hz: f64, sample_rate: f64) -> f64 { + 2.0 * sample_rate * (std::f64::consts::PI * freq_hz / sample_rate).tan() +} + +fn bilinear_pole(pole: Complex, sample_rate: f64) -> Complex { + let two_fs = Complex::new(2.0 * sample_rate, 0.0); + two_fs.add(pole).div(two_fs.sub(pole)) +} + +/// Designs a 2N-order digital Butterworth bandpass from an N-order prototype. +/// `prototype_order = 3` produces the conventional sixth-order analyzer band. +pub fn design_fractional_octave_band( + center_hz: f32, + lower_hz: f32, + upper_hz: f32, + sample_rate: u32, + prototype_order: usize, +) -> Vec { + let fs = f64::from(sample_rate.max(8_000)); + let nyquist = fs * 0.5; + let lower = f64::from(lower_hz).clamp(0.01, nyquist * 0.999_8); + let upper = f64::from(upper_hz).clamp(lower * 1.000_001, nyquist * 0.999_9); + let order = prototype_order.clamp(1, 4); + let omega_1 = prewarp(lower, fs); + let omega_2 = prewarp(upper, fs); + let bandwidth = omega_2 - omega_1; + let omega_0_sq = omega_1 * omega_2; + let mut positive_poles = Vec::with_capacity(order); + + for index in 0..order { + let angle = std::f64::consts::PI * (2 * index + 1 + order) as f64 / (2 * order) as f64; + let prototype_pole = Complex::new(angle.cos(), angle.sin()); + let bp = prototype_pole.scale(bandwidth); + let discriminant = bp.mul(bp).sub(Complex::new(4.0 * omega_0_sq, 0.0)).sqrt(); + for root in [ + bp.add(discriminant).scale(0.5), + bp.sub(discriminant).scale(0.5), + ] { + let digital = bilinear_pole(root, fs); + if digital.im > 1.0e-10 { + positive_poles.push(digital); + } + } + } + + positive_poles.sort_by(|left, right| left.re.total_cmp(&right.re)); + let mut sections: Vec = positive_poles + .into_iter() + .map(|pole| BiquadCoeffs { + // Each section receives one zero at DC and one at Nyquist. + b0: 1.0, + b1: 0.0, + b2: -1.0, + a1: -2.0 * pole.re, + a2: pole.re * pole.re + pole.im * pole.im, + }) + .collect(); + + if sections.is_empty() { + return sections; + } + let magnitude = cascade_magnitude(§ions, center_hz, sample_rate).max(1.0e-30); + let per_section_gain = (1.0 / magnitude).powf(1.0 / sections.len() as f64); + for section in &mut sections { + section.b0 *= per_section_gain; + section.b1 *= per_section_gain; + section.b2 *= per_section_gain; + } + sections +} + +pub fn cascade_magnitude(sections: &[BiquadCoeffs], freq_hz: f32, sample_rate: u32) -> f64 { + let omega = 2.0 * std::f64::consts::PI * f64::from(freq_hz) / f64::from(sample_rate.max(8_000)); + let z1 = Complex::new(omega.cos(), -omega.sin()); + let z2 = z1.mul(z1); + sections.iter().fold(1.0, |magnitude, section| { + let numerator = Complex::new(section.b0, 0.0) + .add(z1.scale(section.b1)) + .add(z2.scale(section.b2)); + let denominator = Complex::new(1.0, 0.0) + .add(z1.scale(section.a1)) + .add(z2.scale(section.a2)); + magnitude * numerator.div(denominator).abs() + }) +} + +pub fn cascade_db(sections: &[BiquadCoeffs], freq_hz: f32, sample_rate: u32) -> f32 { + (20.0 + * cascade_magnitude(sections, freq_hz, sample_rate) + .max(1.0e-30) + .log10()) as f32 +} + +pub fn integrate_power( + previous: f64, + block_power: f64, + block_samples: usize, + sample_rate: u32, + tau_seconds: f32, +) -> f64 { + let dt = block_samples as f64 / f64::from(sample_rate.max(1)); + let tau = f64::from(tau_seconds.max(0.001)); + let alpha = 1.0 - (-dt / tau).exp(); + previous + alpha * (block_power - previous) +} + +#[derive(Clone, Copy)] +struct StereoBiquad { + coeffs: BiquadCoeffs, + z1_l: f64, + z2_l: f64, + z1_r: f64, + z2_r: f64, +} + +pub struct StereoCascade { + sections: Vec, +} + +impl StereoCascade { + pub fn new(coefficients: Vec) -> Self { + Self { + sections: coefficients + .into_iter() + .map(|coeffs| StereoBiquad { + coeffs, + z1_l: 0.0, + z2_l: 0.0, + z1_r: 0.0, + z2_r: 0.0, + }) + .collect(), + } + } + + pub fn process(&mut self, left: f32, right: f32) -> (f32, f32) { + let mut out_l = f64::from(left); + let mut out_r = f64::from(right); + for section in &mut self.sections { + out_l = section.process(out_l, false); + out_r = section.process(out_r, true); + } + (out_l as f32, out_r as f32) + } +} + +impl StereoBiquad { + fn process(&mut self, input: f64, right: bool) -> f64 { + let (z1, z2) = if right { + (&mut self.z1_r, &mut self.z2_r) + } else { + (&mut self.z1_l, &mut self.z2_l) + }; + let output = self.coeffs.b0 * input + *z1; + *z1 = self.coeffs.b1 * input - self.coeffs.a1 * output + *z2; + *z2 = self.coeffs.b2 * input - self.coeffs.a2 * output; + output + } +} + +pub struct FrequencyWeighting { + mode: &'static str, + cascade: StereoCascade, +} + +impl FrequencyWeighting { + pub fn new(mode: &str, sample_rate: u32) -> Self { + let mode = normalize_weighting(mode); + let cascade = StereoCascade::new(design_weighting(mode, sample_rate)); + Self { mode, cascade } + } + + pub fn process(&mut self, left: f32, right: f32) -> (f32, f32) { + if self.mode == "z" { + return (left, right); + } + self.cascade.process(left, right) + } +} + +pub fn normalize_weighting(value: &str) -> &'static str { + match value.trim().to_ascii_lowercase().as_str() { + "a" => "a", + "c" => "c", + _ => "z", + } +} + +fn mapped_real_pole(freq_hz: f64, sample_rate: f64) -> f64 { + let analog = prewarp(freq_hz.min(sample_rate * 0.499), sample_rate); + (2.0 * sample_rate - analog) / (2.0 * sample_rate + analog) +} + +fn real_pole_section(zero_1: f64, zero_2: f64, pole_1: f64, pole_2: f64) -> BiquadCoeffs { + BiquadCoeffs { + b0: 1.0, + b1: -(zero_1 + zero_2), + b2: zero_1 * zero_2, + a1: -(pole_1 + pole_2), + a2: pole_1 * pole_2, + } +} + +fn design_weighting(mode: &str, sample_rate: u32) -> Vec { + if mode == "z" { + return Vec::new(); + } + let fs = f64::from(sample_rate.max(8_000)); + let p1 = mapped_real_pole(20.598_997, fs); + let p2 = mapped_real_pole(107.652_65, fs); + let p3 = mapped_real_pole(737.862_23, fs); + let p4 = mapped_real_pole(12_194.217, fs); + let mut sections = if mode == "a" { + vec![ + real_pole_section(1.0, 1.0, p1, p1), + real_pole_section(1.0, 1.0, p2, p3), + real_pole_section(-1.0, -1.0, p4, p4), + ] + } else { + vec![ + real_pole_section(1.0, 1.0, p1, p1), + real_pole_section(-1.0, -1.0, p4, p4), + ] + }; + let magnitude = cascade_magnitude(§ions, 1_000.0, sample_rate).max(1.0e-30); + let gain = (1.0 / magnitude).powf(1.0 / sections.len() as f64); + for section in &mut sections { + section.b0 *= gain; + section.b1 *= gain; + section.b2 *= gain; + } + sections +} + +pub fn weighting_response_db(mode: &str, freq_hz: f32, sample_rate: u32) -> f32 { + cascade_db( + &design_weighting(normalize_weighting(mode), sample_rate), + freq_hz, + sample_rate, + ) +} + +pub fn weighting_reference_db(mode: &str, freq_hz: f32) -> f32 { + if !freq_hz.is_finite() || freq_hz <= 0.0 { + return 0.0; + } + let f2 = freq_hz * freq_hz; + match normalize_weighting(mode) { + "a" => { + let numerator = (12_194.0f32 * 12_194.0) * f2 * f2; + let denominator = (f2 + 20.6f32 * 20.6) + * ((f2 + 107.7f32 * 107.7) * (f2 + 737.9f32 * 737.9)).sqrt() + * (f2 + 12_194.0f32 * 12_194.0); + 20.0 * (numerator / denominator).max(1.0e-12).log10() + 2.0 + } + "c" => { + let numerator = (12_194.0f32 * 12_194.0) * f2; + let denominator = (f2 + 20.6f32 * 20.6) * (f2 + 12_194.0f32 * 12_194.0); + 20.0 * (numerator / denominator).max(1.0e-12).log10() + 0.06 + } + _ => 0.0, + } +} + +/// Corrects the finite-rate digital weighting filter at a band's center to +/// the standardized A/C reference curve. This is especially important near +/// Nyquist, where the bilinear filter necessarily bends toward zero. +pub fn weighting_power_correction(mode: &str, freq_hz: f32, sample_rate: u32) -> f64 { + if normalize_weighting(mode) == "z" { + return 1.0; + } + let difference = + weighting_reference_db(mode, freq_hz) - weighting_response_db(mode, freq_hz, sample_rate); + 10.0f64.powf(f64::from(difference) / 10.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn third_octave_butterworth_has_correct_center_and_edges() { + assert_eq!(RTW_THIRD_OCTAVE_CENTERS.len(), 31); + for &nominal_center in RTW_THIRD_OCTAVE_CENTERS { + let center = exact_fractional_octave_center(nominal_center, 3); + let (lower, upper) = fractional_octave_edges(center, 3); + let sections = design_fractional_octave_band(center, lower, upper, 48_000, 3); + assert_eq!(sections.len(), 3); + assert!(cascade_db(§ions, center, 48_000).abs() < 0.01); + let lower_db = cascade_db(§ions, lower, 48_000); + let upper_db = cascade_db(§ions, upper, 48_000); + assert!( + (lower_db + 3.0103).abs() < 0.08, + "{center}: lower {lower_db}" + ); + assert!( + (upper_db + 3.0103).abs() < 0.08, + "{center}: upper {upper_db}" + ); + } + } + + #[test] + fn preferred_labels_map_to_exact_iec_centers() { + assert!((exact_fractional_octave_center(31.5, 3) - 31.622_776).abs() < 0.000_1); + assert_eq!(exact_fractional_octave_center(1_000.0, 3), 1_000.0); + assert!((exact_fractional_octave_center(20_000.0, 3) - 19_952.623).abs() < 0.01); + let center = exact_fractional_octave_center(1_250.0, 3); + let (lower, upper) = fractional_octave_edges(center, 3); + assert!(((lower * upper).sqrt() - center).abs() < 0.001); + } + + #[test] + fn neighboring_third_octave_centers_are_suppressed() { + let factor = 2.0f32.powf(1.0 / 6.0); + let sections = + design_fractional_octave_band(1_000.0, 1_000.0 / factor, 1_000.0 * factor, 48_000, 3); + assert!(cascade_db(§ions, 1_000.0 * 2.0f32.powf(1.0 / 3.0), 48_000) < -18.0); + assert!(cascade_db(§ions, 1_000.0 * 2.0f32.powf(-1.0 / 3.0), 48_000) < -18.0); + } + + #[test] + fn design_tracks_sample_rate() { + let factor = 2.0f32.powf(1.0 / 6.0); + for sample_rate in [44_100, 48_000, 96_000] { + let sections = design_fractional_octave_band( + 10_000.0, + 10_000.0 / factor, + 10_000.0 * factor, + sample_rate, + 3, + ); + assert!(cascade_db(§ions, 10_000.0, sample_rate).abs() < 0.01); + } + } + + #[test] + fn power_integration_is_block_size_independent() { + fn run(block_size: usize) -> f64 { + let sample_rate = 48_000; + let mut value = 0.0; + let mut processed = 0usize; + while processed < sample_rate as usize { + let count = block_size.min(sample_rate as usize - processed); + value = integrate_power(value, 1.0, count, sample_rate, 0.125); + processed += count; + } + value + } + let reference = run(1); + for block_size in [64, 128, 192, 512, 1_024] { + let actual = run(block_size); + assert!( + (actual - reference).abs() < 1.0e-12, + "block {block_size}: {actual} vs {reference}" + ); + } + } + + #[test] + fn weighting_is_normalized_and_directionally_correct() { + for mode in ["a", "c"] { + assert!(weighting_response_db(mode, 1_000.0, 48_000).abs() < 0.01); + } + assert!(weighting_response_db("a", 31.5, 48_000) < -35.0); + assert!(weighting_response_db("c", 31.5, 48_000) < -2.0); + assert!(weighting_response_db("a", 8_000.0, 48_000) < 0.0); + assert_eq!(weighting_response_db("z", 31.5, 48_000), 0.0); + } + + #[test] + fn weighting_center_calibration_tracks_standard_reference_points() { + let a_points = [ + (31.5, -39.5), + (63.0, -26.2), + (125.0, -16.1), + (1_000.0, 0.0), + (8_000.0, -1.1), + (16_000.0, -6.6), + ]; + let c_points = [ + (31.5, -3.0), + (63.0, -0.8), + (1_000.0, 0.0), + (8_000.0, -3.0), + (16_000.0, -8.5), + ]; + for (freq, expected) in a_points { + let raw = weighting_response_db("a", freq, 48_000); + let correction = 10.0 * weighting_power_correction("a", freq, 48_000).log10() as f32; + let actual = raw + correction; + assert!((actual - expected).abs() < 1.0, "A {freq} Hz: {actual} dB"); + } + for (freq, expected) in c_points { + let raw = weighting_response_db("c", freq, 48_000); + let correction = 10.0 * weighting_power_correction("c", freq, 48_000).log10() as f32; + let actual = raw + correction; + assert!((actual - expected).abs() < 1.0, "C {freq} Hz: {actual} dB"); + } + } + + #[test] + fn weighting_time_domain_is_finite_and_normalized() { + let sample_rate = 48_000; + for freq in [31.5, 1_000.0, 16_000.0] { + let mut weighting = FrequencyWeighting::new("a", sample_rate); + let mut input_power = 0.0f64; + let mut output_power = 0.0f64; + for index in 0..sample_rate as usize * 2 { + let input = + (2.0 * std::f32::consts::PI * freq * index as f32 / sample_rate as f32).sin(); + let (output, _) = weighting.process(input, input); + assert!(output.is_finite()); + if index >= sample_rate as usize { + input_power += f64::from(input * input); + output_power += f64::from(output * output); + } + } + let raw_db = 10.0 * (output_power / input_power).log10(); + let correction_db = 10.0 * weighting_power_correction("a", freq, sample_rate).log10(); + let calibrated_db = raw_db + correction_db; + let expected = f64::from(weighting_reference_db("a", freq)); + assert!( + (calibrated_db - expected).abs() < 0.05, + "A {freq} Hz: {calibrated_db} dB vs {expected} dB" + ); + } + } + + fn measured_sine_gain(filter: &mut StereoCascade, freq: f32, sample_rate: u32) -> f32 { + let total = sample_rate as usize * 3; + let settle = sample_rate as usize * 2; + let mut input_power = 0.0f64; + let mut output_power = 0.0f64; + for index in 0..total { + let phase = 2.0 * std::f32::consts::PI * freq * index as f32 / sample_rate as f32; + let input = phase.sin(); + let (output, _) = filter.process(input, input); + if index >= settle { + input_power += f64::from(input * input); + output_power += f64::from(output * output); + } + } + (10.0 * (output_power / input_power).log10()) as f32 + } + + #[test] + fn time_domain_filter_matches_designed_response() { + let factor = 2.0f32.powf(1.0 / 6.0); + let coefficients = + design_fractional_octave_band(1_000.0, 1_000.0 / factor, 1_000.0 * factor, 48_000, 3); + let center = measured_sine_gain( + &mut StereoCascade::new(coefficients.clone()), + 1_000.0, + 48_000, + ); + let edge = measured_sine_gain( + &mut StereoCascade::new(coefficients), + 1_000.0 * factor, + 48_000, + ); + assert!(center.abs() < 0.02, "center gain {center}"); + assert!((edge + 3.0103).abs() < 0.08, "edge gain {edge}"); + } +} diff --git a/src/state.rs b/src/state.rs index 07d6cac..6f5f272 100644 --- a/src/state.rs +++ b/src/state.rs @@ -2,8 +2,7 @@ use std::{ collections::HashMap, sync::{ atomic::{AtomicU64, Ordering}, - Arc, - Mutex, + Arc, Mutex, }, }; @@ -16,7 +15,10 @@ use tokio::{ use crate::{ audio::{spawn_audio_capture_worker, AudioWorkerDeps}, config::PhoenixConfig, - model::{InputSource, MeterFrame, PhoenixGlobalConfig, PhoenixGlobalConfigEnvelope, PhoenixRtaConfig, ServiceStatus}, + model::{ + InputSource, MeterFrame, PhoenixGlobalConfig, PhoenixGlobalConfigEnvelope, + PhoenixRtaConfig, ServiceStatus, + }, }; #[derive(Clone)] @@ -24,7 +26,8 @@ pub struct AppState { pub config: PhoenixConfig, current_input: Arc>, seq: Arc, - metrics_tx: broadcast::Sender, + actual_sample_rate: Arc, + metrics_tx: broadcast::Sender>, restart_token: Arc, rta_config: Arc>, global_config: Arc>, @@ -49,7 +52,6 @@ pub(crate) struct NativeWavRecorder { pub pending_discontinuity: bool, } - const NATIVE_RECORDING_EDGE_FADE_FRAMES: usize = 128; fn apply_native_recording_edge_fades(pcm_bytes: &mut [u8], channels: u16) { @@ -62,7 +64,9 @@ fn apply_native_recording_edge_fades(pcm_bytes: &mut [u8], channels: u16) { if total_frames < 2 { return; } - let fade_frames = NATIVE_RECORDING_EDGE_FADE_FRAMES.min(total_frames / 2).max(1); + let fade_frames = NATIVE_RECORDING_EDGE_FADE_FRAMES + .min(total_frames / 2) + .max(1); for frame_idx in 0..fade_frames { let in_gain = (frame_idx as f32 + 1.0) / fade_frames as f32; @@ -71,12 +75,16 @@ fn apply_native_recording_edge_fades(pcm_bytes: &mut [u8], channels: u16) { for ch_idx in 0..ch { let in_off = frame_idx * frame_size + ch_idx * 2; let in_sample = i16::from_le_bytes([pcm_bytes[in_off], pcm_bytes[in_off + 1]]); - let in_scaled = (in_sample as f32 * in_gain).round().clamp(i16::MIN as f32, i16::MAX as f32) as i16; + let in_scaled = (in_sample as f32 * in_gain) + .round() + .clamp(i16::MIN as f32, i16::MAX as f32) as i16; pcm_bytes[in_off..in_off + 2].copy_from_slice(&in_scaled.to_le_bytes()); let out_off = out_frame_idx * frame_size + ch_idx * 2; let out_sample = i16::from_le_bytes([pcm_bytes[out_off], pcm_bytes[out_off + 1]]); - let out_scaled = (out_sample as f32 * out_gain).round().clamp(i16::MIN as f32, i16::MAX as f32) as i16; + let out_scaled = (out_sample as f32 * out_gain) + .round() + .clamp(i16::MIN as f32, i16::MAX as f32) as i16; pcm_bytes[out_off..out_off + 2].copy_from_slice(&out_scaled.to_le_bytes()); } } @@ -84,13 +92,18 @@ fn apply_native_recording_edge_fades(pcm_bytes: &mut [u8], channels: u16) { impl AppState { pub fn new(config: PhoenixConfig) -> Self { - let (metrics_tx, _) = broadcast::channel(512); + // WebSocket consumers always drain to the newest state. A small + // channel bounds memory and prevents seconds of stale measurements. + let (metrics_tx, _) = broadcast::channel(32); let initial_global_config = load_global_config(&config); - let initial_rta_config = apply_global_to_rta(config.default_rta_config(), &initial_global_config); + let initial_rta_config = + apply_global_to_rta(config.default_rta_config(), &initial_global_config); + let configured_sample_rate = config.sample_rate; Self { config, current_input: Arc::new(RwLock::new(InputSource::Line)), seq: Arc::new(AtomicU64::new(0)), + actual_sample_rate: Arc::new(AtomicU64::new(configured_sample_rate as u64)), metrics_tx, restart_token: Arc::new(AtomicU64::new(0)), rta_config: Arc::new(RwLock::new(initial_rta_config)), @@ -100,7 +113,7 @@ impl AppState { } } - pub fn subscribe_metrics(&self) -> broadcast::Receiver { + pub fn subscribe_metrics(&self) -> broadcast::Receiver> { self.metrics_tx.subscribe() } @@ -115,7 +128,10 @@ impl AppState { audio_engine: "alsa-direct", metrics_mode: "live", alsa_device: self.config.alsa_device(), - sample_rate: self.config.sample_rate, + sample_rate: self + .actual_sample_rate + .load(Ordering::SeqCst) + .min(u32::MAX as u64) as u32, } } @@ -148,7 +164,7 @@ impl AppState { lufs_i_norm_enabled: normalized.lufs_i_norm_enabled, ppm_din_loudness_boxes: current.ppm_din_loudness_boxes, ppm_din_loudness_offset_db: current.ppm_din_loudness_offset_db, - xy_points: current.xy_points, + xy_points: normalized.xy_points, gonio_display_gain_db: current.gonio_display_gain_db, record_output_format: current.record_output_format.clone(), record_mp3_bitrate_kbps: current.record_mp3_bitrate_kbps, @@ -158,6 +174,8 @@ impl AppState { clock_led_color: current.clock_led_color.clone(), header_text_color: current.header_text_color.clone(), rta_bar_base_color: current.rta_bar_base_color.clone(), + spectro_gamma: current.spectro_gamma, + spectro_scroll_mode: current.spectro_scroll_mode, peak_history_scroll_mode: current.peak_history_scroll_mode, peak_history_fill_enabled: current.peak_history_fill_enabled, peak_history_fill_invert: current.peak_history_fill_invert, @@ -248,6 +266,7 @@ impl AppState { global_config_rev: self.global_config_rev.clone(), native_wav_recorders: self.native_wav_recorders.clone(), seq: self.seq.clone(), + actual_sample_rate: self.actual_sample_rate.clone(), metrics_tx: self.metrics_tx.clone(), restart_token: self.restart_token.clone(), }); @@ -264,7 +283,10 @@ impl AppState { guard.insert( session_id, NativeWavRecorder { - sample_rate: self.config.sample_rate, + sample_rate: self + .actual_sample_rate + .load(Ordering::SeqCst) + .min(u32::MAX as u64) as u32, channels: 2, pcm_bytes: Vec::new(), total_frames: 0, @@ -334,34 +356,69 @@ fn normalize_rta_config(mut config: PhoenixRtaConfig) -> PhoenixRtaConfig { }; config.integration = match config.integration.trim().to_ascii_lowercase().as_str() { "impulse" => "impulse".to_string(), + "medium" => "medium".to_string(), "slow" => "slow".to_string(), + "average" => "average".to_string(), "peak" => "peak".to_string(), _ => "fast".to_string(), }; config.order = config.order.clamp(2, 8); + if config.order % 2 != 0 { + config.order = (config.order + 1).min(8); + } + if config.layout == "rtw" { + config.engine = "iir".to_string(); + config.bpo = "1_3".to_string(); + config.order = 6; + config.freq_range = "norm".to_string(); + } - let tau_fast = if config.tau_fast.is_finite() { config.tau_fast } else { 0.12 }; - let tau_slow = if config.tau_slow.is_finite() { config.tau_slow } else { 1.0 }; + let tau_fast = if config.tau_fast.is_finite() { + config.tau_fast + } else { + 0.12 + }; + let tau_slow = if config.tau_slow.is_finite() { + config.tau_slow + } else { + 1.0 + }; config.tau_fast = tau_fast.clamp(0.01, 3.0); config.tau_slow = tau_slow.clamp(0.05, 10.0); if config.tau_slow < config.tau_fast { config.tau_slow = config.tau_fast; } - let offset_l = if config.input_offset_db_l.is_finite() { config.input_offset_db_l } else { -5.0 }; - let offset_r = if config.input_offset_db_r.is_finite() { config.input_offset_db_r } else { -5.0 }; + let offset_l = if config.input_offset_db_l.is_finite() { + config.input_offset_db_l + } else { + -5.0 + }; + let offset_r = if config.input_offset_db_r.is_finite() { + config.input_offset_db_r + } else { + -5.0 + }; config.input_offset_db_l = offset_l.clamp(-60.0, 20.0); config.input_offset_db_r = offset_r.clamp(-60.0, 20.0); - let din_attack_ms = if config.ppm_din_attack_ms.is_finite() { config.ppm_din_attack_ms } else { 5.0 }; - let din_decay = if config.ppm_din_decay_db_per_s.is_finite() { config.ppm_din_decay_db_per_s } else { 20.0 / 1.7 }; - let ebu_attack_ms = if config.ppm_ebu_attack_ms.is_finite() { config.ppm_ebu_attack_ms } else { 10.0 }; - let ebu_decay = if config.ppm_ebu_decay_db_per_s.is_finite() { config.ppm_ebu_decay_db_per_s } else { 24.0 / 2.8 }; - config.ppm_din_attack_ms = din_attack_ms.clamp(0.1, 100.0); - config.ppm_din_decay_db_per_s = din_decay.clamp(0.1, 120.0); + // Standard profiles are fixed. Keeping the serialized fields preserves + // 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 = ebu_attack_ms.clamp(0.1, 100.0); - config.ppm_ebu_decay_db_per_s = ebu_decay.clamp(0.1, 120.0); + config.ppm_ebu_attack_ms = 10.0; + config.ppm_ebu_decay_db_per_s = 24.0 / 2.8; 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 = + crate::correlation::normalize_response_seconds(config.correlation_response_s); + config.xy_points = match config.xy_points { + 128 | 256 | 512 | 1024 | 2048 => config.xy_points, + n if n < 192 => 128, + n if n < 384 => 256, + n if n < 768 => 512, + n if n < 1536 => 1024, + _ => 2048, + }; config } @@ -388,7 +445,10 @@ fn normalize_meter_slot(input: String, fallback: &str) -> String { } } -fn normalize_global_config(mut config: PhoenixGlobalConfig, runtime: &PhoenixConfig) -> PhoenixGlobalConfig { +fn normalize_global_config( + mut config: PhoenixGlobalConfig, + runtime: &PhoenixConfig, +) -> PhoenixGlobalConfig { config.fft_size = match config.fft_size { 2048 | 4096 | 8192 | 16384 => config.fft_size, n if n < 3072 => 2048, @@ -402,8 +462,16 @@ fn normalize_global_config(mut config: PhoenixGlobalConfig, runtime: &PhoenixCon "1_12" => "1_12".to_string(), _ => "1_6".to_string(), }; - let offset_l = if config.input_offset_db_l.is_finite() { config.input_offset_db_l } else { -5.0 }; - let offset_r = if config.input_offset_db_r.is_finite() { config.input_offset_db_r } else { -5.0 }; + let offset_l = if config.input_offset_db_l.is_finite() { + config.input_offset_db_l + } else { + -5.0 + }; + let offset_r = if config.input_offset_db_r.is_finite() { + config.input_offset_db_r + } else { + -5.0 + }; config.input_offset_db_l = offset_l.clamp(-10.0, 10.0); config.input_offset_db_r = offset_r.clamp(-10.0, 10.0); config.mono_input = !!config.mono_input; @@ -415,22 +483,26 @@ fn normalize_global_config(mut config: PhoenixGlobalConfig, runtime: &PhoenixCon }; config.lr_fractional_delay_samples = delay.clamp(-1.5, 1.5); config.al_markers_enabled = !!config.al_markers_enabled; - let thin = if config.meter_bar_thin.is_finite() { config.meter_bar_thin } else { 0.55 }; + let thin = if config.meter_bar_thin.is_finite() { + config.meter_bar_thin + } else { + 0.55 + }; config.meter_bar_thin = thin.clamp(0.35, 0.9); config.panel_dividers_enabled = !!config.panel_dividers_enabled; - let din_attack_ms = if config.ppm_din_attack_ms.is_finite() { config.ppm_din_attack_ms } else { 5.0 }; - let din_decay = if config.ppm_din_decay_db_per_s.is_finite() { config.ppm_din_decay_db_per_s } else { 20.0 / 1.7 }; - let ebu_attack_ms = if config.ppm_ebu_attack_ms.is_finite() { config.ppm_ebu_attack_ms } else { 10.0 }; - let ebu_decay = if config.ppm_ebu_decay_db_per_s.is_finite() { config.ppm_ebu_decay_db_per_s } else { 24.0 / 2.8 }; - config.ppm_din_attack_ms = din_attack_ms.clamp(0.1, 100.0); - config.ppm_din_decay_db_per_s = din_decay.clamp(0.1, 120.0); + 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 = ebu_attack_ms.clamp(0.1, 100.0); - config.ppm_ebu_decay_db_per_s = ebu_decay.clamp(0.1, 120.0); + config.ppm_ebu_attack_ms = 10.0; + config.ppm_ebu_decay_db_per_s = 24.0 / 2.8; 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; - let loudness_offset = if config.ppm_din_loudness_offset_db.is_finite() { config.ppm_din_loudness_offset_db } else { 0.0 }; + let loudness_offset = if config.ppm_din_loudness_offset_db.is_finite() { + config.ppm_din_loudness_offset_db + } else { + 0.0 + }; config.ppm_din_loudness_offset_db = loudness_offset.clamp(-7.0, 7.0); config.xy_points = match config.xy_points { 128 | 256 | 512 | 1024 | 2048 => config.xy_points, @@ -440,9 +512,18 @@ fn normalize_global_config(mut config: PhoenixGlobalConfig, runtime: &PhoenixCon n if n < 1536 => 1024, _ => 2048, }; - let gonio_gain = if config.gonio_display_gain_db.is_finite() { config.gonio_display_gain_db } else { -5.0 }; + let gonio_gain = if config.gonio_display_gain_db.is_finite() { + config.gonio_display_gain_db + } else { + -5.0 + }; config.gonio_display_gain_db = gonio_gain.clamp(-35.0, 35.0); - config.record_output_format = match config.record_output_format.trim().to_ascii_lowercase().as_str() { + config.record_output_format = match config + .record_output_format + .trim() + .to_ascii_lowercase() + .as_str() + { "mp3" => "mp3".to_string(), "webm" => "webm".to_string(), _ => "wav".to_string(), @@ -459,13 +540,39 @@ fn normalize_global_config(mut config: PhoenixGlobalConfig, runtime: &PhoenixCon "volumio" => "volumio".to_string(), _ => "analyzer".to_string(), }; - let auto_gap = if config.record_auto_split_gap_sec.is_finite() { config.record_auto_split_gap_sec } else { 1.5 }; + let auto_gap = if config.record_auto_split_gap_sec.is_finite() { + config.record_auto_split_gap_sec + } else { + 1.5 + }; config.record_auto_split_gap_sec = (auto_gap.clamp(0.5, 3.0) * 2.0).round() / 2.0; - let auto_threshold = if config.record_auto_threshold_dbfs.is_finite() { config.record_auto_threshold_dbfs } else { -50.0 }; + let auto_threshold = if config.record_auto_threshold_dbfs.is_finite() { + config.record_auto_threshold_dbfs + } else { + -50.0 + }; config.record_auto_threshold_dbfs = auto_threshold.clamp(-120.0, 20.0); config.clock_led_color = normalize_ui_color(config.clock_led_color, "#ff0000"); config.header_text_color = normalize_ui_color(config.header_text_color, "#ffe066"); config.rta_bar_base_color = normalize_ui_color(config.rta_bar_base_color, "#ffe066"); + config.spectro_gamma = if config.spectro_gamma.is_finite() { + config.spectro_gamma.clamp(0.3, 1.2) + } else { + 0.9 + }; + config.spectro_scroll_mode = match config.spectro_scroll_mode { + x if !x.is_finite() => 1.0, + x if (x - 0.5).abs() < f32::EPSILON => 0.5, + x if (x - 1.0).abs() < f32::EPSILON => 1.0, + x if (x - 2.0).abs() < f32::EPSILON => 2.0, + x if (x - 4.0).abs() < f32::EPSILON => 4.0, + x if (x - 6.0).abs() < f32::EPSILON => 6.0, + x if x < 0.75 => 0.5, + x if x < 1.5 => 1.0, + x if x < 3.0 => 2.0, + x if x < 5.0 => 4.0, + _ => 6.0, + }; config.peak_history_scroll_mode = match config.peak_history_scroll_mode { x if !x.is_finite() => 1.0, x if (x - 0.5).abs() < f32::EPSILON => 0.5, @@ -482,9 +589,18 @@ fn normalize_global_config(mut config: PhoenixGlobalConfig, runtime: &PhoenixCon config.peak_history_fill_enabled = !!config.peak_history_fill_enabled; config.peak_history_fill_invert = !!config.peak_history_fill_invert; config.peak_history_slot = normalize_meter_slot(config.peak_history_slot, "ppm-din"); - let phase_gain = if config.phase_display_gain_db.is_finite() { config.phase_display_gain_db } else { 0.0 }; + let phase_gain = if config.phase_display_gain_db.is_finite() { + config.phase_display_gain_db + } else { + 0.0 + }; config.phase_display_gain_db = (phase_gain.clamp(-35.0, 35.0) / 5.0).round() * 5.0; - config.phase_amplitude_mode = match config.phase_amplitude_mode.trim().to_ascii_lowercase().as_str() { + config.phase_amplitude_mode = match config + .phase_amplitude_mode + .trim() + .to_ascii_lowercase() + .as_str() + { "ppm-din" => "ppm-din".to_string(), _ => "bandpass".to_string(), }; @@ -513,9 +629,17 @@ fn normalize_global_config(mut config: PhoenixGlobalConfig, runtime: &PhoenixCon config.lufs_color_s = normalize_ui_color(config.lufs_color_s, "#ff3b3b"); config.lufs_scale_label_color = normalize_ui_color(config.lufs_scale_label_color, "#8fd3d4"); config.screensaver_enabled = !!config.screensaver_enabled; - let idle = if config.screensaver_idle_min.is_finite() { config.screensaver_idle_min } else { 5.0 }; + let idle = if config.screensaver_idle_min.is_finite() { + config.screensaver_idle_min + } else { + 5.0 + }; config.screensaver_idle_min = idle.clamp(0.0, 120.0); - let activity = if config.screensaver_activity_db.is_finite() { config.screensaver_activity_db } else { -50.0 }; + let activity = if config.screensaver_activity_db.is_finite() { + config.screensaver_activity_db + } else { + -50.0 + }; config.screensaver_activity_db = activity.clamp(-120.0, 0.0); config.screensaver_mode = match config.screensaver_mode.trim().to_ascii_lowercase().as_str() { "starfield" => "starfield".to_string(), @@ -525,11 +649,18 @@ fn normalize_global_config(mut config: PhoenixGlobalConfig, runtime: &PhoenixCon }; config.screensaver_led_glow = !!config.screensaver_led_glow; let color = config.screensaver_led_color.trim(); - config.screensaver_led_color = if color.is_empty() { "#ff0000".to_string() } else { color.to_string() }; + config.screensaver_led_color = if color.is_empty() { + "#ff0000".to_string() + } else { + color.to_string() + }; config } -fn apply_global_to_rta(mut config: PhoenixRtaConfig, global: &PhoenixGlobalConfig) -> PhoenixRtaConfig { +fn apply_global_to_rta( + mut config: PhoenixRtaConfig, + global: &PhoenixGlobalConfig, +) -> PhoenixRtaConfig { config.fft_size = global.fft_size; config.bpo = global.rta_bpo_mode.clone(); config.input_offset_db_l = global.input_offset_db_l; @@ -544,6 +675,7 @@ fn apply_global_to_rta(mut config: PhoenixRtaConfig, global: &PhoenixGlobalConfi config.ppm_ebu_decay_db_per_s = global.ppm_ebu_decay_db_per_s; 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) } @@ -558,7 +690,10 @@ fn load_global_config(runtime: &PhoenixConfig) -> PhoenixGlobalConfig { normalize_global_config(parsed, runtime) } -async fn persist_global_config(runtime: &PhoenixConfig, config: &PhoenixGlobalConfig) -> anyhow::Result<()> { +async fn persist_global_config( + runtime: &PhoenixConfig, + config: &PhoenixGlobalConfig, +) -> anyhow::Result<()> { let path = runtime.global_config_path.clone(); if let Some(parent) = path.parent() { fs::create_dir_all(parent) @@ -571,3 +706,42 @@ async fn persist_global_config(runtime: &PhoenixConfig, config: &PhoenixGlobalCo .with_context(|| format!("failed to write Phoenix config {}", path.display()))?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rtw_profile_is_always_iir_third_octave() { + let config = normalize_rta_config(PhoenixRtaConfig { + engine: "fft".to_string(), + bpo: "1_12".to_string(), + order: 2, + freq_range: "lf".to_string(), + layout: "rtw".to_string(), + ..PhoenixRtaConfig::default() + }); + assert_eq!(config.engine, "iir"); + assert_eq!(config.bpo, "1_3"); + assert_eq!(config.order, 6); + assert_eq!(config.freq_range, "norm"); + } + + #[test] + fn phoenix_profile_retains_explicit_extensions() { + let config = normalize_rta_config(PhoenixRtaConfig { + engine: "fft".to_string(), + bpo: "1_12".to_string(), + order: 8, + freq_range: "lf".to_string(), + layout: "iec".to_string(), + integration: "medium".to_string(), + ..PhoenixRtaConfig::default() + }); + assert_eq!(config.engine, "fft"); + assert_eq!(config.bpo, "1_12"); + assert_eq!(config.order, 8); + assert_eq!(config.freq_range, "lf"); + assert_eq!(config.integration, "medium"); + } +} diff --git a/www/core/audio.js b/www/core/audio.js index 643f443..be4b537 100644 --- a/www/core/audio.js +++ b/www/core/audio.js @@ -2,14 +2,26 @@ // Wird von main.js mit `initAudio(env)` gestartet. Nutzt env.config (CONFIG) und env.meters. import { applyPhoenixGlobalConfig, buildPhoenixGlobalConfigPayload, saveConfig } from './config.js'; +import { decodePhoenixSpectroBuffer, decodePhoenixVisualsBuffer } from './binary_protocol.js'; import { getRtwCenters } from './rtw_centers.js'; // interner Zustand (pro App-Instanz) let phoenixSocket = null; +let phoenixSpectroSocket = null; +let phoenixSpectroRetryTimer = null; +let pendingSpectroBuffer = null; +let phoenixSpectroRaf = 0; +let phoenixVisualsSocket = null; +let phoenixVisualsRetryTimer = null; +let pendingVisualsBuffer = null; +let phoenixVisualsRaf = 0; let envRef = null; let lifecycleHandlersBound = false; let recoverTimer = null; let lastHardRecoverAt = 0; +let pendingPhoenixFrame = null; +let phoenixPacketRaf = 0; +let phoenixPacketBusy = false; const RMS_RING = { L: new Float32Array(512), R: new Float32Array(512), i: 0, n: 0 }; const WAVEFORM_RING_SECONDS = 20; @@ -50,30 +62,65 @@ function normalizePhoenixBaseUrl(rawValue) { } } -function buildPhoenixWsUrl(baseUrl) { +function buildPhoenixWsUrl(baseUrl, pathname = '/api/v1/metrics/ws') { try { const url = new URL(baseUrl); url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; - url.pathname = '/api/v1/metrics/ws'; + url.pathname = pathname; url.search = ''; url.hash = ''; return url.toString(); } catch (_) { const fallback = defaultPhoenixBaseUrl(); - return fallback.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:') + '/api/v1/metrics/ws'; + return fallback.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:') + pathname; } } function closePhoenixSocket() { - if (!phoenixSocket) return; - try { - phoenixSocket.onopen = null; - phoenixSocket.onmessage = null; - phoenixSocket.onerror = null; - phoenixSocket.onclose = null; - phoenixSocket.close(); - } catch (_) {} + if (phoenixSocket) { + try { + phoenixSocket.onopen = null; + phoenixSocket.onmessage = null; + phoenixSocket.onerror = null; + phoenixSocket.onclose = null; + phoenixSocket.close(); + } catch (_) {} + } phoenixSocket = null; + if (phoenixSpectroRetryTimer) clearTimeout(phoenixSpectroRetryTimer); + phoenixSpectroRetryTimer = null; + if (phoenixSpectroSocket) { + try { + phoenixSpectroSocket.onopen = null; + phoenixSpectroSocket.onmessage = null; + phoenixSpectroSocket.onerror = null; + phoenixSpectroSocket.onclose = null; + phoenixSpectroSocket.close(); + } catch (_) {} + } + phoenixSpectroSocket = null; + pendingSpectroBuffer = null; + if (phoenixSpectroRaf) cancelAnimationFrame(phoenixSpectroRaf); + phoenixSpectroRaf = 0; + if (phoenixVisualsRetryTimer) clearTimeout(phoenixVisualsRetryTimer); + phoenixVisualsRetryTimer = null; + if (phoenixVisualsSocket) { + try { + phoenixVisualsSocket.onopen = null; + phoenixVisualsSocket.onmessage = null; + phoenixVisualsSocket.onerror = null; + phoenixVisualsSocket.onclose = null; + phoenixVisualsSocket.close(); + } catch (_) {} + } + phoenixVisualsSocket = null; + pendingVisualsBuffer = null; + if (phoenixVisualsRaf) cancelAnimationFrame(phoenixVisualsRaf); + phoenixVisualsRaf = 0; + pendingPhoenixFrame = null; + if (phoenixPacketRaf) cancelAnimationFrame(phoenixPacketRaf); + phoenixPacketRaf = 0; + phoenixPacketBusy = false; } async function requestPhoenixRtaConfig(baseUrl, config) { @@ -164,14 +211,20 @@ function applyRmsActivity(env, rmsL, rmsR, sampleTs) { if (hit) env.audio.lastSignalTs = sampleTs; } -function copyPhoenixSpectroBins(audioState, spectro) { - if (!audioState) return; - const src = Array.isArray(spectro?.bins) ? spectro.bins : null; +function copyPhoenixSpectroBins(audioState, spectro, frameDelta = 1) { + if (!audioState) return false; + const src = Array.isArray(spectro?.bins) || ArrayBuffer.isView(spectro?.bins) + ? spectro.bins + : null; if (!src || !src.length) { audioState.phoenixSpectroBuffer = null; audioState.phoenixSpectroMeta = null; - return; + return false; } + const sourceSeq = Number(spectro?.seq); + const previousSourceSeq = Number(audioState.phoenixSpectroSourceSeq || 0); + if (Number.isFinite(sourceSeq) && sourceSeq > 0 + && previousSourceSeq > 0 && sourceSeq <= previousSourceSeq) return false; let target = audioState.phoenixSpectroBuffer; if (!(target instanceof Float32Array) || target.length !== src.length) { target = new Float32Array(src.length); @@ -190,7 +243,118 @@ function copyPhoenixSpectroBins(audioState, spectro) { fftSize, frequencyBinCount: target.length, }; - audioState.phoenixSpectroSeq = (audioState.phoenixSpectroSeq || 0) + 1; + let delta = Number.isFinite(Number(frameDelta)) ? Math.max(1, Math.floor(Number(frameDelta))) : 1; + if (Number.isFinite(sourceSeq) && sourceSeq > 0) { + if (previousSourceSeq > 0) delta = Math.max(1, Math.floor(sourceSeq - previousSourceSeq)); + audioState.phoenixSpectroSourceSeq = sourceSeq; + } + audioState.phoenixSpectroSeq = (audioState.phoenixSpectroSeq || 0) + delta; + return true; +} + +function openPhoenixSpectroSocket(baseUrl, env) { + if (phoenixSpectroSocket && ( + phoenixSpectroSocket.readyState === WebSocket.OPEN + || phoenixSpectroSocket.readyState === WebSocket.CONNECTING + )) return; + const socket = new WebSocket(buildPhoenixWsUrl(baseUrl, '/api/v1/spectro/ws')); + socket.binaryType = 'arraybuffer'; + phoenixSpectroSocket = socket; + + socket.onmessage = (event) => { + if (!(event.data instanceof ArrayBuffer)) return; + pendingSpectroBuffer = event.data; + scheduleSpectroBufferPump(env); + }; + + const retry = () => { + if (phoenixSpectroSocket === socket) phoenixSpectroSocket = null; + if (!phoenixSocket || phoenixSocket.readyState !== WebSocket.OPEN) return; + if (phoenixSpectroRetryTimer) clearTimeout(phoenixSpectroRetryTimer); + phoenixSpectroRetryTimer = setTimeout(() => { + phoenixSpectroRetryTimer = null; + openPhoenixSpectroSocket(baseUrl, env); + }, 1000); + }; + socket.onerror = retry; + socket.onclose = retry; +} + +function scheduleSpectroBufferPump(env) { + if (phoenixSpectroRaf || !pendingSpectroBuffer) return; + phoenixSpectroRaf = requestAnimationFrame(() => { + phoenixSpectroRaf = 0; + const buffer = pendingSpectroBuffer; + pendingSpectroBuffer = null; + try { + const decoded = decodePhoenixSpectroBuffer(buffer); + if (!decoded) return; + if (!copyPhoenixSpectroBins(env.audio, decoded)) return; + env.requestRender?.('spectro'); + } catch (err) { + console.warn('Phoenix spectrogram packet error:', err); + } finally { + if (pendingSpectroBuffer) scheduleSpectroBufferPump(env); + } + }); +} + +function openPhoenixVisualsSocket(baseUrl, env) { + if (phoenixVisualsSocket && ( + phoenixVisualsSocket.readyState === WebSocket.OPEN + || phoenixVisualsSocket.readyState === WebSocket.CONNECTING + )) return; + const socket = new WebSocket(buildPhoenixWsUrl(baseUrl, '/api/v1/visuals/ws')); + socket.binaryType = 'arraybuffer'; + phoenixVisualsSocket = socket; + socket.onmessage = (event) => { + if (!(event.data instanceof ArrayBuffer)) return; + pendingVisualsBuffer = event.data; + scheduleVisualsBufferPump(env); + }; + const retry = () => { + if (phoenixVisualsSocket === socket) phoenixVisualsSocket = null; + if (!phoenixSocket || phoenixSocket.readyState !== WebSocket.OPEN) return; + if (phoenixVisualsRetryTimer) clearTimeout(phoenixVisualsRetryTimer); + phoenixVisualsRetryTimer = setTimeout(() => { + phoenixVisualsRetryTimer = null; + openPhoenixVisualsSocket(baseUrl, env); + }, 1000); + }; + socket.onerror = retry; + socket.onclose = retry; +} + +function scheduleVisualsBufferPump(env) { + if (phoenixVisualsRaf || !pendingVisualsBuffer) return; + phoenixVisualsRaf = requestAnimationFrame(() => { + phoenixVisualsRaf = 0; + const buffer = pendingVisualsBuffer; + pendingVisualsBuffer = null; + try { + applyVisualsBuffer(env, buffer); + } catch (err) { + console.warn('Phoenix visual packet error:', err); + } finally { + if (pendingVisualsBuffer) scheduleVisualsBufferPump(env); + } + }); +} + +function applyVisualsBuffer(env, buffer) { + const decoded = decodePhoenixVisualsBuffer(buffer); + if (!decoded) return; + const { seq, left, right, wave } = decoded; + const previousSeq = Number(env.audio?.phoenixVisualsSeq || 0); + if (previousSeq > 0 && seq <= previousSeq) return; + if (left && right) { + env.audio.xyL = left; + env.audio.xyR = right; + env.audio.xySeq = seq; + } + if (wave) updateWaveformEnvelopeStore(env.audio, wave); + env.audio.phoenixVisualsSeq = seq; + env.requestRender?.('visuals'); } async function updateActiveMeters(env, packet, CONFIG) { @@ -230,21 +394,33 @@ async function syncPhoenixGlobalConfig(env, revision) { async function applyIncomingAudioPacket(env, packet, CONFIG, sampleTs = performance.now()) { const d = packet || {}; if (!env?.audio) return; + const seq = Number(d.seq); + const previousSeq = Number(env.audio.phoenixMetricsSeq || 0); + if (Number.isFinite(seq) && seq > 0 && previousSeq > 0 && seq <= previousSeq) return; + if (Number.isFinite(seq) && seq > 0) env.audio.phoenixMetricsSeq = seq; env.requestRender?.('audio'); env.audio.lastSampleTs = sampleTs; env.audio.alive = true; + if (Number.isFinite(Number(d.sampleRate)) && Number(d.sampleRate) > 0) { + env.audio.sampleRate = Number(d.sampleRate); + env.audio.nyq = Number(d.sampleRate) / 2; + } if (d.xyL && d.xyR) { env.audio.xyL = d.xyL; env.audio.xyR = d.xyR; env.audio.xySeq = Number.isFinite(d.seq) ? d.seq : (env.audio.xySeq || 0); } + if (typeof d.correlation === 'number') env.audio.correlation = d.correlation; + if (typeof d.correlationNegativePeak === 'number') { + env.audio.correlationNegativePeak = d.correlationNegativePeak; + } if (d.waveL && env.audio.pushWaveSamples) { const channelCount = d.waveChannels || (d.waveR ? 2 : 1); env.audio.sampleRate = d.sampleRate || env.audio.sampleRate || 48000; env.audio.pushWaveSamples(d.waveL, d.waveR || null, channelCount, d.sampleRate); } if (d.rta) env.audio.rtaData = d.rta; - if (d.spectro) copyPhoenixSpectroBins(env.audio, d.spectro); + if (d.spectro) copyPhoenixSpectroBins(env.audio, d.spectro, d.spectroFrameDelta); if (typeof d.ppmDinL === 'number') env.audio.ppmDinL = d.ppmDinL; if (typeof d.ppmDinR === 'number') env.audio.ppmDinR = d.ppmDinR; if (typeof d.ppmEbuL === 'number') env.audio.ppmEbuL = d.ppmEbuL; @@ -294,6 +470,8 @@ function buildPhoenixMeterPacket(frame) { const lufsSR = Number(frame?.lufs_sr); const ppmBoxL = Number(frame?.ppm_box_l); const ppmBoxR = Number(frame?.ppm_box_r); + const correlation = Number(frame?.correlation); + const correlationNegativePeak = Number(frame?.correlation_negative_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; @@ -302,7 +480,11 @@ function buildPhoenixMeterPacket(frame) { const spectro = frame?.spectro && typeof frame.spectro === 'object' ? frame.spectro : null; const waveEnv = frame?.wave_env && typeof frame.wave_env === 'object' ? frame.wave_env : null; return { - sampleRate: 48000, + sampleRate: Number.isFinite(Number(frame?.sample_rate)) ? Number(frame.sample_rate) : 48000, + correlation: Number.isFinite(correlation) ? Math.max(-1, Math.min(1, correlation)) : 0, + correlationNegativePeak: Number.isFinite(correlationNegativePeak) + ? Math.max(-1, Math.min(1, correlationNegativePeak)) + : 0, rmsL: Number.isFinite(rmsL) ? rmsL : -120, rmsR: Number.isFinite(rmsR) ? rmsR : -120, tpL: Number.isFinite(tpL) ? tpL : -120, @@ -344,10 +526,14 @@ function buildPhoenixMeterPacket(frame) { sampleRate: Number.isFinite(Number(rta?.sample_rate)) ? Number(rta.sample_rate) : 48000, } : null, spectro: spectro ? { + seq: Number.isFinite(Number(spectro?.seq)) ? Number(spectro.seq) : 0, bins: Array.isArray(spectro?.bins) ? spectro.bins : [], sampleRate: Number.isFinite(Number(spectro?.sample_rate)) ? Number(spectro.sample_rate) : 48000, fftSize: Number.isFinite(Number(spectro?.fft_size)) ? Number(spectro.fft_size) : 4096, } : null, + spectroFrameDelta: Number.isFinite(Number(frame?.__spectro_frame_delta)) + ? Math.max(1, Math.floor(Number(frame.__spectro_frame_delta))) + : 1, waveEnv: waveEnv ? { data: Array.isArray(waveEnv?.data) ? waveEnv.data : [], columns: Number.isFinite(Number(waveEnv?.columns)) ? Number(waveEnv.columns) : 0, @@ -363,6 +549,57 @@ function buildPhoenixMeterPacket(frame) { }; } +function mergePendingPhoenixFrame(previous, next) { + if (!next) return previous; + const previousSeq = Number(previous?.seq || 0); + const nextSeq = Number(next?.seq || 0); + if (previous && previousSeq > 0 && nextSeq > 0 && nextSeq <= previousSeq) return previous; + if (!previous) { + if (next?.spectro) next.__spectro_frame_delta = 1; + return next; + } + const merged = next; + if (!merged.wave_env && previous.wave_env) merged.wave_env = previous.wave_env; + if ((!merged.xy_l || !merged.xy_l.length) && previous.xy_l?.length) { + merged.xy_l = previous.xy_l; + merged.xy_r = previous.xy_r; + } + if (!merged.rta && previous.rta) merged.rta = previous.rta; + const previousSpectroCount = Math.max(0, Number(previous.__spectro_frame_delta) || 0); + if (merged.spectro) { + merged.__spectro_frame_delta = previousSpectroCount + 1; + } else if (previous.spectro) { + merged.spectro = previous.spectro; + merged.__spectro_frame_delta = Math.max(1, previousSpectroCount); + } + return merged; +} + +function schedulePhoenixPacketPump(env, CONFIG) { + if (phoenixPacketRaf || phoenixPacketBusy || !pendingPhoenixFrame) return; + phoenixPacketRaf = requestAnimationFrame(async () => { + phoenixPacketRaf = 0; + const frame = pendingPhoenixFrame; + pendingPhoenixFrame = null; + if (!frame) return; + phoenixPacketBusy = true; + try { + const packet = buildPhoenixMeterPacket(frame); + await applyIncomingAudioPacket(env, packet, CONFIG, performance.now()); + } catch (err) { + console.warn('Phoenix packet error:', err); + } finally { + phoenixPacketBusy = false; + if (pendingPhoenixFrame) schedulePhoenixPacketPump(env, CONFIG); + } + }); +} + +function enqueuePhoenixFrame(env, CONFIG, frame) { + pendingPhoenixFrame = mergePendingPhoenixFrame(pendingPhoenixFrame, frame); + schedulePhoenixPacketPump(env, CONFIG); +} + function bindLifecycleHandlers() { if (lifecycleHandlersBound) return; @@ -625,33 +862,39 @@ function updateWaveformEnvelopeStore(audioState, payload) { appendWaveformEnvelope(audioState.waveEnvStore, payload); } -function buildRtaRuntimeConfig(CONFIG = {}) { - const bpoMode = CONFIG.RTA_BPO_MODE || '1_6'; +export function buildRtaRuntimeConfig(CONFIG = {}) { + const bpoMode = CONFIG.RTA_BPO_MODE || '1_3'; const layout = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'rtw' : 'iec'; - const runtimeBpoMode = layout === 'rtw' ? '1_12' : bpoMode; + const rtwProfile = layout === 'rtw'; + const runtimeBpoMode = rtwProfile ? '1_3' : bpoMode; return { - engine: CONFIG.RTA_ENGINE || 'fft', + engine: rtwProfile ? 'iir' : (CONFIG.RTA_ENGINE || 'iir'), fftSize: CONFIG.FFT_SIZE || 4096, monoInput: !!CONFIG.MONO_INPUT, lrFractionalDelayEnabled: !!CONFIG.LR_FRACTIONAL_DELAY_ENABLED, lrFractionalDelaySamples: Number.isFinite(CONFIG.LR_FRACTIONAL_DELAY_SAMPLES) ? CONFIG.LR_FRACTIONAL_DELAY_SAMPLES : 0, bpo: runtimeBpoMode, - freqRange: CONFIG.RTA_FREQ_RANGE || 'norm', + freqRange: rtwProfile ? 'norm' : (CONFIG.RTA_FREQ_RANGE || 'norm'), weighting: CONFIG.RTA_WEIGHTING || 'z', - order: CONFIG.RTA_IIR_ORDER || 4, + order: rtwProfile ? 6 : (CONFIG.RTA_IIR_ORDER || 6), tauFast: CONFIG.RTA_IIR_TAU_FAST || 0.12, tauSlow: CONFIG.RTA_IIR_TAU_SLOW || 1.0, integration: CONFIG.RTA_INTEGRATION || 'fast', layout, inputOffsetDbL: Number.isFinite(CONFIG.INPUT_OFFSET_DB_L) ? CONFIG.INPUT_OFFSET_DB_L : -5, inputOffsetDbR: Number.isFinite(CONFIG.INPUT_OFFSET_DB_R) ? CONFIG.INPUT_OFFSET_DB_R : -5, - ppmDinAttackMs: Number.isFinite(CONFIG.PPM_DIN_ATTACK_MS) ? CONFIG.PPM_DIN_ATTACK_MS : 5, - ppmDinDecayDbPerS: Number.isFinite(CONFIG.PPM_DIN_DECAY_DB_PER_S) ? CONFIG.PPM_DIN_DECAY_DB_PER_S : (20 / 1.7), + ppmDinAttackMs: 10, + ppmDinDecayDbPerS: 20 / 1.5, ppmDinFastAttack: !!CONFIG.PPM_DIN_FAST_ATTACK, - ppmEbuAttackMs: Number.isFinite(CONFIG.PPM_EBU_ATTACK_MS) ? CONFIG.PPM_EBU_ATTACK_MS : 10, - ppmEbuDecayDbPerS: Number.isFinite(CONFIG.PPM_EBU_DECAY_DB_PER_S) ? CONFIG.PPM_EBU_DECAY_DB_PER_S : (24 / 2.8), + ppmEbuAttackMs: 10, + ppmEbuDecayDbPerS: 24 / 2.8, lufsIWindowMin: Number.isFinite(CONFIG.LUFS_I_WINDOW_MIN) ? CONFIG.LUFS_I_WINDOW_MIN : 4, lufsINormEnabled: !!CONFIG.LUFS_I_NORM_ENABLED, + correlationResponseS: Number(CONFIG.CORR_RESPONSE_S) >= 1.75 ? 2.5 : 1.0, + correlationResetToken: Math.max(0, Math.floor(Number(CONFIG.CORR_RESET_TOKEN) || 0)), + xyPoints: [128, 256, 512, 1024, 2048].includes(Number(CONFIG.XY_POINTS)) + ? Number(CONFIG.XY_POINTS) + : 1024, rtwCenters: layout === 'rtw' ? getRtwCenters(runtimeBpoMode) : null, }; } @@ -700,6 +943,9 @@ async function initPhoenixAudio(env) { env.audio.phoenixSpectroScratch = null; env.audio.phoenixSpectroMeta = null; env.audio.phoenixSpectroSeq = 0; + env.audio.phoenixSpectroSourceSeq = 0; + env.audio.phoenixVisualsSeq = 0; + env.audio.phoenixMetricsSeq = 0; env.audio.rmsDb = { L: -120, R: -120, mono: -120 }; const phoenixAnalyser = { @@ -852,16 +1098,15 @@ async function initPhoenixAudio(env) { }; socket.onopen = () => { + openPhoenixSpectroSocket(baseUrl, env); + openPhoenixVisualsSocket(baseUrl, env); finish(true); }; socket.onmessage = (event) => { try { const frame = JSON.parse(event.data); - const packet = buildPhoenixMeterPacket(frame); - applyIncomingAudioPacket(env, packet, CONFIG, performance.now()).catch((err) => { - console.warn('Phoenix packet error:', err); - }); + enqueuePhoenixFrame(env, CONFIG, frame); } catch (err) { console.warn('Phoenix metrics parse error:', err); } diff --git a/www/core/binary_protocol.js b/www/core/binary_protocol.js new file mode 100644 index 0000000..c74d65e --- /dev/null +++ b/www/core/binary_protocol.js @@ -0,0 +1,68 @@ +// Phoenix binary WebSocket packet decoders. Keep this module dependency-free so +// protocol compatibility can be tested without a browser. + +export function decodePhoenixSpectroBuffer(buffer) { + if (!(buffer instanceof ArrayBuffer) || buffer.byteLength < 20) return null; + const view = new DataView(buffer); + if (view.getUint32(0, true) !== 0x50585350) return null; + const seq = view.getUint32(4, true); + const sampleRate = view.getUint32(8, true); + const fftSize = view.getUint32(12, true); + const count = view.getUint32(16, true); + if (count < 1 || buffer.byteLength !== 20 + count * 4) return null; + return { + seq, + sampleRate, + fftSize, + bins: new Float32Array(buffer, 20, count), + }; +} + +export function decodePhoenixVisualsBuffer(buffer) { + if (!(buffer instanceof ArrayBuffer) || buffer.byteLength < 32) return null; + const view = new DataView(buffer); + if (view.getUint32(0, true) !== 0x50585653 || view.getUint16(4, true) !== 1) return null; + const flags = view.getUint16(6, true); + const seq = view.getUint32(8, true); + const xyCount = view.getUint32(12, true); + const waveColumns = view.getUint32(16, true); + const waveChannels = view.getUint16(20, true); + const waveColumnSamples = view.getUint32(24, true); + const waveSampleRate = view.getUint32(28, true); + if (waveChannels > 2) return null; + const xyBytes = xyCount * 8; + const waveValues = waveColumns * waveChannels * 2; + const expectedBytes = 32 + xyBytes + waveValues * 4; + if (!Number.isSafeInteger(expectedBytes) || buffer.byteLength !== expectedBytes) return null; + + let offset = 32; + let left = null; + let right = null; + if ((flags & 1) && xyCount > 0) { + left = new Float32Array(xyCount); + right = new Float32Array(xyCount); + for (let index = 0; index < xyCount; index++) { + left[index] = view.getFloat32(offset, true); + right[index] = view.getFloat32(offset + 4, true); + offset += 8; + } + } else { + offset += xyBytes; + } + + let wave = null; + if ((flags & 2) && waveValues > 0 && (waveChannels === 1 || waveChannels === 2)) { + const data = new Float32Array(waveValues); + for (let index = 0; index < waveValues; index++) { + data[index] = view.getFloat32(offset + index * 4, true); + } + wave = { + data, + columns: waveColumns, + channels: waveChannels, + columnSamples: waveColumnSamples, + sampleRate: waveSampleRate, + }; + } + return { seq, left, right, wave }; +} diff --git a/www/core/config.js b/www/core/config.js index 3fbfd09..38662c9 100644 --- a/www/core/config.js +++ b/www/core/config.js @@ -51,20 +51,20 @@ const CONFIG = { REALTIME_BAR_DECAY_DB_PER_S: 20, RTA_ENGINE: 'iir', // 'fft' | 'iir' RTA_FREQ_RANGE: 'norm', // 'norm' | 'lf' - RTA_BPO_MODE: '1_6', + RTA_BPO_MODE: '1_3', RTA_WEIGHTING: 'z', // 'z' | 'a' | 'c' RTA_DISPLAY_GAIN_FFT_DB: 20, RTA_DISPLAY_GAIN_IIR_DB: 20, RTA_BAR_LAYOUT: 'rtw', // 'iec' | 'rtw' RTA_BAR_BASE_COLOR: '#ffe066', HEADER_TEXT_COLOR: '#ffe066', - RTA_INTEGRATION: 'fast', // 'impulse' | 'fast' | 'slow' | 'peak' + RTA_INTEGRATION: 'fast', // 'impulse' | 'fast' | 'medium' | 'slow' | 'average' | 'peak' RTA_BALLISTICS_MODE: 'average', // 'average' | 'peak' | 'both' RTA_PEAK_HOLD_MODE: 'auto', // 'off' | 'auto' | 'manual' - RTA_PEAK_HOLD_SEC: 2, + RTA_PEAK_HOLD_SEC: 2.5, RTA_PEAK_DECAY_DB_PER_S: 20, RTA_DISPLAY_HOLD_SEC: 0, - RTA_IIR_ORDER: 4, + RTA_IIR_ORDER: 6, RTA_IIR_TAU_FAST: 0.12, RTA_IIR_TAU_SLOW: 1.0, SPECTRO_GAMMA: 0.4, @@ -156,20 +156,19 @@ const CONFIG = { PPM_DIN_TOP: +5, PPM_DIN_BOTTOM: -50, PPM_DIN_RED_START: 0, - // DIN PPM attack and decay times: 5 ms integration and 20 dB in 1.7 s - // (≈11.8 dB/s) return, per IEC 60268‑10 Type I. - PPM_DIN_ATTACK_MS: 5, + // 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: 11.8, + PPM_DIN_DECAY_DB_PER_S: 20 / 1.5, // Hold time for DIN PPM: verlängert auf 1000 ms (1 s), damit der Peak‑Hold // auch bei kurzen Transienten deutlich sichtbar bleibt. Gemäß // Rundfunkpraxis werden Peak‑Hold‑Striche häufig 1–2 Sekunden gehalten. PPM_DIN_HOLD_MS: 1000, // Decay rate for the DIN PPM hold. When the held peak decays, it uses - // the same slope as the main meter (≈11.8 dB/s). - PPM_DIN_HOLD_DECAY_DB_PER_S: 11.8, + // the same slope as the main meter (≈13.33 dB/s). + PPM_DIN_HOLD_DECAY_DB_PER_S: 20 / 1.5, // Offsets für die PPM-Meter (DIN/EBU-spezifisch). Die EBU/DIN Offsets // ersetzen den früheren globalen PPM_OFFSET. @@ -178,16 +177,18 @@ const CONFIG = { PPM_REF_DBFS_PEAK_FOR_0_DBU: -15.0, // Ballistic parameters for the EBU PPM (Type IIb). Attack ~10 ms and - // decay 24 dB in 2.8 s (≈8.6 dB/s), per IEC 60268‑10 Type II. A short + // decay 24 dB in 2.8 s (≈8.57 dB/s), per EBU Tech 3205-E Type IIb. A short // peak-hold (~750 ms) helps visualise transients. Hold decay matches // the main decay rate. PPM_EBU_ATTACK_MS: 10, - PPM_EBU_DECAY_DB_PER_S: 8.6, + PPM_EBU_DECAY_DB_PER_S: 24 / 2.8, PPM_EBU_HOLD_MS: 750, - PPM_EBU_HOLD_DECAY_DB_PER_S: 8.6, + PPM_EBU_HOLD_DECAY_DB_PER_S: 24 / 2.8, CORR_SMOOTH: 0.85, CORR_ZERO_ON_SILENCE: false, CORR_SILENCE_THRESHOLD_RMS_DBFS: -75, + CORR_RESPONSE_S: 1.0, + CORR_RESET_TOKEN: 0, XY_POINTS: 1024, XY_STYLE: 'lines', XY_SILENCE_GATE_ENABLED: true, @@ -198,6 +199,7 @@ const CONFIG = { GONIO_MANUAL_GAIN_DB: -5, GONIO_AGC_ENABLED: false, GONIO_LINE_FADE_MS: 50, + GONIO_PERSISTENCE_MODE: 'fast', PHASE_DISPLAY_GAIN_DB: 0, PHASE_AGC_ENABLED: false, PHASE_TRAIL_ENABLED: true, @@ -599,6 +601,8 @@ const PHOENIX_GLOBAL_OPTION_KEYS = Object.freeze([ 'CLOCK_LED_COLOR', 'HEADER_TEXT_COLOR', 'RTA_BAR_BASE_COLOR', + 'SPECTRO_GAMMA', + 'SPECTRO_SCROLL_MODE', 'PEAK_HISTORY_SCROLL_MODE', 'PEAK_HISTORY_FILL_ENABLED', 'PEAK_HISTORY_FILL_INVERT', @@ -774,6 +778,14 @@ function loadConfig(opts = {}) { if (typeof saved.PPM_DIN_DECAY_DB_PER_S === 'number') CONFIG.PPM_DIN_DECAY_DB_PER_S = saved.PPM_DIN_DECAY_DB_PER_S; if (typeof saved.PPM_DIN_HOLD_MS === 'number') CONFIG.PPM_DIN_HOLD_MS = saved.PPM_DIN_HOLD_MS; if (CONFIG.RTA_BAR_LAYOUT === 'classic') CONFIG.RTA_BAR_LAYOUT = 'rtw'; + if (CONFIG.RTA_BAR_LAYOUT === 'rtw') { + CONFIG.RTA_ENGINE = 'iir'; + CONFIG.RTA_BPO_MODE = '1_3'; + CONFIG.RTA_IIR_ORDER = 6; + CONFIG.RTA_FREQ_RANGE = 'norm'; + const hold = Number(CONFIG.RTA_PEAK_HOLD_SEC); + CONFIG.RTA_PEAK_HOLD_SEC = hold >= 3.25 ? 4.0 : 2.5; + } if (!Number.isFinite(CONFIG.DBFS_TOP)) CONFIG.DBFS_TOP = CONFIG_DEFAULTS.DBFS_TOP; if (!Number.isFinite(CONFIG.DBFS_BOTTOM)) CONFIG.DBFS_BOTTOM = CONFIG_DEFAULTS.DBFS_BOTTOM; // Clamp to sensible bounds while keeping the user-selected display range. @@ -827,6 +839,14 @@ function loadConfig(opts = {}) { 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; + CONFIG.PPM_DIN_DECAY_DB_PER_S = 20 / 1.5; + CONFIG.PPM_DIN_HOLD_DECAY_DB_PER_S = 20 / 1.5; + CONFIG.PPM_EBU_ATTACK_MS = 10; + CONFIG.PPM_EBU_DECAY_DB_PER_S = 24 / 2.8; + CONFIG.PPM_EBU_HOLD_DECAY_DB_PER_S = 24 / 2.8; CONFIG.HIFI_PEAK_ALIGNMENT = (CONFIG.HIFI_PEAK_ALIGNMENT === 'ppm_din_zero' || CONFIG.HIFI_PEAK_ALIGNMENT === 'dbfs_zero') ? 'ppm_din_zero' : 'ppm_din_minus5'; @@ -969,6 +989,10 @@ function buildPhoenixGlobalConfigPayload() { if (raw === 'stopwatch') return 'stopwatch'; return fallback; }; + const normalizeSpectroScroll = (value) => { + const number = Number(value); + return [0.5, 1, 2, 4, 6].includes(number) ? number : 1; + }; const normalizePeakHistoryScroll = (value) => { const allowed = new Set([0.5, 1, 2, 4, 6]); const num = Number(value); @@ -990,11 +1014,11 @@ function buildPhoenixGlobalConfigPayload() { monoInput: !!CONFIG.MONO_INPUT, lrFractionalDelayEnabled: !!CONFIG.LR_FRACTIONAL_DELAY_ENABLED, lrFractionalDelaySamples: Number.isFinite(CONFIG.LR_FRACTIONAL_DELAY_SAMPLES) ? CONFIG.LR_FRACTIONAL_DELAY_SAMPLES : (CONFIG_DEFAULTS.LR_FRACTIONAL_DELAY_SAMPLES ?? 0.996), - ppmDinAttackMs: Number.isFinite(CONFIG.PPM_DIN_ATTACK_MS) ? CONFIG.PPM_DIN_ATTACK_MS : (CONFIG_DEFAULTS.PPM_DIN_ATTACK_MS ?? 5), - ppmDinDecayDbPerS: Number.isFinite(CONFIG.PPM_DIN_DECAY_DB_PER_S) ? CONFIG.PPM_DIN_DECAY_DB_PER_S : (CONFIG_DEFAULTS.PPM_DIN_DECAY_DB_PER_S ?? 11.8), + ppmDinAttackMs: 10, + ppmDinDecayDbPerS: 20 / 1.5, ppmDinFastAttack: !!CONFIG.PPM_DIN_FAST_ATTACK, - ppmEbuAttackMs: Number.isFinite(CONFIG.PPM_EBU_ATTACK_MS) ? CONFIG.PPM_EBU_ATTACK_MS : (CONFIG_DEFAULTS.PPM_EBU_ATTACK_MS ?? 10), - ppmEbuDecayDbPerS: Number.isFinite(CONFIG.PPM_EBU_DECAY_DB_PER_S) ? CONFIG.PPM_EBU_DECAY_DB_PER_S : (CONFIG_DEFAULTS.PPM_EBU_DECAY_DB_PER_S ?? 8.6), + ppmEbuAttackMs: 10, + ppmEbuDecayDbPerS: 24 / 2.8, 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, @@ -1016,6 +1040,8 @@ function buildPhoenixGlobalConfigPayload() { clockLedColor: normalizeColor(CONFIG.CLOCK_LED_COLOR, CONFIG_DEFAULTS.CLOCK_LED_COLOR || '#ff0000'), headerTextColor: normalizeColor(CONFIG.HEADER_TEXT_COLOR, CONFIG_DEFAULTS.HEADER_TEXT_COLOR || '#ffe066'), rtaBarBaseColor: normalizeColor(CONFIG.RTA_BAR_BASE_COLOR, CONFIG_DEFAULTS.RTA_BAR_BASE_COLOR || '#ffe066'), + spectroGamma: Number.isFinite(CONFIG.SPECTRO_GAMMA) ? Math.max(0.3, Math.min(1.2, CONFIG.SPECTRO_GAMMA)) : 0.9, + spectroScrollMode: normalizeSpectroScroll(CONFIG.SPECTRO_SCROLL_MODE), peakHistoryScrollMode: normalizePeakHistoryScroll(CONFIG.PEAK_HISTORY_SCROLL_MODE), peakHistoryFillEnabled: !!CONFIG.PEAK_HISTORY_FILL_ENABLED, peakHistoryFillInvert: !!CONFIG.PEAK_HISTORY_FILL_INVERT, @@ -1063,6 +1089,10 @@ function applyPhoenixGlobalConfig(payload = {}) { if (raw === 'stopwatch') return 'stopwatch'; return fallback; }; + const normalizeSpectroScroll = (value) => { + const number = Number(value); + return [0.5, 1, 2, 4, 6].includes(number) ? number : 1; + }; const fft = Number(payload.fftSize); if (Number.isFinite(fft)) { CONFIG.FFT_SIZE = (fft === 2048 || fft === 4096 || fft === 8192 || fft === 16384) @@ -1073,15 +1103,20 @@ function applyPhoenixGlobalConfig(payload = {}) { const mode = String(payload.rtaBpoMode || '').trim(); CONFIG.RTA_BPO_MODE = (mode === '1_3' || mode === '1_6' || mode === '1_12') ? mode : '1_6'; } - const dinAttack = Number(payload.ppmDinAttackMs); - if (Number.isFinite(dinAttack)) CONFIG.PPM_DIN_ATTACK_MS = Math.max(0.1, Math.min(100, dinAttack)); - const dinDecay = Number(payload.ppmDinDecayDbPerS); - if (Number.isFinite(dinDecay)) CONFIG.PPM_DIN_DECAY_DB_PER_S = Math.max(0.1, Math.min(120, dinDecay)); + if (CONFIG.RTA_BAR_LAYOUT === 'rtw') { + CONFIG.RTA_ENGINE = 'iir'; + CONFIG.RTA_BPO_MODE = '1_3'; + CONFIG.RTA_IIR_ORDER = 6; + CONFIG.RTA_FREQ_RANGE = 'norm'; + } + const spectroGamma = Number(payload.spectroGamma); + if (Number.isFinite(spectroGamma)) CONFIG.SPECTRO_GAMMA = Math.max(0.3, Math.min(1.2, spectroGamma)); + 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; - const ebuAttack = Number(payload.ppmEbuAttackMs); - if (Number.isFinite(ebuAttack)) CONFIG.PPM_EBU_ATTACK_MS = Math.max(0.1, Math.min(100, ebuAttack)); - const ebuDecay = Number(payload.ppmEbuDecayDbPerS); - if (Number.isFinite(ebuDecay)) CONFIG.PPM_EBU_DECAY_DB_PER_S = Math.max(0.1, Math.min(120, ebuDecay)); + CONFIG.PPM_EBU_ATTACK_MS = 10; + CONFIG.PPM_EBU_DECAY_DB_PER_S = 24 / 2.8; 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; diff --git a/www/index.html b/www/index.html index aab7ac1..6d4c73e 100644 --- a/www/index.html +++ b/www/index.html @@ -307,7 +307,7 @@ Filterauflösung – Anzahl Bänder @@ -355,7 +355,9 @@ Reaktionsmodus @@ -1024,7 +1026,7 @@ PPM DIN: Fast‑Attack (sofort, nicht normgerecht) - Umgeht die 5‑ms‑Integration und lässt die Anzeige sofort ansteigen (kann gefühltes Anzeige‑Lag durch Latenz reduzieren). + Umgeht die feste 10-ms-Normintegration und zeigt Sample-Peaks sofort an (kann gefühltes Anzeige-Lag reduzieren).