Compare commits

..
17 Commits
58 changed files with 4966 additions and 2594 deletions
+10
View File
@@ -117,6 +117,16 @@ Volumio-spezifisch:
/home/volumio/phoenix.log
```
Die Volumio-spezifischen Units, der eingeschränkte Update-Helfer und die
zugehörige sudo-Regel werden einmalig als root installiert:
```bash
sudo bash /home/volumio/Phoenix/scripts/install_volumio_services.sh
```
Der Installer verändert keine Debian-/Analyzer-Units und startet laufende
Dienste nicht automatisch neu.
Wenn `toggle-ext` genutzt werden soll, sollte es also unter diesem Pfad liegen:
```text
+41 -31
View File
@@ -3,9 +3,10 @@
## Verbindliches Ziel
- Referenz ist der RTW PortaMonitor 1064X/1064X-PLUS, insbesondere dessen RTA-, PPM-, Peakmeter-, Goniometer- und Korrelationsverhalten.
- Gemessen werden analoge Line-Signale. Deshalb umfasst die endgültige Referenzmessung immer den vollständigen analogen Eingang einschließlich Vorstufe, Antialiasing und A/D-Wandler; reine Digitaltests sichern nur den nachfolgenden DSP ab.
- Der primäre RTW-nahe RTA arbeitet als IIR-Fractional-Octave-Filterbank. FFT bleibt eine optionale, getrennt gekennzeichnete Spektrumsansicht.
- Der RTW-RTA-Modus soll 31 Bänder in 1/3-Oktaven von 20 Hz bis 20 kHz sowie ein Verhalten entsprechend IEC 225/ANSI Class 2 beziehungsweise der passenden aktuellen Nachfolgenorm bieten.
- Vorgesehene RTA-Modi: Fast, Medium, Slow, Average und Peak; Peak Hold 2,5 s, 4 s oder manuell.
- Vorgesehene RTA-Detektoren: Average (RMS) und Peak (10 ms); dazu die RMS-Reaktionszeiten Fast, Medium, Slow und Impulse sowie Peak Hold 2,5 s, 4 s oder manuell.
- 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.
@@ -27,12 +28,13 @@ Diese vorhandenen Funktionen sind nicht automatisch messtechnisch korrekt. Die f
- [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.
- **Ist:** Der DSP läuft weiterhin samplekontinuierlich, erzeugt skalare Transport-Snapshots aber periodengrößenunabhängig nur noch mit im Mittel 60 Hz. Der WebSocket sendet diese ohne zusätzlichen 16-ms-Ticker sofort weiter und leert bei Rückstau bis zum neuesten Zustand.
- **Peak-Schutz:** Sample- und True-Peak-Maxima werden über alle Capture-Blöcke bis zum nächsten Snapshot gesammelt. Muss der WebSocket mehrere Snapshots zusammenfassen, bleiben deren höchste True-Peak-Werte ebenfalls erhalten.
- **Abnahme:** Automatische Tests prüfen die periodengrößenunabhängige 60-Hz-Taktung, Peak-Erhalt und Latest-State-Semantik.
- [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.
- **Ist:** Skalare Messzustände laufen mit etwa 60 JSON-Paketen/s. Das Spektrogramm besitzt zusätzlich zum eigenen Binär-WebSocket nun auch einen vollständig getrennten Backend-Kanal und behält dadurch seinen FFT-Takt unabhängig von den Mess-Snapshots. Goniometer/Waveform verwenden einen kompakten Binär-WebSocket. Roh-Waveformsamples werden nicht mehr in jedem Capture-Frame vervielfacht; Waveform-Hüllkurven werden bereits zwischen zwei 60-Hz-Snapshots lückenlos gesammelt. 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.
@@ -62,18 +64,18 @@ Diese vorhandenen Funktionen sind nicht automatisch messtechnisch korrekt. Die f
- **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.
- [ ] **6. RTW-Detektor und Reaktionszeiten vollständig und energetisch korrekt implementieren**
- **Soll:** Getrennte Auswahl von Average-/Peak-Detektor und der RMS-Reaktionszeit entsprechend der RTW-Bedienlogik.
- **Ist:** Average (RMS) und Peak (10 ms) sind getrennte Detektoren; Fast arbeitet mit 125 ms, Slow mit 1 s und Impulse asymmetrisch mit 35 ms Anstieg sowie 1,5 s Rücklauf. Der IIR-Average-Detektor integriert samplegenau im Leistungsbereich; Peak ermittelt gleitend den höchsten gefilterten Samplewert der letzten 10 ms. Beide sind damit unabhängig von der ALSA-Periodengröße. Alte gespeicherte `Average`-/`Peak`-Modi werden automatisch migriert.
- **Geprüft:** Regressionstests prüfen Blockgrößenunabhängigkeit, 10-ms-Peakintegration, Impulse-Anstieg/-Rücklauf sowie die Konfigurationsmigration.
- **Noch offen:** Medium bleibt mit 0,5 s eine Phoenix-/Legacy-Zeitkonstante, solange kein verbindlicher RTW-Wert vorliegt. Die endgültige dynamische Gerätevalidierung bleibt deshalb 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.
- **Ist:** 2,5 s, 4 s und manueller Hold werden samplezeitbasiert im Backend geführt. Der Modus `Hold` springt nach Ablauf eindeutig auf den aktuellen Detektorwert. Der zusätzliche Standardmodus `Hold + Fall` hält zunächst genauso lange und läuft danach mit einstellbaren 1 bis 60 dB/s zurück; Standard sind 20 dB/s. Der Reset erreicht über ein Token sowohl Backend als auch Anzeige. FFT und IIR verwenden dieselbe Hold-Semantik, ohne eine zweite Browser-Holdstufe.
- **Noch offen:** Der achtbandige RTW-Speicher ist noch nicht ergänzt.
- **Aufgabe:** Acht auswählbare Speicherbänder implementieren und Aktuell-, Hold- und Speicherwert visuell eindeutig trennen.
- **Abnahme:** Hold-Zeiten und Reset-Verhalten stimmen zeitlich und visuell mit der Referenz überein.
- [ ] **8. RTA-Anzeigeoptionen am PortaMonitor-Profil ausrichten**
@@ -91,39 +93,46 @@ Diese vorhandenen Funktionen sind nicht automatisch messtechnisch korrekt. Die f
- **Geprüft:** RTA-Filtertests laufen bei 44,1, 48 und 96 kHz; die abschließende Prüfung mit real unterschiedlich aushandelnder Hardware bleibt Teil der Geräteabnahme.
- **Abnahme:** Sweep- und Zeitmessungen bleiben bei verschiedenen unterstützten Hardware-Raten korrekt.
- [ ] **10. True Peak kontinuierlich und blockübergreifend korrigieren**
- [x] **10. True Peak kontinuierlich und blockübergreifend korrigieren**
- **Soll:** Intersample-Peaks werden unabhängig von ihrer Lage zum ALSA-Block zuverlässig erkannt.
- **Ist:** Eine 4-fache Sinc-Interpolation mit kurzer Historie existiert.
- **Falsch/kaputt:** Ungefähr die letzten acht Intervalle jedes Capture-Blocks werden nicht interpoliert und können zu niedrige dBTP-Werte liefern.
- **Aufgabe:** Kontinuierlichen Oversampling-Filter mit vollständiger Historie verwenden und gegen ITU-Testmaterial sowie synthetische Grenzfälle prüfen.
- **Ist:** Die vierphasige FIR-Interpolation verwendet die zwölf Referenzkoeffizienten aus ITU-R BS.1770 Annex 2 und besitzt eine kontinuierliche Samplehistorie. Die zuvor an Capture-Blöcken übersprungenen Intervalle werden lückenlos ausgewertet. Hauptbalken, klassische Zeigeransicht und Verlauf teilen eine gemeinsame dBTP-Skala mit sichtbarer Übersteuerungsreserve bis +6 dBTP.
- **Geprüft:** EBU-Tech-3341-Testfälle 15 bis 19 einschließlich des +3-dBTP-Intersample-Signals bestehen innerhalb +0,2/-0,4 dB. Synthetische Intersample-Peaks werden oberhalb des Sample-Peaks erkannt; 64, 127, 128, 192 und 511 Samples große Capture-Blöcke liefern denselben Maximalwert.
- **Abnahme:** Gleiche Peakwerte unabhängig von Blockgrenze, Periodengröße und Samplerate.
- [x] **11. DIN- und EBU-PPM softwareseitig norm- und RTW-nah auslegen**
- **Soll:** Richtige Skalen, Referenzpegel, Tonburst-Reaktion, Integration, Rücklauf, Peak Hold, Peak Memory und Over-Anzeige.
- **Ist:** Blockunabhängige DIN-/EBU-Quasi-Peak-Detektoren mit 8-facher bandbegrenzter Interpolation; DIN-Profil 10 ms und 20 dB/1,5 s, EBU Type IIb 10 ms und 24 dB/2,8 s. Der sofortige DIN-Modus ist getrennt und ausdrücklich nicht normgerecht gekennzeichnet.
- **Ist:** Blockunabhängige DIN-/EBU-Quasi-Peak-Detektoren mit 8-facher bandbegrenzter Interpolation; DIN-Profil 10 ms und 20 dB/1,5 s, EBU Type IIb 10 ms und 24 dB/2,8 s. Der alte, nicht normgerechte DIN-Sofortmodus wurde vollständig entfernt; ein eigener DIN-Tonbursttest sichert die feste 10-ms-Integration ab.
- **Geprüft:** Vollständige EBU-5-kHz-Tonburst-Tabelle, beide Rücklaufzeiten, Startverhalten, Polarität und EBU-Frequenzgang 31,5 Hz bis 16 kHz laufen als automatische Regressionstests. Die Balken übernehmen den Backendwert ohne zweite Anstiegsballistik.
- **Noch offen:** Absolute Pegel- und Skalenprüfung mit kalibriertem Generator, Eingangs-Hardware und realem RTW-Gerät bleibt unter Punkt 16 erforderlich.
- **Abnahme:** Softwaretests bestehen; die endgültige Aussage zur Messgeräte-Konformität erfolgt erst nach der Hardwarevergleichsmessung.
- [ ] **12. VU und RMS eindeutig und reproduzierbar definieren**
- [x] **12. VU und RMS softwareseitig eindeutig und reproduzierbar definieren**
- **Soll VU:** RTW-artige Moving-Coil-Ballistik mit richtigem Einschwingen, Rücklauf und Überschwingen.
- **Ist VU:** 300-ms-Rechteckmittel der gleichgerichteten Samples.
- **Falsch VU:** Boxcar-Mittelung entspricht nicht der mechanischen VU-Ballistik.
- **Ist VU:** Vollweggleichrichtung mit RMS-Kalibrierung und unterdämpftem Moving-Coil-Modell. Der 1-kHz-Sprung erreicht nach 300 ms etwa 99 % und überschwingt um 1 bis 1,5 %; der Rücklauf und mehrere Capture-Perioden werden automatisch geprüft.
- **Soll RMS:** Dokumentiertes gleitendes Messfenster mit eindeutigem dBFS-/Kalibrierbezug.
- **Ist RMS:** RMS nur über den aktuellen ALSA-Block, bei 128 Samples etwa 2,67 ms.
- **Falsch RMS:** Wert und Unruhe hängen von der Periodengröße ab.
- **Aufgabe:** Beide Detektoren unabhängig von der Capture-Blockgröße implementieren und separat testen.
- **Abnahme:** Identische Werte und Ballistiken bei verschiedenen ALSA-Perioden.
- **Ist RMS:** True RMS wird samplekontinuierlich aus der linearen Signalleistung berechnet. Fast (125 ms) und Slow (1 s) verwenden exponentielle Leistungsintegration; alternativ steht ein festes gleitendes 300-ms-Fenster bereit. Die frühere Browser-Nachglättung bereits logarithmierter dB-Werte und der fälschlich als RMS angebotene Impulse-Modus sind entfernt. Hauptbalken, Verlauf und klassische Ansicht verwenden denselben Backend-Messwert und dieselbe dBFS-/dBu-Umrechnung.
- **Geprüft:** Stationärer 1-kHz-Sinus ergibt in allen drei Integrationen -3,01 dBFS; 64, 127, 128, 192, 511 und 512 Samples große Capture-Blöcke liefern identische Werte. Fast und Slow werden zusätzlich an ihrer analytischen Sprungantwort geprüft.
- **Noch extern zu prüfen:** Pegelkalibrierung und die optische Übereinstimmung mit dem konkreten RTW-PortaMonitor am analogen Eingang.
- **Abnahme:** Softwaretests bestehen; endgültige Geräteübereinstimmung folgt mit der analogen Referenzmessung.
- [ ] **13. FFT-Modus als optionale Spektrumsansicht fachlich korrigieren**
- [x] **12a. RTA-Oktavauflösung zwischen Einzel- und Mehrfachansichten synchronisieren**
- **Ist:** Globale Konfiguration und aktiver DSP konnten durch getrennte API-Aufrufe auseinanderlaufen; beobachtet wurden global 1/6 und gleichzeitig aktiv 1/12. Der Server hält beide Werte jetzt invariant zusammen und repariert auch bereits abweichende Laufzeitzustände. Die eingebettete RTA-Ansicht folgt während des Aktualisierungs-Roundtrips unmittelbar der gewählten Auflösung und kann ein vorhandenes 1/12-Paket verlustfrei auf 1/6 oder 1/3 herunterselektieren.
- **Geprüft:** Regressionstests sichern die Backend-Reparatur sowie die lokale 1/12-zu-1/6-Auswahl einschließlich Average- und Peak-Bändern ab.
- [x] **13. FFT-Modus als optionale Spektrumsansicht fachlich korrigieren**
- **Soll:** FFT ist eine korrekte Zusatzansicht, aber nicht die RTW-IIR-Referenz.
- **Ist:** 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.
- **Ist:** Das FFT-RTA summiert einseitige Binenergien mit Hann-Fensterleistungskompensation. Rand-Bins werden entsprechend ihrem tatsächlichen Bandüberlappungsanteil berücksichtigt; es findet keine Normierung auf die Anzahl oder Gesamtgewichtung der Bins mehr statt. Die native FFT-Integration wird im Browser nicht erneut ausgeführt. Der getrennte Spektrogramm-Amplitudenmaßstab bleibt unverändert.
- **Geprüft:** Sinusenergie bleibt bei 2048, 4096, 8192 und 16384 Punkten innerhalb der Testtoleranz konstant. Ein zusätzlicher Test prüft die geometrische Bandenergie bei allen FFT-Größen.
- **Abnahme:** Konsistente Pegel bei FFT-Größenwechseln und eindeutig getrennte Kennzeichnung im UI.
## Priorität 3 - Stereoanzeigen und visuelles RTW-Verhalten
- [x] **13a. Phasenrad samplekontinuierlich und bildratenunabhängig berechnen**
- **Ist:** Bandpass, 33-Tap-Hilbert-Transformation und energiegewichtete komplexe L/R-Kreuzleistung laufen mit der nativen Samplerate kontinuierlich im Audiokern. Der Hilbert-FIR verwendet einen dauerhaften Ringpuffer und bleibt deshalb über ALSA-, Transport- und Browsergrenzen lückenlos.
- **Datenweg:** Der Browser erhält mit jedem 60-Hz-Messzustand nur Winkel, Kohärenz, Bandpegel und Bandpeak. Die Phasenrechnung hängt nicht mehr von den für das Goniometer gewählten 128 bis 2048 XY-Punkten ab und belastet den Browser nicht mehr mit Bandpass/Hilbert-DSP.
- **Darstellung:** Winkel- und Radiusglättung verwenden reale Zeitkonstanten statt Faktoren pro Grafikframe. Die AGC wird nur mit einem neuen Messzustand fortgeschrieben und richtet den Bandpeak ohne den früheren zusätzlichen Verstärkungsfaktor auf -15 dB aus.
- **Geprüft:** Automatische Tests prüfen Phasenwinkel und Kohärenz eines Sinustons, einseitige Signale, erhaltene Filter-/Hilbert-Historie über Snapshots sowie identische Glättung bei unterschiedlicher Bildrate.
- [ ] **14. Goniometer-Datenweg und Persistenz optimieren**
- **Soll:** Aktuelle XY-Daten erscheinen im nächsten möglichen Bildschirmframe; Fast/Medium/Slow-Persistenz wirkt wie das RTW-Instrument.
- **Ist:** M/S-Darstellung, manueller Gain, AGC, Linien/Punkte sowie reproduzierbare Fast-/Medium-/Slow- und freie Phoenix-Persistenz sind vorhanden.
@@ -134,8 +143,8 @@ Diese vorhandenen Funktionen sind nicht automatisch messtechnisch korrekt. Die f
- **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:** Die Korrelation wird samplekontinuierlich im Audiokern aus gleich integrierten L²-, R²- und L·R-Leistungen berechnet. 1,0 s und 2,5 s sind wählbar; der Browser zeigt den fertigen Wert ohne zweite bildratenabhängige Glättung. Negative-Peak-Memory, Marker und manueller Reset sind implementiert.
- **Soll:** Bereich -1 bis +1, passende Farben, wählbare Ansprechzeiten von 0,5 s, 1,0 s und 2,5 s sowie Negative-Peak-Memory.
- **Ist:** Die Korrelation wird samplekontinuierlich im Audiokern aus gleich integrierten L²-, R²- und L·R-Leistungen berechnet. 0,5 s, 1,0 s und 2,5 s sind wählbar; ein konfigurierbarer Mono-RMS-Silence-Threshold setzt die Anzeige bei Grundrauschen auf Neutralstellung. Der Browser zeigt den fertigen Wert ohne zweite bildratenabhängige Glättung. Negative-Peak-Memory, Marker und manueller Reset sind implementiert.
- **Geprüft:** Automatische Tests prüfen +1, 0 und -1, Stille, einseitiges Signal, beide Ansprechzeiten und den Memory-Reset.
- **Noch offen:** Dynamische Phasenlagen und das exakte Zeit-/Memory-Verhalten müssen am PortaMonitor verglichen werden; deshalb bleibt der Gesamtpunkt offen.
- **Abnahme:** Statische und dynamische Testsignale stimmen innerhalb der festgelegten Toleranz mit der Referenz überein.
@@ -159,7 +168,8 @@ Diese vorhandenen Funktionen sind nicht automatisch messtechnisch korrekt. Die f
- [ ] **18. Automatisierte DSP- und Darstellungsregressionstests aufbauen**
- **Soll:** Keine Änderung kann unbemerkt Pegel, Frequenzgang, Ballistik, Latenz oder RTW-Darstellung verschlechtern.
- **Ist:** DIN-/EBU-PPM, Spektrogramm-Zeitbasis und Langlauf, Worker-Verhalten sowie die Binärprotokolle sind automatisiert abgesichert. Servertests prüfen außerdem das Zusammenführen der Waveform-Hüllkurve und die Trennung großer Nutzdaten vom JSON-Messstrom.
- **Noch offen:** RTA und Korrelation besitzen nun DSP-Tests; der Goniometerweg prüft mehrere Sampleraten, Periodengrößen, Punktreduktion und Persistenzprofile. True Peak, VU/RMS, dynamische Referenzvergleiche und End-to-End-Latenz besitzen noch keine vollständige automatische Regression. Deshalb bleibt dieser Gesamtpunkt offen.
- **Ergänzt:** RTA und Korrelation besitzen DSP-Tests; die Korrelation prüft jetzt auch einen dynamischen Phasensprung und unterscheidet dabei 1,0 von 2,5 Sekunden. Der Goniometerweg prüft mehrere Sampleraten, Periodengrößen, Punktreduktion und Persistenzprofile. True Peak verwendet den ITU-R-BS.1770-Referenzfilter und prüft EBU-Tech-3341-Fälle 15 bis 19, Intersample-Erkennung und Blockgrenzen; RMS prüft Fast/Slow-Zeitantwort, das exakte 300-ms-Fenster, Pegel und Blockunabhängigkeit. Der Laufzeittest begrenzt zusätzlich das Alter empfangener Messframes auf 500 ms und erkennt damit Transport-Backlogs.
- **Noch offen:** Die transienten EBU-Dateitestfälle 20 bis 23, echte Capture-bis-Paint-Latenz und dynamische RTW-Gerätevergleichsreihen. Deshalb bleibt dieser Gesamtpunkt offen.
- **Aufgabe:** Einzeltöne aller 31 Bänder, Sweeps, Weiß-/Rosarauschen, Pegelsprünge, Tonbursts, Phasen-/Korrelationssignale und Intersample-Peaks testen. Sampleraten, Perioden und Blockgrenzen variieren. RTW-Screenshots und Messprotokolle als Referenz verwenden, soweit rechtlich möglich.
- **Abnahme:** Automatischer Bericht mit Erwartungswerten und Toleranzen für jede Messfunktion.
+1
View File
@@ -0,0 +1 @@
volumio ALL=(root) NOPASSWD: /usr/bin/systemctl --no-block start phoenix-update-volumio.service
@@ -0,0 +1,15 @@
[Unit]
Description=Apply staged Phoenix update on Volumio
After=local-fs.target
[Service]
Type=oneshot
User=root
Group=root
TimeoutStartSec=30min
Nice=10
IOSchedulingClass=idle
Environment=PHOENIX_INSTALL_PROFILE=volumio
Environment=PHOENIX_UPDATE_DETACHED=1
Environment=PHOENIX_ROOT_OVERRIDE=/home/volumio/Phoenix
ExecStart=/bin/bash /usr/local/libexec/phoenix/restart_phoenix_services.sh
@@ -11,10 +11,10 @@
"panelDividersEnabled": true,
"ppmDinAttackMs": 10.0,
"ppmDinDecayDbPerS": 13.333333,
"ppmDinFastAttack": false,
"ppmDinLoudnessBoxes": true,
"ppmEbuAttackMs": 10.0,
"ppmEbuDecayDbPerS": 8.571429,
"rmsIntegration": "fast",
"lufsIWindowMin": 4,
"lufsINormEnabled": false,
"ppmDinLoudnessOffsetDb": 0.0,
@@ -11,10 +11,10 @@
"panelDividersEnabled": true,
"ppmDinAttackMs": 10.0,
"ppmDinDecayDbPerS": 13.333333,
"ppmDinFastAttack": false,
"ppmDinLoudnessBoxes": true,
"ppmEbuAttackMs": 10.0,
"ppmEbuDecayDbPerS": 8.571429,
"rmsIntegration": "fast",
"lufsIWindowMin": 4,
"lufsINormEnabled": false,
"ppmDinLoudnessOffsetDb": 0.0,
+58
View File
@@ -0,0 +1,58 @@
#!/bin/bash
set -euo pipefail
if [ "$(id -u)" -ne 0 ]; then
echo "run this installer as root" >&2
exit 1
fi
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
PHOENIX_ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
SYSTEMD_SOURCE="$PHOENIX_ROOT/etc/systemd/system"
SUDOERS_SOURCE="$PHOENIX_ROOT/etc/sudoers.d/phoenix-update-volumio"
LIBEXEC_DIR="/usr/local/libexec/phoenix"
required_files=(
"$SYSTEMD_SOURCE/analyzer-kiosk-volumio.service"
"$SYSTEMD_SOURCE/analyzer-web-volumio.service"
"$SYSTEMD_SOURCE/phoenix-update-volumio.service"
"$SUDOERS_SOURCE"
"$PHOENIX_ROOT/scripts/restart_phoenix_services.sh"
)
for path in "${required_files[@]}"; do
if [ ! -f "$path" ]; then
echo "required Volumio integration file missing: $path" >&2
exit 1
fi
done
if ! command -v visudo >/dev/null 2>&1; then
echo "visudo not found; refusing to install sudo policy" >&2
exit 1
fi
visudo -cf "$SUDOERS_SOURCE"
install -d -o root -g root -m 0755 "$LIBEXEC_DIR"
install -o root -g root -m 0755 \
"$PHOENIX_ROOT/scripts/restart_phoenix_services.sh" \
"$LIBEXEC_DIR/restart_phoenix_services.sh"
install -o root -g root -m 0644 \
"$SYSTEMD_SOURCE/analyzer-kiosk-volumio.service" \
/etc/systemd/system/analyzer-kiosk-volumio.service
install -o root -g root -m 0644 \
"$SYSTEMD_SOURCE/analyzer-web-volumio.service" \
/etc/systemd/system/analyzer-web-volumio.service
install -o root -g root -m 0644 \
"$SYSTEMD_SOURCE/phoenix-update-volumio.service" \
/etc/systemd/system/phoenix-update-volumio.service
install -o root -g root -m 0440 \
"$SUDOERS_SOURCE" \
/etc/sudoers.d/phoenix-update-volumio
systemctl daemon-reload
systemctl enable analyzer-web-volumio.service analyzer-kiosk-volumio.service
echo "Volumio integration installed"
echo "Existing services were not restarted"
+98 -1
View File
@@ -3,7 +3,12 @@
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
PHOENIX_ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
PHOENIX_ROOT="${PHOENIX_ROOT_OVERRIDE:-}"
if [ -z "$PHOENIX_ROOT" ]; then
PHOENIX_ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
else
PHOENIX_ROOT="$(cd "$PHOENIX_ROOT" && pwd -P)"
fi
PHOENIX_PARENT="$(cd "$PHOENIX_ROOT/.." && pwd -P)"
PHOENIX_NAME="$(basename "$PHOENIX_ROOT")"
STAGE_DIR="$PHOENIX_PARENT/${PHOENIX_NAME}.update"
@@ -14,6 +19,10 @@ LOG_FILE="$PHOENIX_PARENT/${PHOENIX_NAME}.update.log"
exec >>"$LOG_FILE" 2>&1
echo "=== $(date): restart_phoenix_services.sh START ==="
is_volumio_install() {
[ "${PHOENIX_INSTALL_PROFILE:-}" = "volumio" ] || [ "$PHOENIX_ROOT" = "/home/volumio/Phoenix" ]
}
restart_if_present() {
local unit="$1"
if ! command -v systemctl >/dev/null 2>&1; then
@@ -61,6 +70,19 @@ run_systemd_run() {
/usr/bin/env PHOENIX_UPDATE_DETACHED=1 /bin/bash "$0"
}
start_volumio_update_service() {
local unit="phoenix-update-volumio.service"
if [ "$(id -u)" -eq 0 ]; then
/usr/bin/systemctl --no-block start "$unit"
return $?
fi
if ! command -v sudo >/dev/null 2>&1; then
echo "sudo not found; cannot start $unit as non-root" >&2
return 1
fi
sudo -n /usr/bin/systemctl --no-block start "$unit"
}
health_ok() {
if ! command -v curl >/dev/null 2>&1; then
return 0
@@ -144,7 +166,56 @@ wait_for_candidate_health() {
return 1
}
build_volumio_candidate() {
local phoenix_dir="${1:-$STAGE_DIR}"
local cargo_bin=""
local cargo_dir=""
local candidate
if ! needs_phoenix_build "$phoenix_dir"; then
echo "Volumio candidate already contains a runnable Phoenix binary"
return 0
fi
for candidate in /root/.cargo/bin/cargo /home/volumio/.cargo/bin/cargo /usr/local/bin/cargo /usr/bin/cargo; do
if [ -x "$candidate" ]; then
cargo_bin="$candidate"
break
fi
done
if [ -z "$cargo_bin" ]; then
echo "cargo not found; cannot build staged Volumio update" >&2
return 1
fi
cargo_dir="$(dirname "$cargo_bin")"
echo "building staged Volumio update before stopping the running kiosk"
if ! (
cd "$phoenix_dir"
PATH="$cargo_dir:/root/.cargo/bin:/home/volumio/.cargo/bin:/usr/local/bin:/usr/bin:/bin" \
CARGO_TERM_COLOR=never "$cargo_bin" build --release --locked
); then
if [ -d "$phoenix_dir/target" ]; then
chown -R volumio:volumio "$phoenix_dir/target" || true
fi
echo "staged Volumio build failed; running installation was left untouched" >&2
return 1
fi
chown -R volumio:volumio "$phoenix_dir/target"
if [ ! -x "$phoenix_dir/target/release/phoenix" ] || [ ! -s "$phoenix_dir/target/release/phoenix" ]; then
echo "staged Volumio build did not produce a runnable Phoenix binary" >&2
return 1
fi
echo "staged Volumio build completed"
}
start_all_services() {
if is_volumio_install; then
restart_if_present analyzer-web-volumio.service
clear_browser_asset_caches
restart_if_present analyzer-kiosk-volumio.service
return
fi
restart_if_present phoenix.service
restart_if_present analyzer-web.service
restart_if_present analyzer-web-volumio.service
@@ -154,6 +225,11 @@ start_all_services() {
}
stop_all_services() {
if is_volumio_install; then
stop_if_present analyzer-kiosk-volumio.service
stop_if_present analyzer-web-volumio.service
return
fi
stop_if_present analyzer-kiosk.service
stop_if_present analyzer-kiosk-volumio.service
stop_if_present analyzer-web.service
@@ -180,6 +256,16 @@ rollback_to_backup() {
}
if [ "${PHOENIX_UPDATE_DETACHED:-0}" != "1" ]; then
if is_volumio_install; then
echo "handing Volumio update to phoenix-update-volumio.service"
if start_volumio_update_service; then
echo "Volumio update service accepted"
exit 0
fi
echo "failed to start phoenix-update-volumio.service" >&2
echo "run scripts/install_volumio_services.sh once as root" >&2
exit 1
fi
if ! command -v systemd-run >/dev/null 2>&1; then
echo "systemd-run not found; cannot safely detach updater" >&2
exit 1
@@ -202,6 +288,10 @@ if [ ! -f "$STAGE_DIR/Cargo.toml" ] || [ ! -d "$STAGE_DIR/src" ] || [ ! -d "$STA
exit 1
fi
if is_volumio_install; then
build_volumio_candidate "$STAGE_DIR"
fi
stop_all_services
rm -rf "$BACKUP_DIR"
@@ -222,4 +312,11 @@ if ! wait_for_candidate_health "$PHOENIX_ROOT" "$health_attempts" "$health_delay
exit 1
fi
if is_volumio_install && [ "$(id -u)" -eq 0 ] && [ -f "$PHOENIX_ROOT/scripts/restart_phoenix_services.sh" ]; then
install -o root -g root -m 0755 \
"$PHOENIX_ROOT/scripts/restart_phoenix_services.sh" \
/usr/local/libexec/phoenix/restart_phoenix_services.sh
echo "Volumio privileged update helper refreshed"
fi
echo "=== $(date): restart_phoenix_services.sh OK ==="
+26
View File
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import { buildBandBinMapping, computeBandLevels } from '../www/core/utils.js';
const band = [{ center: 1000, fLo: 875, fHi: 1125 }];
for (const binCount of [2048, 4096, 8192, 16384]) {
const nyquist = 24000;
const binWidth = nyquist / binCount;
const mapping = buildBandBinMapping(band, nyquist, binCount);
const totalWeight = mapping[0].bins.reduce((sum, bin) => sum + bin.weight, 0);
assert.ok(
Math.abs(totalWeight * binWidth - 250) < 1e-6,
`bin overlaps must cover the complete band at FFT size ${binCount * 2}`,
);
// Constant power density: every bin contains density × bin width. The
// integrated band level must therefore be independent of FFT resolution.
const binPower = binWidth * 1e-6;
const binsDb = new Float32Array(binCount);
binsDb.fill(10 * Math.log10(binPower));
const level = computeBandLevels(mapping, binsDb)[0];
const expected = 10 * Math.log10(250e-6);
assert.ok(Math.abs(level - expected) < 1e-5, `FFT size ${binCount * 2}: ${level} vs ${expected}`);
}
console.log('FFT band-energy regression tests passed');
+29
View File
@@ -8,6 +8,10 @@ assert.ok(functionSource, 'persistence resolver must remain testable');
const context = vm.createContext({ Number, String, Math });
vm.runInContext(functionSource.replace('export function', 'function'), context);
const markerFunctionSource = source.match(/export function resolveCorrelationNegativeMarker[\s\S]*?\n\}/)?.[0];
assert.ok(markerFunctionSource, 'correlation marker resolver must remain testable');
vm.runInContext(`const CORR_HOLD_MS = 4000;\n${markerFunctionSource.replace('export function', 'function')}`, context);
assert.equal(context.resolveGoniometerPersistenceMs({ GONIO_PERSISTENCE_MODE: 'fast' }), 50);
assert.equal(context.resolveGoniometerPersistenceMs({ GONIO_PERSISTENCE_MODE: 'medium' }), 150);
assert.equal(context.resolveGoniometerPersistenceMs({ GONIO_PERSISTENCE_MODE: 'slow' }), 300);
@@ -28,4 +32,29 @@ assert.match(source, /pool\.pop\(\)/, 'trail buffers must be reused');
assert.doesNotMatch(source, /utils\.correlation\(/,
'browser must not recalculate or smooth backend correlation');
const markerState = {
corrNegativeMarkerMode: 'memory',
corrHoldPeak: 0,
corrHoldUntil: 0,
corrResetToken: 0,
};
assert.equal(context.resolveCorrelationNegativeMarker(
markerState, { CORR_NEGATIVE_MARKER_MODE: 'memory' }, -0.2, -0.8, 1000
), -0.8, 'memory mode must display the continuous DSP negative peak');
assert.equal(context.resolveCorrelationNegativeMarker(
markerState, { CORR_NEGATIVE_MARKER_MODE: 'off' }, -0.9, -0.9, 1100
), 0, 'off mode must hide the negative marker');
assert.equal(context.resolveCorrelationNegativeMarker(
markerState, { CORR_NEGATIVE_MARKER_MODE: 'hold' }, -0.4, -0.9, 2000
), -0.4, 'hold mode must start from the current correlation');
assert.equal(context.resolveCorrelationNegativeMarker(
markerState, { CORR_NEGATIVE_MARKER_MODE: 'hold' }, -0.2, -0.9, 5000
), -0.4, 'hold mode must retain its negative peak for four seconds');
assert.equal(context.resolveCorrelationNegativeMarker(
markerState, { CORR_NEGATIVE_MARKER_MODE: 'hold' }, -0.2, -0.9, 6000
), -0.2, 'hold mode must release to the current value after four seconds');
assert.equal(context.resolveCorrelationNegativeMarker(
markerState, { CORR_NEGATIVE_MARKER_MODE: 'hold', CORR_RESET_TOKEN: 1 }, 0.2, -0.9, 6100
), 0, 'reset must clear the four-second hold as well');
console.log('goniometer regression tests passed');
+13
View File
@@ -0,0 +1,13 @@
import fs from 'node:fs';
import assert from 'node:assert/strict';
const html = fs.readFileSync(new URL('../www/index.html', import.meta.url), 'utf8');
const main = fs.readFileSync(new URL('../www/main.js', import.meta.url), 'utf8');
const styleSelect = html.match(/<select\b[^>]*\bid="styleSel"[^>]*>/)?.[0] || '';
assert(styleSelect, 'style selector is missing');
assert(!/\bopt-w-\d+\b/.test(styleSelect), 'style selector must not have a fixed-width utility class');
assert(/adjustStyleDropdownWidth\(\)/.test(main), 'dynamic style selector sizing is missing');
assert(/styleSel\.style\.width\s*=/.test(main), 'dynamic style selector width assignment is missing');
console.log('GUI contract regression tests passed');
+62
View File
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const source = fs.readFileSync(new URL('../www/views/phase_wheel.js', import.meta.url), 'utf8');
const context = vm.createContext({ Number, Math });
const smoothingSource = source.match(/export function smoothingAlpha[\s\S]*?\n\}/)?.[0];
assert.ok(smoothingSource, 'time-based smoothing must remain testable');
vm.runInContext(smoothingSource.replace('export function', 'function'), context);
const oneFrame = context.smoothingAlpha(1 / 60, 0.2);
const twoFrames = 1 - Math.pow(1 - oneFrame, 2);
assert.ok(Math.abs(twoFrames - context.smoothingAlpha(1 / 30, 0.2)) < 1e-12,
'smoothing over equal real time must not depend on the display frame rate');
assert.doesNotMatch(source, /auto\.gain \* PHASE_AGC_BASE_GAIN/,
'AGC target gain must not be multiplied a second time');
const maxAngleStep = Number(
source.match(/PHASE_TRAIL_MAX_ANGLE_STEP_RAD\s*=\s*\(([\d.]+)\s*\*\s*Math\.PI\)\s*\/\s*180/)?.[1],
) * Math.PI / 180;
assert.ok(Number.isFinite(maxAngleStep) && maxAngleStep <= (2 * Math.PI) / 180,
'phase trail interpolation must limit angular steps to at most two degrees');
const interpolationSource = source.match(/function appendPhaseTrailSegment[\s\S]*?\n\}/)?.[0];
assert.ok(interpolationSource, 'phase trail must interpolate polar segments');
const trailContext = vm.createContext({ Number, Math });
const trailHelpers = [
source.match(/const PHASE_TRAIL_MAX_ANGLE_STEP_RAD\s*=.*?;/)?.[0],
source.match(/function radToDeg[\s\S]*?\n\}/)?.[0],
source.match(/function trailColorForAngle[\s\S]*?\n\}/)?.[0],
source.match(/function wrapAngle[\s\S]*?\n\}/)?.[0],
source.match(/function lerp[\s\S]*?\n\}/)?.[0],
source.match(/function createPhaseTrailPoint[\s\S]*?\n\}/)?.[0],
interpolationSource,
];
assert.ok(trailHelpers.every(Boolean), 'phase trail interpolation helpers must remain testable');
vm.runInContext(trailHelpers.join('\n'), trailContext);
const wheel = { cx: 0, cy: 0, radius: 100 };
const trail = [trailContext.createPhaseTrailPoint(wheel, -Math.PI / 2, 1, 0)];
trailContext.appendPhaseTrailSegment(trail, wheel, trail[0], Math.PI / 2, 1, 100);
assert.equal(trail.length, 91,
'a 180-degree phase change must be split into 90 two-degree segments');
for (let index = 1; index < trail.length; index++) {
const previousAngle = Math.atan2(trail[index - 1].y, trail[index - 1].x);
const currentAngle = Math.atan2(trail[index].y, trail[index].x);
const angularStep = Math.abs(Math.atan2(
Math.sin(currentAngle - previousAngle),
Math.cos(currentAngle - previousAngle),
));
assert.ok(angularStep <= maxAngleStep + 1e-12,
'interpolated trail segments must respect the maximum angular step');
assert.ok(Math.abs(Math.hypot(trail[index].x, trail[index].y) - wheel.radius) < 1e-9,
'constant-radius phase movement must remain on a circular arc');
}
assert.match(source, /PHASE_TRAIL_MAX_POINTS/,
'interpolated phase trail history must remain bounded');
console.log('phase wheel regression tests passed');
+65 -6
View File
@@ -1,14 +1,34 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
import { getRtwCenters } from '../www/core/rtw_centers.js';
import { buildRtwTickPositions, selectLocalRtwPacket } from '../www/views/realtime.js';
const source = fs.readFileSync(new URL('../www/core/audio.js', import.meta.url), 'utf8');
const configSource = fs.readFileSync(new URL('../www/core/config.js', import.meta.url), 'utf8');
const correlationNormalizerSource = configSource.match(
/export function normalizeCorrelationResponseSeconds[\s\S]*?\n\}/,
)?.[0];
assert.ok(correlationNormalizerSource, 'correlation response normalization must remain testable');
const correlationContext = vm.createContext({ Number });
vm.runInContext(
correlationNormalizerSource.replace('export function', 'function'),
correlationContext,
);
assert.equal(correlationContext.normalizeCorrelationResponseSeconds(0.5), 0.5);
assert.equal(correlationContext.normalizeCorrelationResponseSeconds(1.0), 1.0);
assert.equal(correlationContext.normalizeCorrelationResponseSeconds(2.5), 2.5);
assert.equal(correlationContext.normalizeCorrelationResponseSeconds(Number.NaN), 1.0);
const functionSource = source.match(/export function buildRtaRuntimeConfig[\s\S]*?\n\}/)?.[0];
assert.ok(functionSource, 'buildRtaRuntimeConfig must remain testable');
const context = vm.createContext({
Number,
normalizeCorrelationResponseSeconds: correlationContext.normalizeCorrelationResponseSeconds,
getRtwCenters(mode) {
return mode === '1_3' ? new Array(31).fill(0) : [];
if (mode === '1_12') return new Array(121).fill(0);
if (mode === '1_6') return new Array(61).fill(0);
return new Array(31).fill(0);
},
});
vm.runInContext(functionSource.replace('export function', 'function'), context);
@@ -19,19 +39,35 @@ const forced = context.buildRtaRuntimeConfig({
RTA_BPO_MODE: '1_12',
RTA_FREQ_RANGE: 'lf',
RTA_IIR_ORDER: 2,
RTA_DETECTOR: 'peak',
RTA_PEAK_HOLD_MODE: 'fall',
RTA_PEAK_HOLD_SEC: 4,
RTA_PEAK_DECAY_DB_PER_S: 20,
RTA_PEAK_RESET_TOKEN: 9,
CORR_RESPONSE_S: 2.5,
CORR_SILENCE_THRESHOLD_RMS_DBFS: -50,
CORR_RESET_TOKEN: 7,
XY_POINTS: 256,
});
assert.equal(forced.engine, 'iir');
assert.equal(forced.bpo, '1_3');
assert.equal(forced.bpo, '1_12');
assert.equal(forced.freqRange, 'norm');
assert.equal(forced.order, 6);
assert.equal(forced.rtwCenters.length, 31);
assert.equal(forced.detector, 'peak');
assert.equal(forced.rtaPeakHoldMode, 'fall');
assert.equal(forced.rtaPeakHoldSeconds, 4);
assert.equal(forced.rtaPeakDecayDbPerSecond, 20);
assert.equal(forced.rtaPeakResetToken, 9);
assert.equal(forced.tauFast, 0.125);
assert.equal(forced.rtwCenters.length, 121);
assert.equal(forced.correlationResponseS, 2.5);
assert.equal(forced.correlationSilenceThresholdRmsDbfs, -50);
assert.equal(forced.correlationResetToken, 7);
assert.equal(forced.xyPoints, 256);
const veryFastCorrelation = context.buildRtaRuntimeConfig({ CORR_RESPONSE_S: 0.5 });
assert.equal(veryFastCorrelation.correlationResponseS, 0.5);
const extension = context.buildRtaRuntimeConfig({
RTA_BAR_LAYOUT: 'iec',
RTA_ENGINE: 'fft',
@@ -44,7 +80,6 @@ assert.equal(extension.bpo, '1_12');
assert.equal(extension.freqRange, 'lf');
assert.equal(extension.order, 8);
const configSource = fs.readFileSync(new URL('../www/core/config.js', import.meta.url), 'utf8');
const selectionSource = configSource.match(/function applyRtaBpoSelection[\s\S]*?\n\}/)?.[0];
assert.ok(selectionSource, 'RTA BPO selection policy must remain testable');
const selectionContext = vm.createContext({
@@ -54,9 +89,33 @@ const selectionContext = vm.createContext({
vm.runInContext(selectionSource, selectionContext);
assert.equal(selectionContext.applyRtaBpoSelection('1_6'), '1_6');
assert.equal(selectionContext.CONFIG.RTA_BPO_MODE, '1_6');
assert.equal(selectionContext.CONFIG.RTA_BAR_LAYOUT, 'iec');
selectionContext.CONFIG.RTA_BAR_LAYOUT = 'rtw';
assert.equal(selectionContext.CONFIG.RTA_BAR_LAYOUT, 'rtw');
assert.equal(selectionContext.applyRtaBpoSelection('1_3'), '1_3');
assert.equal(selectionContext.CONFIG.RTA_BAR_LAYOUT, 'rtw');
const twelfthCenters = getRtwCenters('1_12');
const staleTwelfthPacket = {
engine: 'iir',
bpo: '1_12',
centers: twelfthCenters,
bands_avg: twelfthCenters.map((_, index) => index),
bands_peak: twelfthCenters.map((_, index) => index + 1000),
};
const selectedSixth = selectLocalRtwPacket(staleTwelfthPacket, '1_6');
assert.equal(selectedSixth.bpo, '1_6');
assert.equal(selectedSixth.centers.length, getRtwCenters('1_6').length);
assert.equal(selectedSixth.bands_avg.length, selectedSixth.centers.length);
assert.equal(selectedSixth.bands_peak.length, selectedSixth.centers.length);
for (const mode of ['1_3', '1_6', '1_12']) {
const centers = getRtwCenters(mode);
const ticks = [20, 31.5, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
const positions = buildRtwTickPositions(centers, ticks);
for (const tick of ticks) {
const index = centers.indexOf(tick);
assert.ok(index >= 0, `${mode} must contain the ${tick} Hz display band`);
assert.equal(positions[String(tick)], (index + 0.5) / centers.length);
}
}
console.log('RTA profile regression tests passed');
+7 -2
View File
@@ -19,10 +19,15 @@ const [metrics] = sockets;
let count = 0;
let lastSeq = 0;
let invalidPayload = false;
let maxFrameAgeMs = 0;
metrics.addEventListener('message', (event) => {
const frame = JSON.parse(String(event.data));
const seq = Number(frame.seq);
if (seq <= lastSeq || 'spectro' in frame || 'wave_env' in frame || 'xy_l' in frame || 'wave_l' in frame) {
const timestamp = Number(frame.timestamp_ms);
const age = Date.now() - timestamp;
maxFrameAgeMs = Math.max(maxFrameAgeMs, age);
if (seq <= lastSeq || !Number.isFinite(timestamp) || age < -100 || age > 500
|| 'spectro' in frame || 'wave_env' in frame || 'xy_l' in frame || 'wave_l' in frame) {
invalidPayload = true;
}
lastSeq = seq;
@@ -33,4 +38,4 @@ await new Promise((resolve) => setTimeout(resolve, 1100));
sockets.forEach((socket) => socket.close());
assert.equal(invalidPayload, false, 'metrics must be ordered and contain no large visual payloads');
assert.ok(count >= 50 && count <= 75, `expected about 60 metrics/s, received ${count}`);
console.log(`runtime websocket test passed (${count} metrics in 1.1 s; all three streams opened)`);
console.log(`runtime websocket test passed (${count} metrics in 1.1 s; max frame age ${maxFrameAgeMs} ms; all three streams opened)`);
+7 -1
View File
@@ -33,6 +33,8 @@ PHOENIX_SAMPLE_RATE="${PHOENIX_SAMPLE_RATE:-48000}"
PHOENIX_PERIOD_SIZE="${PHOENIX_PERIOD_SIZE:-128}"
PHOENIX_BUFFER_SIZE="${PHOENIX_BUFFER_SIZE:-512}"
PHOENIX_GLOBAL_CONFIG_PATH="${PHOENIX_GLOBAL_CONFIG_PATH:-/home/analyzer/.config/phoenix/global-config.json}"
PHOENIX_RTA_CONFIG_PATH="${PHOENIX_RTA_CONFIG_PATH:-/home/analyzer/.config/phoenix/rta-config.json}"
PHOENIX_KIOSK_BROWSER_CONFIG_PATH="${PHOENIX_KIOSK_BROWSER_CONFIG_PATH:-/home/analyzer/.config/phoenix/kiosk-browser-config.json}"
PHOENIX_RECORDINGS_DIR_ANALYZER="${PHOENIX_RECORDINGS_DIR_ANALYZER:-/home/analyzer/Aufnahmen}"
PHOENIX_RECORDINGS_DIR_VOLUMIO="${PHOENIX_RECORDINGS_DIR_VOLUMIO:-/home/volumio/Aufnahmen}"
PHOENIX_LR_FRAC_DELAY_ENABLED="${PHOENIX_LR_FRAC_DELAY_ENABLED:-true}"
@@ -83,6 +85,8 @@ else
PHOENIX_PERIOD_SIZE="$PHOENIX_PERIOD_SIZE" \
PHOENIX_BUFFER_SIZE="$PHOENIX_BUFFER_SIZE" \
PHOENIX_GLOBAL_CONFIG_PATH="$PHOENIX_GLOBAL_CONFIG_PATH" \
PHOENIX_RTA_CONFIG_PATH="$PHOENIX_RTA_CONFIG_PATH" \
PHOENIX_KIOSK_BROWSER_CONFIG_PATH="$PHOENIX_KIOSK_BROWSER_CONFIG_PATH" \
PHOENIX_RECORDINGS_DIR_ANALYZER="$PHOENIX_RECORDINGS_DIR_ANALYZER" \
PHOENIX_RECORDINGS_DIR_VOLUMIO="$PHOENIX_RECORDINGS_DIR_VOLUMIO" \
PHOENIX_LR_FRAC_DELAY_ENABLED="$PHOENIX_LR_FRAC_DELAY_ENABLED" \
@@ -97,6 +101,8 @@ else
PHOENIX_PERIOD_SIZE="$PHOENIX_PERIOD_SIZE" \
PHOENIX_BUFFER_SIZE="$PHOENIX_BUFFER_SIZE" \
PHOENIX_GLOBAL_CONFIG_PATH="$PHOENIX_GLOBAL_CONFIG_PATH" \
PHOENIX_RTA_CONFIG_PATH="$PHOENIX_RTA_CONFIG_PATH" \
PHOENIX_KIOSK_BROWSER_CONFIG_PATH="$PHOENIX_KIOSK_BROWSER_CONFIG_PATH" \
PHOENIX_RECORDINGS_DIR_ANALYZER="$PHOENIX_RECORDINGS_DIR_ANALYZER" \
PHOENIX_RECORDINGS_DIR_VOLUMIO="$PHOENIX_RECORDINGS_DIR_VOLUMIO" \
PHOENIX_LR_FRAC_DELAY_ENABLED="$PHOENIX_LR_FRAC_DELAY_ENABLED" \
@@ -158,7 +164,7 @@ sudo -u "$KIOSK_USER" /usr/bin/chromium \
--lang=de-DE --disable-translate --disable-features=Translate,TranslateUI \
--memory-pressure-off \
--max-active-webgl-contexts=1 \
http://localhost/index.html \
'http://localhost/index.html?kiosk=1' \
>>"$LOG" 2>&1
echo "=== $(date): session.sh END ==="
+11 -12
View File
@@ -24,6 +24,8 @@ PHOENIX_SAMPLE_RATE="${PHOENIX_SAMPLE_RATE:-48000}"
PHOENIX_PERIOD_SIZE="${PHOENIX_PERIOD_SIZE:-128}"
PHOENIX_BUFFER_SIZE="${PHOENIX_BUFFER_SIZE:-512}"
PHOENIX_GLOBAL_CONFIG_PATH="${PHOENIX_GLOBAL_CONFIG_PATH:-/home/volumio/.config/phoenix/global-config.json}"
PHOENIX_RTA_CONFIG_PATH="${PHOENIX_RTA_CONFIG_PATH:-/home/volumio/.config/phoenix/rta-config.json}"
PHOENIX_KIOSK_BROWSER_CONFIG_PATH="${PHOENIX_KIOSK_BROWSER_CONFIG_PATH:-/home/volumio/.config/phoenix/kiosk-browser-config.json}"
PHOENIX_RECORDINGS_DIR_ANALYZER="${PHOENIX_RECORDINGS_DIR_ANALYZER:-/home/analyzer/Aufnahmen}"
PHOENIX_RECORDINGS_DIR_VOLUMIO="${PHOENIX_RECORDINGS_DIR_VOLUMIO:-/home/volumio/Aufnahmen}"
PHOENIX_LR_FRAC_DELAY_ENABLED="${PHOENIX_LR_FRAC_DELAY_ENABLED:-true}"
@@ -32,7 +34,7 @@ PHOENIX_LR_FRAC_DELAY_SAMPLES="${PHOENIX_LR_FRAC_DELAY_SAMPLES:-0.996}"
CHROME_BIN="${CHROME_BIN:-/usr/bin/chromium-browser}"
EXT_VK="${EXT_VK:-/data/volumiokioskextensions/VirtualKeyboard}"
EXT_TOGGLE="${EXT_TOGGLE:-/opt/analyzer/toggle-ext}"
ANALYZER_URL="${ANALYZER_URL:-http://localhost:8088/index.html}"
ANALYZER_URL="${ANALYZER_URL:-http://localhost:8088/index.html?kiosk=1}"
VOLUMIO_URL="${VOLUMIO_URL:-http://localhost:3000}"
mkdir -p "$(dirname "$LOG")"
@@ -96,6 +98,8 @@ else
PHOENIX_PERIOD_SIZE="$PHOENIX_PERIOD_SIZE" \
PHOENIX_BUFFER_SIZE="$PHOENIX_BUFFER_SIZE" \
PHOENIX_GLOBAL_CONFIG_PATH="$PHOENIX_GLOBAL_CONFIG_PATH" \
PHOENIX_RTA_CONFIG_PATH="$PHOENIX_RTA_CONFIG_PATH" \
PHOENIX_KIOSK_BROWSER_CONFIG_PATH="$PHOENIX_KIOSK_BROWSER_CONFIG_PATH" \
PHOENIX_RECORDINGS_DIR_ANALYZER="$PHOENIX_RECORDINGS_DIR_ANALYZER" \
PHOENIX_RECORDINGS_DIR_VOLUMIO="$PHOENIX_RECORDINGS_DIR_VOLUMIO" \
PHOENIX_LR_FRAC_DELAY_ENABLED="$PHOENIX_LR_FRAC_DELAY_ENABLED" \
@@ -112,6 +116,8 @@ else
PHOENIX_PERIOD_SIZE="$PHOENIX_PERIOD_SIZE" \
PHOENIX_BUFFER_SIZE="$PHOENIX_BUFFER_SIZE" \
PHOENIX_GLOBAL_CONFIG_PATH="$PHOENIX_GLOBAL_CONFIG_PATH" \
PHOENIX_RTA_CONFIG_PATH="$PHOENIX_RTA_CONFIG_PATH" \
PHOENIX_KIOSK_BROWSER_CONFIG_PATH="$PHOENIX_KIOSK_BROWSER_CONFIG_PATH" \
PHOENIX_RECORDINGS_DIR_ANALYZER="$PHOENIX_RECORDINGS_DIR_ANALYZER" \
PHOENIX_RECORDINGS_DIR_VOLUMIO="$PHOENIX_RECORDINGS_DIR_VOLUMIO" \
PHOENIX_LR_FRAC_DELAY_ENABLED="$PHOENIX_LR_FRAC_DELAY_ENABLED" \
@@ -159,10 +165,6 @@ else
chmod 700 "$CHROME_USER_DATA_DIR"
fi
pkill -9 -x chromium || true
pkill -9 -f "chromium.*--kiosk" || true
sleep 0.3
EXTENSIONS=()
EXT_VK_RESOLVED="$(resolve_extension_dir "$EXT_VK" \
"$PHOENIX_DIR/VirtualKeyboard" \
@@ -210,21 +212,18 @@ CHROME_CMD=(
--disable-features=Translate,TranslateUI
--memory-pressure-off
--max-active-webgl-contexts=1
"$ANALYZER_URL"
"$VOLUMIO_URL"
)
if [ -n "$EXTENSION_ARG" ]; then
CHROME_CMD+=(--load-extension="$EXTENSION_ARG")
fi
# Start with the lightweight Analyzer page. The tab-toggle extension creates
# exactly one inactive Volumio tab after Chromium is ready.
CHROME_CMD+=("$ANALYZER_URL")
sudo -u "$KIOSK_USER" "${CHROME_CMD[@]}" >>"$LOG" 2>&1 &
CHROME_PID=$!
sleep 2
if command -v xdotool >/dev/null 2>&1; then
xdotool key Ctrl+t type "$VOLUMIO_URL" key Return || true
fi
wait "$CHROME_PID"
echo "=== $(date): session_volumio.sh END ==="
+538 -335
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -14,8 +14,10 @@ pub struct PhoenixConfig {
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub buffer_size: u32,
pub global_config_path: PathBuf,
pub rta_config_path: PathBuf,
pub frontend_presets_path: PathBuf,
pub frontend_layouts_path: PathBuf,
pub kiosk_browser_config_path: PathBuf,
pub recordings_dir_analyzer: PathBuf,
pub recordings_dir_volumio: PathBuf,
pub max_recording_upload_bytes: u64,
@@ -53,6 +55,11 @@ impl PhoenixConfig {
.map(|v| PathBuf::from(v.trim()))
.filter(|v| !v.as_os_str().is_empty())
.unwrap_or_else(default_global_config_path),
rta_config_path: std::env::var("PHOENIX_RTA_CONFIG_PATH")
.ok()
.map(|v| PathBuf::from(v.trim()))
.filter(|v| !v.as_os_str().is_empty())
.unwrap_or_else(default_rta_config_path),
frontend_presets_path: std::env::var("PHOENIX_FRONTEND_PRESETS_PATH")
.ok()
.map(|v| PathBuf::from(v.trim()))
@@ -63,6 +70,11 @@ impl PhoenixConfig {
.map(|v| PathBuf::from(v.trim()))
.filter(|v| !v.as_os_str().is_empty())
.unwrap_or_else(default_frontend_layouts_path),
kiosk_browser_config_path: std::env::var("PHOENIX_KIOSK_BROWSER_CONFIG_PATH")
.ok()
.map(|v| PathBuf::from(v.trim()))
.filter(|v| !v.as_os_str().is_empty())
.unwrap_or_else(default_kiosk_browser_config_path),
recordings_dir_analyzer: std::env::var("PHOENIX_RECORDINGS_DIR_ANALYZER")
.ok()
.map(|v| PathBuf::from(v.trim()))
@@ -128,6 +140,17 @@ fn default_global_config_path() -> PathBuf {
PathBuf::from("phoenix-global-config.json")
}
fn default_rta_config_path() -> PathBuf {
if let Some(home) = std::env::var_os("HOME") {
let mut path = PathBuf::from(home);
path.push(".config");
path.push("phoenix");
path.push("rta-config.json");
return path;
}
PathBuf::from("phoenix-rta-config.json")
}
fn default_frontend_presets_path() -> PathBuf {
if let Some(home) = std::env::var_os("HOME") {
let mut path = PathBuf::from(home);
@@ -150,6 +173,17 @@ fn default_frontend_layouts_path() -> PathBuf {
PathBuf::from("phoenix-frontend-layouts.json")
}
fn default_kiosk_browser_config_path() -> PathBuf {
if let Some(home) = std::env::var_os("HOME") {
let mut path = PathBuf::from(home);
path.push(".config");
path.push("phoenix");
path.push("kiosk-browser-config.json");
return path;
}
PathBuf::from("phoenix-kiosk-browser-config.json")
}
fn default_recordings_dir_analyzer() -> PathBuf {
PathBuf::from("/home/analyzer/Aufnahmen")
}
+135 -14
View File
@@ -9,39 +9,73 @@ pub struct CorrelationMeter {
sample_rate: u32,
response_seconds: f32,
alpha: f64,
gate_alpha: f64,
silence_threshold_db: f32,
silence_threshold_power: f64,
power_l: f64,
power_r: f64,
cross_power: f64,
gate_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 {
pub fn new(
sample_rate: u32,
response_seconds: f32,
silence_threshold_db: f32,
reset_token: u64,
) -> Self {
let mut meter = Self {
sample_rate: 0,
response_seconds: 0.0,
alpha: 1.0,
gate_alpha: 1.0,
silence_threshold_db: -75.0,
silence_threshold_power: 10.0_f64.powf(-7.5),
power_l: 0.0,
power_r: 0.0,
cross_power: 0.0,
gate_power: 0.0,
value: 0.0,
negative_peak: 0.0,
reset_token,
};
meter.configure(sample_rate, response_seconds, reset_token);
meter.configure(
sample_rate,
response_seconds,
silence_threshold_db,
reset_token,
);
meter
}
pub fn configure(&mut self, sample_rate: u32, response_seconds: f32, reset_token: u64) {
pub fn configure(
&mut self,
sample_rate: u32,
response_seconds: f32,
silence_threshold_db: f32,
reset_token: u64,
) {
let sample_rate = sample_rate.max(8_000);
let response_seconds = normalize_response_seconds(response_seconds);
let silence_threshold_db = normalize_silence_threshold_db(silence_threshold_db);
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();
// The silence decision must react independently of the selected
// correlation response time. A 50 ms RMS envelope avoids both
// sample-zero chatter and multi-second threshold lag.
self.gate_alpha = 1.0 - (-1.0 / (f64::from(sample_rate) * 0.05)).exp();
}
if self.silence_threshold_db != silence_threshold_db {
self.silence_threshold_db = silence_threshold_db;
self.silence_threshold_power =
10.0_f64.powf(f64::from(silence_threshold_db) / 10.0);
}
if self.reset_token != reset_token {
self.reset_token = reset_token;
@@ -55,12 +89,18 @@ impl CorrelationMeter {
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);
self.gate_power += self.gate_alpha
* (0.5 * (l * l + r * r) - self.gate_power);
// Below roughly -100 dBFS RMS per channel the quotient is no longer a
// useful phase measurement and should settle at the neutral position.
// Below the configured mono RMS threshold the correlation indication
// settles at its 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.value = if self.gate_power >= self.silence_threshold_power
&& 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
@@ -80,10 +120,22 @@ impl CorrelationMeter {
}
pub fn normalize_response_seconds(value: f32) -> f32 {
if value.is_finite() && value >= 1.75 {
2.5
} else {
if !value.is_finite() || value <= 0.0 {
1.0
} else if value < 0.75 {
0.5
} else if value < 1.75 {
1.0
} else {
2.5
}
}
pub fn normalize_silence_threshold_db(value: f32) -> f32 {
if value.is_finite() {
value.clamp(-90.0, -40.0)
} else {
-75.0
}
}
@@ -111,7 +163,7 @@ mod tests {
(std::f32::consts::PI, -1.0),
(std::f32::consts::FRAC_PI_2, 0.0),
] {
let mut meter = CorrelationMeter::new(sample_rate, 1.0, 0);
let mut meter = CorrelationMeter::new(sample_rate, 1.0, -75.0, 0);
run_signal(&mut meter, 5, |index| {
let angle = phase_step * index as f32;
(angle.sin(), (angle + phase).sin())
@@ -127,8 +179,8 @@ mod tests {
#[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);
let mut fast = CorrelationMeter::new(sample_rate, 1.0, -75.0, 0);
let mut slow = CorrelationMeter::new(sample_rate, 2.5, -75.0, 0);
for index in 0..sample_rate as usize {
let sample = if index & 1 == 0 { 0.5 } else { -0.5 };
fast.process(sample, sample);
@@ -141,13 +193,82 @@ mod tests {
(sample, -sample)
});
assert!(fast.negative_peak() < -0.7);
fast.configure(sample_rate, 1.0, 1);
fast.configure(sample_rate, 1.0, -75.0, 1);
assert_eq!(fast.negative_peak(), 0.0);
}
#[test]
fn fast_response_tracks_a_phase_reversal_before_slow_response() {
let sample_rate = 48_000;
let mut very_fast = CorrelationMeter::new(sample_rate, 0.5, -75.0, 0);
let mut fast = CorrelationMeter::new(sample_rate, 1.0, -75.0, 0);
let mut slow = CorrelationMeter::new(sample_rate, 2.5, -75.0, 0);
for index in 0..sample_rate as usize * 5 {
let sample = if index & 1 == 0 { 0.5 } else { -0.5 };
very_fast.process(sample, sample);
fast.process(sample, sample);
slow.process(sample, sample);
}
for index in 0..sample_rate as usize {
let sample = if index & 1 == 0 { 0.5 } else { -0.5 };
very_fast.process(sample, -sample);
fast.process(sample, -sample);
slow.process(sample, -sample);
}
assert!(very_fast.value() < fast.value() - 0.35);
assert!(fast.value() < slow.value() - 0.35);
assert!(very_fast.negative_peak() < fast.negative_peak());
assert!(fast.negative_peak() < 0.0);
assert_eq!(slow.negative_peak(), 0.0);
}
#[test]
fn response_time_normalization_accepts_all_three_profiles() {
assert_eq!(normalize_response_seconds(0.5), 0.5);
assert_eq!(normalize_response_seconds(1.0), 1.0);
assert_eq!(normalize_response_seconds(2.5), 2.5);
assert_eq!(normalize_response_seconds(f32::NAN), 1.0);
assert_eq!(normalize_response_seconds(0.0), 1.0);
}
#[test]
fn configurable_silence_threshold_neutralizes_the_meter() {
let sample_rate = 48_000;
let amplitude = 10.0_f32.powf(-64.0 / 20.0);
let mut meter = CorrelationMeter::new(sample_rate, 0.5, -75.0, 0);
run_signal(&mut meter, 1, |index| {
let sample = if index & 1 == 0 {
amplitude
} else {
-amplitude
};
(sample, sample)
});
assert!(meter.value() > 0.99);
meter.configure(sample_rate, 0.5, -50.0, 0);
run_signal(&mut meter, 1, |index| {
let sample = if index & 1 == 0 {
amplitude
} else {
-amplitude
};
(sample, sample)
});
assert_eq!(meter.value(), 0.0);
}
#[test]
fn silence_threshold_is_normalized_to_the_ui_range() {
assert_eq!(normalize_silence_threshold_db(-50.0), -50.0);
assert_eq!(normalize_silence_threshold_db(-100.0), -90.0);
assert_eq!(normalize_silence_threshold_db(-20.0), -40.0);
assert_eq!(normalize_silence_threshold_db(f32::NAN), -75.0);
}
#[test]
fn silence_and_single_channel_are_neutral() {
let mut meter = CorrelationMeter::new(48_000, 1.0, 0);
let mut meter = CorrelationMeter::new(48_000, 1.0, -75.0, 0);
run_signal(&mut meter, 2, |_| (0.0, 0.0));
assert_eq!(meter.value(), 0.0);
assert_eq!(meter.negative_peak(), 0.0);
+12
View File
@@ -3,10 +3,14 @@ mod config;
mod correlation;
mod goniometer;
mod model;
mod phase_wheel;
mod ppm;
mod rms;
mod routes;
mod rta;
mod state;
mod true_peak;
mod vu;
use std::net::SocketAddr;
@@ -49,6 +53,14 @@ async fn main() -> anyhow::Result<()> {
"/api/v1/frontend-layouts",
get(routes::get_frontend_layouts).post(routes::set_frontend_layout),
)
.route(
"/api/v1/device-backup",
get(routes::export_device_backup).post(routes::restore_device_backup),
)
.route(
"/api/v1/kiosk-browser-config",
get(routes::get_kiosk_browser_config).post(routes::set_kiosk_browser_config),
)
.route(
"/api/v1/update/download",
post(routes::download_online_update),
+27 -5
View File
@@ -19,6 +19,9 @@ pub struct ServiceStatus {
#[derive(Clone, Debug, Serialize)]
pub struct RtaFrame {
pub engine: String,
pub filterbank: String,
pub detector: String,
pub response: String,
pub bands_avg: Vec<f32>,
pub bands_peak: Vec<f32>,
pub centers: Vec<f32>,
@@ -51,6 +54,8 @@ pub struct WaveEnvFrame {
#[serde(default, rename_all = "camelCase")]
pub struct PhoenixRtaConfig {
pub engine: String,
pub filterbank: String,
pub detector: String,
pub fft_size: u32,
pub mono_input: bool,
pub lr_fractional_delay_enabled: bool,
@@ -63,16 +68,21 @@ pub struct PhoenixRtaConfig {
pub tau_slow: f32,
pub integration: String,
pub layout: String,
pub rta_peak_hold_mode: String,
pub rta_peak_hold_seconds: f32,
pub rta_peak_decay_db_per_second: f32,
pub rta_peak_reset_token: u64,
pub input_offset_db_l: f32,
pub input_offset_db_r: f32,
pub ppm_din_attack_ms: f32,
pub ppm_din_decay_db_per_s: f32,
pub ppm_din_fast_attack: bool,
pub ppm_ebu_attack_ms: f32,
pub ppm_ebu_decay_db_per_s: f32,
pub rms_integration: String,
pub lufs_i_window_min: u32,
pub lufs_i_norm_enabled: bool,
pub correlation_response_s: f32,
pub correlation_silence_threshold_rms_dbfs: f32,
pub correlation_reset_token: u64,
pub xy_points: u32,
}
@@ -81,6 +91,8 @@ impl Default for PhoenixRtaConfig {
fn default() -> Self {
Self {
engine: "iir".to_string(),
filterbank: "legacy".to_string(),
detector: "average".to_string(),
fft_size: 8192,
mono_input: false,
lr_fractional_delay_enabled: true,
@@ -89,20 +101,25 @@ impl Default for PhoenixRtaConfig {
freq_range: "norm".to_string(),
weighting: "z".to_string(),
order: 6,
tau_fast: 0.12,
tau_fast: 0.125,
tau_slow: 1.0,
integration: "fast".to_string(),
layout: "rtw".to_string(),
rta_peak_hold_mode: "fall".to_string(),
rta_peak_hold_seconds: 2.5,
rta_peak_decay_db_per_second: 20.0,
rta_peak_reset_token: 0,
input_offset_db_l: -5.0,
input_offset_db_r: -5.0,
ppm_din_attack_ms: 10.0,
ppm_din_decay_db_per_s: 20.0 / 1.5,
ppm_din_fast_attack: false,
ppm_ebu_attack_ms: 10.0,
ppm_ebu_decay_db_per_s: 24.0 / 2.8,
rms_integration: "fast".to_string(),
lufs_i_window_min: 4,
lufs_i_norm_enabled: false,
correlation_response_s: 1.0,
correlation_silence_threshold_rms_dbfs: -75.0,
correlation_reset_token: 0,
xy_points: 1024,
}
@@ -125,9 +142,9 @@ pub struct PhoenixGlobalConfig {
pub panel_dividers_enabled: bool,
pub ppm_din_attack_ms: f32,
pub ppm_din_decay_db_per_s: f32,
pub ppm_din_fast_attack: bool,
pub ppm_ebu_attack_ms: f32,
pub ppm_ebu_decay_db_per_s: f32,
pub rms_integration: String,
pub lufs_i_window_min: u32,
pub lufs_i_norm_enabled: bool,
pub ppm_din_loudness_boxes: bool,
@@ -194,9 +211,9 @@ impl Default for PhoenixGlobalConfig {
panel_dividers_enabled: true,
ppm_din_attack_ms: 10.0,
ppm_din_decay_db_per_s: 20.0 / 1.5,
ppm_din_fast_attack: false,
ppm_ebu_attack_ms: 10.0,
ppm_ebu_decay_db_per_s: 24.0 / 2.8,
rms_integration: "fast".to_string(),
lufs_i_window_min: 4,
lufs_i_norm_enabled: false,
ppm_din_loudness_boxes: true,
@@ -264,6 +281,11 @@ pub struct MeterFrame {
pub period_size: u32,
pub correlation: f32,
pub correlation_negative_peak: f32,
#[serde(skip_serializing_if = "Option::is_none")]
pub phase_angle_rad: Option<f32>,
pub phase_coherence: f32,
pub phase_level: f32,
pub phase_peak: f32,
pub rms_l: f32,
pub rms_r: f32,
pub vu_l: f32,
+264
View File
@@ -0,0 +1,264 @@
//! Continuous phase-wheel analysis on the native-rate audio stream.
const HILBERT_TAPS: usize = 33;
const HILBERT_HALF: usize = (HILBERT_TAPS - 1) / 2;
const BANDPASS_LOW_HZ: f64 = 300.0;
const BANDPASS_HIGH_HZ: f64 = 5_000.0;
#[derive(Clone, Copy, Debug, Default)]
pub struct PhaseWheelSnapshot {
pub angle_rad: Option<f32>,
pub coherence: f32,
pub level: f32,
pub peak: f32,
}
#[derive(Clone, Copy, Debug, Default)]
struct BandpassChannel {
hp_x: f64,
hp_y: f64,
lp_y: f64,
}
impl BandpassChannel {
fn process(&mut self, sample: f64, hp_alpha: f64, lp_alpha: f64) -> f64 {
let hp = hp_alpha * (self.hp_y + sample - self.hp_x);
self.hp_x = sample;
self.hp_y = hp;
self.lp_y = lp_alpha * hp + (1.0 - lp_alpha) * self.lp_y;
self.lp_y
}
}
pub struct PhaseWheelAnalyzer {
sample_rate: u32,
hp_alpha: f64,
lp_alpha: f64,
band_l: BandpassChannel,
band_r: BandpassChannel,
ring_l: [f64; HILBERT_TAPS],
ring_r: [f64; HILBERT_TAPS],
hilbert: [f64; HILBERT_TAPS],
write: usize,
fill: usize,
cross_re: f64,
cross_im: f64,
weight_sum: f64,
amplitude_sum: f64,
amplitude_peak: f64,
count: usize,
}
impl PhaseWheelAnalyzer {
pub fn new(sample_rate: u32) -> Self {
let mut analyzer = Self {
sample_rate: 0,
hp_alpha: 0.0,
lp_alpha: 0.0,
band_l: BandpassChannel::default(),
band_r: BandpassChannel::default(),
ring_l: [0.0; HILBERT_TAPS],
ring_r: [0.0; HILBERT_TAPS],
hilbert: build_hilbert_kernel(),
write: 0,
fill: 0,
cross_re: 0.0,
cross_im: 0.0,
weight_sum: 0.0,
amplitude_sum: 0.0,
amplitude_peak: 0.0,
count: 0,
};
analyzer.configure(sample_rate);
analyzer
}
pub fn configure(&mut self, sample_rate: u32) {
let rate = sample_rate.max(8_000);
if self.sample_rate == rate {
return;
}
self.sample_rate = rate;
self.hp_alpha = highpass_alpha(rate, BANDPASS_LOW_HZ);
self.lp_alpha = lowpass_alpha(rate, BANDPASS_HIGH_HZ);
self.band_l = BandpassChannel::default();
self.band_r = BandpassChannel::default();
self.ring_l.fill(0.0);
self.ring_r.fill(0.0);
self.write = 0;
self.fill = 0;
self.clear_accumulator();
}
pub fn process(&mut self, left: f32, right: f32) {
let filtered_l = self
.band_l
.process(left as f64, self.hp_alpha, self.lp_alpha);
let filtered_r = self
.band_r
.process(right as f64, self.hp_alpha, self.lp_alpha);
self.ring_l[self.write] = filtered_l;
self.ring_r[self.write] = filtered_r;
self.write = (self.write + 1) % HILBERT_TAPS;
self.fill = (self.fill + 1).min(HILBERT_TAPS);
if self.fill < HILBERT_TAPS {
return;
}
// `write` points at the oldest sample. The real component is delayed
// by half the FIR length, so it is aligned with the causal Hilbert FIR.
let real_index = (self.write + HILBERT_HALF) % HILBERT_TAPS;
let l_re = self.ring_l[real_index].clamp(-1.0, 1.0);
let r_re = self.ring_r[real_index].clamp(-1.0, 1.0);
let mut l_im = 0.0;
let mut r_im = 0.0;
// The ideal odd Hilbert kernel has zero coefficients at every even
// offset; with a 33-tap kernel those are the even tap indices.
for tap in (1..HILBERT_TAPS).step_by(2) {
let index = (self.write + tap) % HILBERT_TAPS;
l_im += self.ring_l[index] * self.hilbert[tap];
r_im += self.ring_r[index] * self.hilbert[tap];
}
let mag_l = l_re.hypot(l_im).min(1.0);
let mag_r = r_re.hypot(r_im).min(1.0);
let weight = mag_l * mag_r;
// zL * conj(zR): its argument is the energy-weighted L/R phase.
self.cross_re += l_re * r_re + l_im * r_im;
self.cross_im += l_im * r_re - l_re * r_im;
self.weight_sum += weight;
let amplitude = 0.5 * (mag_l + mag_r);
self.amplitude_sum += amplitude;
self.amplitude_peak = self.amplitude_peak.max(amplitude);
self.count += 1;
}
pub fn take_snapshot(&mut self) -> PhaseWheelSnapshot {
let level = if self.count > 0 {
(self.amplitude_sum / self.count as f64) as f32
} else {
0.0
};
let resultant = self.cross_re.hypot(self.cross_im);
let angle_rad = if self.weight_sum > 1e-12 && resultant > self.weight_sum * 1e-9 {
Some(self.cross_im.atan2(self.cross_re) as f32)
} else {
None
};
let coherence = if self.weight_sum > 1e-12 {
(resultant / self.weight_sum).clamp(0.0, 1.0) as f32
} else {
0.0
};
let snapshot = PhaseWheelSnapshot {
angle_rad,
coherence,
level,
peak: self.amplitude_peak as f32,
};
self.clear_accumulator();
snapshot
}
fn clear_accumulator(&mut self) {
self.cross_re = 0.0;
self.cross_im = 0.0;
self.weight_sum = 0.0;
self.amplitude_sum = 0.0;
self.amplitude_peak = 0.0;
self.count = 0;
}
}
fn highpass_alpha(sample_rate: u32, cutoff: f64) -> f64 {
let rc = 1.0 / (2.0 * std::f64::consts::PI * cutoff.max(1.0));
let dt = 1.0 / sample_rate.max(1) as f64;
(rc / (rc + dt)).clamp(0.0, 1.0)
}
fn lowpass_alpha(sample_rate: u32, cutoff: f64) -> f64 {
let rc = 1.0 / (2.0 * std::f64::consts::PI * cutoff.max(1.0));
let dt = 1.0 / sample_rate.max(1) as f64;
(dt / (rc + dt)).clamp(0.0, 1.0)
}
fn build_hilbert_kernel() -> [f64; HILBERT_TAPS] {
let mut kernel = [0.0; HILBERT_TAPS];
for (index, value) in kernel.iter_mut().enumerate() {
let offset = index as isize - HILBERT_HALF as isize;
if offset == 0 || offset % 2 == 0 {
continue;
}
let window = 0.54
- 0.46
* ((2.0 * std::f64::consts::PI * index as f64) / (HILBERT_TAPS - 1) as f64).cos();
*value = 2.0 / (std::f64::consts::PI * offset as f64) * window;
}
kernel
}
#[cfg(test)]
mod tests {
use super::*;
fn feed_tone(analyzer: &mut PhaseWheelAnalyzer, phase: f64, samples: usize) {
let omega = 2.0 * std::f64::consts::PI * 1_000.0 / 48_000.0;
for index in 0..samples {
let t = omega * index as f64;
analyzer.process((0.5 * t.sin()) as f32, (0.5 * (t - phase).sin()) as f32);
}
}
#[test]
fn continuous_analyzer_tracks_tone_phase() {
let mut analyzer = PhaseWheelAnalyzer::new(48_000);
feed_tone(&mut analyzer, std::f64::consts::FRAC_PI_2, 4_800);
let snapshot = analyzer.take_snapshot();
let angle = snapshot.angle_rad.expect("coherent tone has a phase");
assert!((angle.abs() - std::f32::consts::FRAC_PI_2).abs() < 0.03);
assert!(
snapshot.coherence > 0.9,
"coherence was {}",
snapshot.coherence
);
assert!(snapshot.level > 0.1);
assert!(snapshot.peak >= snapshot.level);
}
#[test]
fn snapshot_reset_does_not_reset_filter_or_hilbert_history() {
let mut analyzer = PhaseWheelAnalyzer::new(48_000);
feed_tone(&mut analyzer, 0.4, 2_400);
let first = analyzer.take_snapshot().angle_rad.unwrap();
feed_tone(&mut analyzer, 0.4, 800);
let second = analyzer.take_snapshot().angle_rad.unwrap();
assert!((first - second).abs() < 0.03);
}
#[test]
fn one_sided_signal_does_not_invent_a_phase() {
let mut analyzer = PhaseWheelAnalyzer::new(48_000);
for index in 0..2_400 {
let t = 2.0 * std::f64::consts::PI * 1_000.0 * index as f64 / 48_000.0;
analyzer.process((0.5 * t.sin()) as f32, 0.0);
}
let snapshot = analyzer.take_snapshot();
assert!(snapshot.angle_rad.is_none());
assert_eq!(snapshot.coherence, 0.0);
}
#[test]
fn energetic_component_dominates_a_quiet_conflicting_tone() {
let mut analyzer = PhaseWheelAnalyzer::new(48_000);
for index in 0..9_600 {
let t = index as f64 / 48_000.0;
let strong = 2.0 * std::f64::consts::PI * 1_000.0 * t;
let quiet = 2.0 * std::f64::consts::PI * 2_000.0 * t;
let left = 0.5 * strong.sin() + 0.04 * quiet.sin();
let right = 0.5 * strong.sin() + 0.04 * (quiet - std::f64::consts::FRAC_PI_2).sin();
analyzer.process(left as f32, right as f32);
}
let angle = analyzer.take_snapshot().angle_rad.unwrap();
assert!(angle.abs() < 0.03, "quiet tone pulled phase to {angle}");
}
}
+23 -45
View File
@@ -2,9 +2,10 @@
//!
//! The detector is deliberately independent of ALSA block boundaries. Its two
//! attack branches are calibrated against the 5 kHz tone-burst response in EBU
//! Tech 3205-E. DIN uses the RTW PortaMonitor/Peakmeter norm profile (10 ms
//! integration, 20 dB return in 1.5 s). The optional DIN sample mode is kept
//! separate and must never be labelled as a standards-compliant DIN reading.
//! Tech 3205-E. DIN uses the DIN 45406 / IEC 60268-10 profile (10 ms
//! integration, 20 dB return in 1.5 s). Both standards define the normal
//! quasi-peak attack by the 5 kHz, 10 ms burst reaching 2 dB below the
//! continuous-tone indication; their normal-mode return times differ.
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
@@ -19,21 +20,16 @@ const INTERP_TAPS: usize = INTERP_RADIUS * 2 + 1;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PpmStandard {
Din,
DinSample,
EbuTypeIib,
}
impl PpmStandard {
fn return_db_per_second(self) -> f32 {
match self {
Self::Din | Self::DinSample => 20.0 / 1.5,
Self::Din => 20.0 / 1.5,
Self::EbuTypeIib => 24.0 / 2.8,
}
}
fn uses_sample_attack(self) -> bool {
matches!(self, Self::DinSample)
}
}
#[derive(Clone, Copy, Debug, Default)]
@@ -61,11 +57,7 @@ pub struct PpmDetector {
impl PpmDetector {
pub fn new(sample_rate: u32, standard: PpmStandard) -> Self {
let sr = sample_rate.max(8_000);
let detector_rate = if standard.uses_sample_attack() {
sr as f32
} else {
(sr * OVERSAMPLE as u32) as f32
};
let detector_rate = (sr * OVERSAMPLE as u32) as f32;
let coeff = |tau_s: f32| (-1.0 / (detector_rate * tau_s)).exp();
let release_coeff = 10.0f32.powf(-standard.return_db_per_second() / (20.0 * detector_rate));
Self {
@@ -91,31 +83,10 @@ impl PpmDetector {
}
pub fn process(&mut self, left: f32, right: f32) -> (f32, f32) {
if self.standard.uses_sample_attack() {
let l = Self::process_channel(
&mut self.left,
left.abs(),
self.standard,
self.fast_coeff,
self.slow_coeff,
self.release_coeff,
);
let r = Self::process_channel(
&mut self.right,
right.abs(),
self.standard,
self.fast_coeff,
self.slow_coeff,
self.release_coeff,
);
return (l, r);
}
self.raw_l[self.raw_pos] = left;
self.raw_r[self.raw_pos] = right;
self.raw_pos = (self.raw_pos + 1) % INTERP_TAPS;
let standard = self.standard;
let fast_coeff = self.fast_coeff;
let slow_coeff = self.slow_coeff;
let release_coeff = self.release_coeff;
@@ -131,7 +102,6 @@ impl PpmDetector {
Self::process_channel(
&mut self.left,
l.abs(),
standard,
fast_coeff,
slow_coeff,
release_coeff,
@@ -139,7 +109,6 @@ impl PpmDetector {
Self::process_channel(
&mut self.right,
r.abs(),
standard,
fast_coeff,
slow_coeff,
release_coeff,
@@ -183,18 +152,10 @@ impl PpmDetector {
fn process_channel(
state: &mut PpmChannel,
input: f32,
standard: PpmStandard,
fast_coeff: f32,
slow_coeff: f32,
release_coeff: f32,
) -> f32 {
if standard.uses_sample_attack() {
state.output = input.max(state.output * release_coeff);
state.fast = state.output;
state.slow = state.output;
return state.output;
}
state.fast = if input > state.fast {
fast_coeff * state.fast + (1.0 - fast_coeff) * input
} else {
@@ -273,6 +234,23 @@ mod tests {
}
}
#[test]
fn din_matches_normative_ten_millisecond_tone_burst() {
// DIN 45406 / IEC 60268-10 integration time: a 5 kHz burst at
// reference level must indicate 2 dB below the continuous-tone value
// after 10 ms. RTW specifies the same 10 ms normal integration for
// its DIN peakmeters.
let mut continuous = PpmDetector::new(SR, PpmStandard::Din);
let reference = run_tone(&mut continuous, 5_000.0, 0.5, 500.0);
let mut detector = PpmDetector::new(SR, PpmStandard::Din);
let measured = run_tone(&mut detector, 5_000.0, 0.5, 10.0);
let relative_db = db(measured / reference);
assert!(
(relative_db - (-2.0)).abs() <= 0.5,
"10 ms: measured {relative_db:.3} dB, expected -2.000 +/- 0.500 dB"
);
}
#[test]
fn ebu_return_time_is_24_db_in_2_8_seconds() {
assert_return_time(PpmStandard::EbuTypeIib, 24.0, 2.8, 0.02);
+213
View File
@@ -0,0 +1,213 @@
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RmsIntegration {
Fast,
Slow,
Window300,
}
impl RmsIntegration {
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub fn from_config(value: &str) -> Self {
match value.trim().to_ascii_lowercase().as_str() {
"slow" => Self::Slow,
"window" | "window300" | "none" => Self::Window300,
_ => Self::Fast,
}
}
}
/// Sample-continuous true-RMS detector.
///
/// Time weighting is applied to linear squared samples. Taking the square root
/// and converting to decibels happens only after integration. This is important:
/// averaging already-logarithmic dB values does not produce an RMS value.
pub struct TrueRmsDetector {
sample_rate: u32,
mode: RmsIntegration,
power_l: f64,
power_r: f64,
fast_alpha: f64,
slow_alpha: f64,
window_l: Vec<f64>,
window_r: Vec<f64>,
window_pos: usize,
window_fill: usize,
window_sum_l: f64,
window_sum_r: f64,
}
impl TrueRmsDetector {
pub fn new(sample_rate: u32, mode: RmsIntegration) -> Self {
let mut detector = Self {
sample_rate: 0,
mode,
power_l: 0.0,
power_r: 0.0,
fast_alpha: 0.0,
slow_alpha: 0.0,
window_l: Vec::new(),
window_r: Vec::new(),
window_pos: 0,
window_fill: 0,
window_sum_l: 0.0,
window_sum_r: 0.0,
};
detector.configure(sample_rate, mode);
detector
}
pub fn configure(&mut self, sample_rate: u32, mode: RmsIntegration) {
let sample_rate = sample_rate.max(8_000);
if self.sample_rate == sample_rate && self.mode == mode {
return;
}
self.sample_rate = sample_rate;
self.mode = mode;
self.fast_alpha = alpha(sample_rate, 0.125);
self.slow_alpha = alpha(sample_rate, 1.0);
if mode == RmsIntegration::Window300 {
let window_len = ((sample_rate as f64 * 0.300).round() as usize).max(1);
self.window_l = vec![0.0; window_len];
self.window_r = vec![0.0; window_len];
} else {
self.window_l.clear();
self.window_r.clear();
}
self.reset();
}
pub fn reset(&mut self) {
self.power_l = 0.0;
self.power_r = 0.0;
self.window_l.fill(0.0);
self.window_r.fill(0.0);
self.window_pos = 0;
self.window_fill = 0;
self.window_sum_l = 0.0;
self.window_sum_r = 0.0;
}
pub fn process(&mut self, left: f32, right: f32) -> (f32, f32) {
let input_l = f64::from(left) * f64::from(left);
let input_r = f64::from(right) * f64::from(right);
match self.mode {
RmsIntegration::Fast => {
self.power_l += self.fast_alpha * (input_l - self.power_l);
self.power_r += self.fast_alpha * (input_r - self.power_r);
}
RmsIntegration::Slow => {
self.power_l += self.slow_alpha * (input_l - self.power_l);
self.power_r += self.slow_alpha * (input_r - self.power_r);
}
RmsIntegration::Window300 => self.process_window(input_l, input_r),
}
(
self.power_l.max(0.0).sqrt() as f32,
self.power_r.max(0.0).sqrt() as f32,
)
}
fn process_window(&mut self, input_l: f64, input_r: f64) {
if self.window_fill < self.window_l.len() {
self.window_fill += 1;
} else {
self.window_sum_l -= self.window_l[self.window_pos];
self.window_sum_r -= self.window_r[self.window_pos];
}
self.window_l[self.window_pos] = input_l;
self.window_r[self.window_pos] = input_r;
self.window_sum_l += input_l;
self.window_sum_r += input_r;
self.window_pos = (self.window_pos + 1) % self.window_l.len();
let denom = self.window_fill.max(1) as f64;
self.power_l = self.window_sum_l / denom;
self.power_r = self.window_sum_r / denom;
}
}
fn alpha(sample_rate: u32, tau_seconds: f64) -> f64 {
1.0 - (-1.0 / (sample_rate as f64 * tau_seconds)).exp()
}
#[cfg(test)]
mod tests {
use super::*;
fn sine_rms(mode: RmsIntegration, block_size: usize) -> f32 {
let sample_rate = 48_000u32;
let mut detector = TrueRmsDetector::new(sample_rate, mode);
let samples: Vec<f32> = (0..sample_rate * 12)
.map(|index| {
(2.0 * std::f32::consts::PI * 1_000.0 * index as f32 / sample_rate as f32).sin()
})
.collect();
let mut result = 0.0;
for block in samples.chunks(block_size) {
for &sample in block {
result = detector.process(sample, sample).0;
}
}
result
}
#[test]
fn sine_level_is_true_rms_for_every_integration() {
for mode in [
RmsIntegration::Fast,
RmsIntegration::Slow,
RmsIntegration::Window300,
] {
let measured = sine_rms(mode, 127);
assert!(
(measured - std::f32::consts::FRAC_1_SQRT_2).abs() < 2.0e-3,
"{mode:?}: {measured}"
);
}
}
#[test]
fn result_is_independent_of_capture_blocks() {
for mode in [
RmsIntegration::Fast,
RmsIntegration::Slow,
RmsIntegration::Window300,
] {
let reference = sine_rms(mode, 1);
for block_size in [64, 127, 128, 192, 511, 512] {
assert!((sine_rms(mode, block_size) - reference).abs() < 1.0e-7);
}
}
}
#[test]
fn fast_and_slow_apply_the_declared_power_time_constants() {
let sample_rate = 48_000;
for (mode, tau) in [(RmsIntegration::Fast, 0.125), (RmsIntegration::Slow, 1.0)] {
let mut detector = TrueRmsDetector::new(sample_rate, mode);
let samples = (sample_rate as f64 * tau).round() as usize;
let mut value = 0.0;
for _ in 0..samples {
value = detector.process(1.0, 1.0).0;
}
let expected = (1.0f64 - (-1.0f64).exp()).sqrt() as f32;
assert!((value - expected).abs() < 2.0e-5, "{mode:?}: {value}");
}
}
#[test]
fn rectangular_window_is_exactly_300_milliseconds() {
for sample_rate in [44_100, 48_000, 96_000] {
let mut detector = TrueRmsDetector::new(sample_rate, RmsIntegration::Window300);
let window_len = (sample_rate as f64 * 0.300).round() as usize;
assert_eq!(detector.window_l.len(), window_len);
let mut value = detector.process(1.0, 0.5).0;
for _ in 1..window_len {
value = detector.process(0.0, 0.0).0;
}
assert!(value > 0.0);
let (left, right) = detector.process(0.0, 0.0);
assert_eq!(left, 0.0);
assert_eq!(right, 0.0);
}
}
}
+425 -35
View File
@@ -7,10 +7,12 @@ use axum::{
Json,
};
use http_body_util::BodyExt;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use std::{
collections::BTreeMap,
path::PathBuf,
process::Stdio,
sync::atomic::{AtomicU64, Ordering},
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
@@ -23,6 +25,9 @@ use crate::{
};
const ONLINE_UPDATE_ZIP_URL: &str = "https://webshare.casaderoll.de/share/Phoenix.zip";
const DEVICE_BACKUP_KIND: &str = "phoenix-device-backup";
const DEVICE_BACKUP_SCHEMA_VERSION: u32 = 1;
static JSON_WRITE_TOKEN: AtomicU64 = AtomicU64::new(1);
fn stable_script_command(program: &str) -> Command {
let mut command = Command::new(program);
@@ -42,6 +47,39 @@ pub struct FrontendPresetSavePayload {
pub config: serde_json::Value,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceBackup {
pub kind: String,
pub schema_version: u32,
pub exported_at_unix_ms: u128,
pub global_config: PhoenixGlobalConfig,
pub rta_config: PhoenixRtaConfig,
#[serde(default)]
pub frontend_presets: BTreeMap<String, serde_json::Value>,
#[serde(default)]
pub frontend_layouts: BTreeMap<String, serde_json::Value>,
#[serde(default)]
pub kiosk_browser_storage: BTreeMap<String, String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KioskBrowserConfig {
#[serde(default)]
pub revision: u64,
#[serde(default)]
pub storage: BTreeMap<String, String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KioskBrowserConfigUpdate {
pub base_revision: u64,
#[serde(default)]
pub storage: BTreeMap<String, String>,
}
pub async fn health() -> Json<serde_json::Value> {
Json(serde_json::json!({
"ok": true,
@@ -197,6 +235,218 @@ pub async fn set_frontend_layout(
}
}
pub async fn export_device_backup(State(state): State<AppState>) -> Response {
let presets = match read_frontend_presets(&state.config).await {
Ok(value) => value,
Err(err) => return device_backup_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
};
let layouts = match read_frontend_layouts(&state.config).await {
Ok(value) => value,
Err(err) => return device_backup_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
};
let exported_at_unix_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let kiosk_browser_storage = match read_kiosk_browser_config(&state.config).await {
Ok(value) => value.storage,
Err(err) => return device_backup_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
};
Json(DeviceBackup {
kind: DEVICE_BACKUP_KIND.to_string(),
schema_version: DEVICE_BACKUP_SCHEMA_VERSION,
exported_at_unix_ms,
global_config: state.global_config().await,
rta_config: state.rta_config().await,
frontend_presets: presets.into_iter().collect(),
frontend_layouts: layouts.into_iter().collect(),
kiosk_browser_storage,
})
.into_response()
}
pub async fn restore_device_backup(
State(state): State<AppState>,
Json(backup): Json<DeviceBackup>,
) -> Response {
if let Err(message) = validate_device_backup(&backup) {
return device_backup_error(StatusCode::BAD_REQUEST, message);
}
let old_presets = match read_frontend_presets(&state.config).await {
Ok(value) => value,
Err(err) => return device_backup_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
};
let old_layouts = match read_frontend_layouts(&state.config).await {
Ok(value) => value,
Err(err) => return device_backup_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
};
let old_global = state.global_config().await;
let old_rta = state.rta_config().await;
let old_kiosk = match read_kiosk_browser_config(&state.config).await {
Ok(value) => value,
Err(err) => return device_backup_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
};
let new_presets: serde_json::Map<String, serde_json::Value> =
backup.frontend_presets.clone().into_iter().collect();
let new_layouts: serde_json::Map<String, serde_json::Value> =
backup.frontend_layouts.clone().into_iter().collect();
let result: anyhow::Result<()> = async {
persist_frontend_presets(&state.config, &new_presets).await?;
persist_frontend_layouts(&state.config, &new_layouts).await?;
persist_kiosk_browser_config(
&state.config,
&KioskBrowserConfig {
revision: old_kiosk.revision.saturating_add(1),
storage: backup.kiosk_browser_storage.clone(),
},
)
.await?;
state.set_rta_config(backup.rta_config.clone()).await;
state.persist_current_rta_config().await?;
state
.set_global_config(backup.global_config.clone())
.await?;
Ok(())
}
.await;
if let Err(err) = result {
let _ = persist_frontend_presets(&state.config, &old_presets).await;
let _ = persist_frontend_layouts(&state.config, &old_layouts).await;
let _ = persist_kiosk_browser_config(&state.config, &old_kiosk).await;
state.set_rta_config(old_rta).await;
let _ = state.set_global_config(old_global).await;
return device_backup_error(
StatusCode::INTERNAL_SERVER_ERROR,
format!("restore failed; previous settings were restored ({err})"),
);
}
(
StatusCode::OK,
Json(serde_json::json!({
"ok": true,
"schemaVersion": DEVICE_BACKUP_SCHEMA_VERSION,
"presets": backup.frontend_presets.len(),
"layouts": backup.frontend_layouts.len(),
"kioskBrowserValues": backup.kiosk_browser_storage.len(),
})),
)
.into_response()
}
fn validate_device_backup(backup: &DeviceBackup) -> Result<(), String> {
if backup.kind != DEVICE_BACKUP_KIND {
return Err("not a Phoenix device backup".to_string());
}
if backup.schema_version != DEVICE_BACKUP_SCHEMA_VERSION {
return Err(format!(
"unsupported backup schema {}; expected {}",
backup.schema_version, DEVICE_BACKUP_SCHEMA_VERSION
));
}
for (id, value) in &backup.frontend_presets {
if normalize_frontend_preset_id(id).is_empty() || !value.is_object() {
return Err(format!("invalid software preset '{id}'"));
}
}
for (id, value) in &backup.frontend_layouts {
if normalize_frontend_layout_id(id).is_empty() || !value.is_object() {
return Err(format!("invalid layout preset '{id}'"));
}
}
validate_kiosk_browser_storage(&backup.kiosk_browser_storage)?;
Ok(())
}
pub async fn get_kiosk_browser_config(State(state): State<AppState>) -> Response {
match read_kiosk_browser_config(&state.config).await {
Ok(value) => (StatusCode::OK, Json(value)).into_response(),
Err(err) => device_backup_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
}
}
pub async fn set_kiosk_browser_config(
State(state): State<AppState>,
Json(update): Json<KioskBrowserConfigUpdate>,
) -> Response {
if let Err(message) = validate_kiosk_browser_storage(&update.storage) {
return device_backup_error(StatusCode::BAD_REQUEST, message);
}
let current = match read_kiosk_browser_config(&state.config).await {
Ok(value) => value,
Err(err) => return device_backup_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
};
if update.base_revision != current.revision {
return (
StatusCode::CONFLICT,
Json(serde_json::json!({
"ok": false,
"error": "kiosk browser configuration changed",
"revision": current.revision,
})),
)
.into_response();
}
let next = KioskBrowserConfig {
revision: current.revision.saturating_add(1),
storage: update.storage,
};
match persist_kiosk_browser_config(&state.config, &next).await {
Ok(()) => (
StatusCode::OK,
Json(serde_json::json!({
"ok": true,
"revision": next.revision,
})),
)
.into_response(),
Err(err) => device_backup_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
}
}
fn validate_kiosk_browser_storage(storage: &BTreeMap<String, String>) -> Result<(), String> {
if storage.len() > 128 {
return Err("too many kiosk browser values".to_string());
}
let mut total = 0usize;
for (key, value) in storage {
let allowed = key.starts_with("analyzer_")
|| key.starts_with("MIGRATE_")
|| matches!(
key.as_str(),
"ppm_din_mode"
| "calibration_notice_seen_v1"
| "recorder_warning_ack_v1"
| "ppm_din_loudness_warn_ack_v1"
| "migrate_rta_peak_fall_default_v1"
| "migrate_vu_offset_default_zero_v2"
| "migrate_lr_delay_zero_to_0_05_v1"
);
if !allowed || key.len() > 128 {
return Err(format!("invalid kiosk browser key '{key}'"));
}
total = total.saturating_add(key.len()).saturating_add(value.len());
}
if total > 2 * 1024 * 1024 {
return Err("kiosk browser configuration is too large".to_string());
}
Ok(())
}
fn device_backup_error(status: StatusCode, message: String) -> Response {
(
status,
Json(serde_json::json!({
"ok": false,
"error": message,
})),
)
.into_response()
}
pub async fn download_online_update() -> Response {
let phoenix_root = match resolve_phoenix_root_dir() {
Ok(path) => path,
@@ -814,6 +1064,32 @@ async fn read_frontend_layouts(
Ok(parsed.as_object().cloned().unwrap_or_default())
}
async fn read_kiosk_browser_config(
config: &crate::config::PhoenixConfig,
) -> anyhow::Result<KioskBrowserConfig> {
let path = config.kiosk_browser_config_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(KioskBrowserConfig::default())
}
Err(err) => {
return Err(anyhow::anyhow!(
"failed to read kiosk browser config {} ({})",
path.display(),
err
))
}
};
serde_json::from_str::<KioskBrowserConfig>(&raw).map_err(|err| {
anyhow::anyhow!(
"failed to parse kiosk browser config {} ({})",
path.display(),
err
)
})
}
async fn persist_frontend_presets(
config: &crate::config::PhoenixConfig,
presets: &serde_json::Map<String, serde_json::Value>,
@@ -824,11 +1100,7 @@ async fn persist_frontend_presets(
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)
.await
.with_context(|| format!("failed to write frontend presets {}", path.display()))?;
Ok(())
persist_json_atomic(&path, &serde_json::Value::Object(presets.clone())).await
}
async fn persist_frontend_layouts(
@@ -841,10 +1113,44 @@ async fn persist_frontend_layouts(
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)
persist_json_atomic(&path, &serde_json::Value::Object(layouts.clone())).await
}
async fn persist_kiosk_browser_config(
config: &crate::config::PhoenixConfig,
kiosk: &KioskBrowserConfig,
) -> anyhow::Result<()> {
let path = config.kiosk_browser_config_path.clone();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await.with_context(|| {
format!(
"failed to create kiosk browser config dir {}",
parent.display()
)
})?;
}
persist_json_atomic(&path, kiosk).await
}
async fn persist_json_atomic<T: Serialize>(path: &PathBuf, value: &T) -> anyhow::Result<()> {
let json = serde_json::to_vec_pretty(value)?;
let token = JSON_WRITE_TOKEN.fetch_add(1, Ordering::Relaxed);
let temp_name = format!(
".{}.tmp-{}-{}",
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or("phoenix"),
std::process::id(),
token
);
let temp_path = path.with_file_name(temp_name);
fs::write(&temp_path, json)
.await
.with_context(|| format!("failed to write frontend layouts {}", path.display()))?;
.with_context(|| format!("failed to write temporary JSON {}", temp_path.display()))?;
if let Err(err) = fs::rename(&temp_path, path).await {
let _ = fs::remove_file(&temp_path).await;
return Err(err).with_context(|| format!("failed to replace JSON {}", path.display()));
}
Ok(())
}
@@ -890,15 +1196,14 @@ 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 {
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 (tp_l, tp_r) = drain_latest_meter_frame(&mut rx, &mut latest);
let mut frame = (*latest).clone();
frame.tp_l = tp_l;
frame.tp_r = tp_r;
strip_visual_payloads(&mut frame);
let payload = match serde_json::to_string(&frame) {
@@ -931,10 +1236,7 @@ fn strip_visual_payloads(frame: &mut MeterFrame) {
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;
};
@@ -959,34 +1261,30 @@ async fn visuals_ws_inner(mut socket: axum::extract::ws::WebSocket, state: AppSt
}
async fn spectro_ws_inner(mut socket: axum::extract::ws::WebSocket, state: AppState) {
let mut rx = state.subscribe_metrics();
state.spectro_subscriber_connected();
spectro_ws_session(&mut socket, &state).await;
state.spectro_subscriber_disconnected();
}
async fn spectro_ws_session(socket: &mut axum::extract::ws::WebSocket, state: &AppState) {
let mut rx = state.subscribe_spectro();
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,
}
let mut latest = match rx.recv().await {
Ok(frame) => 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;
}
}
Ok(frame) => 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"));
let payload = encode_spectro_frame(&latest);
if socket
.send(axum::extract::ws::Message::Binary(payload))
.await
@@ -1093,10 +1391,16 @@ async fn recv_latest_meter_frame(
fn drain_latest_meter_frame(
rx: &mut tokio::sync::broadcast::Receiver<Arc<MeterFrame>>,
latest: &mut Arc<MeterFrame>,
) {
) -> (f32, f32) {
let mut tp_l = latest.tp_l;
let mut tp_r = latest.tp_r;
loop {
match rx.try_recv() {
Ok(newer) => *latest = newer,
Ok(newer) => {
tp_l = tp_l.max(newer.tp_l);
tp_r = tp_r.max(newer.tp_r);
*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);
@@ -1104,6 +1408,7 @@ fn drain_latest_meter_frame(
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => break,
}
}
(tp_l, tp_r)
}
fn drain_visual_meter_frames(
@@ -1282,6 +1587,10 @@ mod tests {
period_size: 128,
correlation: 0.25,
correlation_negative_peak: -0.5,
phase_angle_rad: Some(0.25),
phase_coherence: 0.75,
phase_level: 0.2,
phase_peak: 0.3,
rms_l: -20.0,
rms_r: -21.0,
vu_l: -20.0,
@@ -1353,6 +1662,33 @@ mod tests {
}
}
#[test]
fn metrics_drain_keeps_peak_maxima_while_selecting_latest_state() {
let (tx, mut rx) = tokio::sync::broadcast::channel(8);
let mut first = meter_frame();
first.seq = 1;
first.tp_l = -12.0;
first.tp_r = -9.0;
let mut peak = meter_frame();
peak.seq = 2;
peak.tp_l = -1.5;
peak.tp_r = -3.0;
let mut latest_frame = meter_frame();
latest_frame.seq = 3;
latest_frame.tp_l = -18.0;
latest_frame.tp_r = -20.0;
tx.send(Arc::new(first)).unwrap();
tx.send(Arc::new(peak)).unwrap();
tx.send(Arc::new(latest_frame)).unwrap();
let mut latest = rx.try_recv().unwrap();
let (tp_l, tp_r) = drain_latest_meter_frame(&mut rx, &mut latest);
assert_eq!(latest.seq, 3);
assert_eq!(tp_l, -1.5);
assert_eq!(tp_r, -3.0);
}
#[test]
fn wave_envelopes_merge_without_losing_columns() {
let mut target = Some(WaveEnvFrame {
@@ -1416,4 +1752,58 @@ mod tests {
json.len()
);
}
#[test]
fn device_backup_round_trip_preserves_all_sections() {
let backup = DeviceBackup {
kind: DEVICE_BACKUP_KIND.to_string(),
schema_version: DEVICE_BACKUP_SCHEMA_VERSION,
exported_at_unix_ms: 123_456,
global_config: PhoenixGlobalConfig::default(),
rta_config: PhoenixRtaConfig::default(),
frontend_presets: BTreeMap::from([(
"michael".to_string(),
serde_json::json!({"RTA_BPO_MODE": "1_6"}),
)]),
frontend_layouts: BTreeMap::from([(
"1".to_string(),
serde_json::json!({"UI_LAST_STYLE": "quad-view"}),
)]),
kiosk_browser_storage: BTreeMap::from([(
"analyzer_style".to_string(),
"quad-view".to_string(),
)]),
};
let json = serde_json::to_string(&backup).unwrap();
let restored: DeviceBackup = serde_json::from_str(&json).unwrap();
assert!(validate_device_backup(&restored).is_ok());
assert_eq!(restored.kind, backup.kind);
assert_eq!(restored.frontend_presets, backup.frontend_presets);
assert_eq!(restored.frontend_layouts, backup.frontend_layouts);
assert_eq!(restored.kiosk_browser_storage, backup.kiosk_browser_storage);
assert_eq!(
serde_json::to_value(restored.global_config).unwrap(),
serde_json::to_value(backup.global_config).unwrap()
);
}
#[test]
fn device_backup_rejects_wrong_schema_and_invalid_store_entries() {
let mut backup = DeviceBackup {
kind: DEVICE_BACKUP_KIND.to_string(),
schema_version: DEVICE_BACKUP_SCHEMA_VERSION + 1,
exported_at_unix_ms: 0,
global_config: PhoenixGlobalConfig::default(),
rta_config: PhoenixRtaConfig::default(),
frontend_presets: BTreeMap::new(),
frontend_layouts: BTreeMap::new(),
kiosk_browser_storage: BTreeMap::new(),
};
assert!(validate_device_backup(&backup).is_err());
backup.schema_version = DEVICE_BACKUP_SCHEMA_VERSION;
backup
.frontend_layouts
.insert("99".to_string(), serde_json::json!({}));
assert!(validate_device_backup(&backup).is_err());
}
}
+142 -3
View File
@@ -1,8 +1,9 @@
//! 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.
//! Provides both the original repeated-RBJ Phoenix characteristic and complete
//! Butterworth fractional-octave bandpasses for controlled comparison.
use std::collections::VecDeque;
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,
@@ -155,6 +156,35 @@ pub fn design_fractional_octave_band(
sections
}
/// Reproduces the original Phoenix analyzer characteristic: one constant-peak
/// RBJ bandpass is repeated for every second order of the requested cascade.
/// It is retained for comparison with existing Phoenix/RTW captures and is
/// not presented as a filter implementation prescribed by IEC 61260.
pub fn design_legacy_repeated_band(
center_hz: f32,
sample_rate: u32,
bands_per_octave: usize,
total_order: usize,
) -> Vec<BiquadCoeffs> {
let fs = f64::from(sample_rate.max(8_000));
let center = f64::from(center_hz).clamp(0.01, fs * 0.499_9);
let bpo = bands_per_octave.max(1) as f64;
let upper = 2.0f64.powf(1.0 / (2.0 * bpo));
let lower = 2.0f64.powf(-1.0 / (2.0 * bpo));
let q = 1.0 / (upper - lower);
let omega = 2.0 * std::f64::consts::PI * center / fs;
let alpha = omega.sin() / (2.0 * q);
let a0 = 1.0 + alpha;
let section = BiquadCoeffs {
b0: alpha / a0,
b1: 0.0,
b2: -alpha / a0,
a1: -2.0 * omega.cos() / a0,
a2: (1.0 - alpha) / a0,
};
vec![section; (total_order.clamp(2, 8) / 2).max(1)]
}
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());
@@ -190,6 +220,58 @@ pub fn integrate_power(
previous + alpha * (block_power - previous)
}
pub fn integrate_power_asymmetric(
previous: f64,
block_power: f64,
block_samples: usize,
sample_rate: u32,
rise_tau_seconds: f32,
fall_tau_seconds: f32,
) -> f64 {
let tau = if block_power >= previous {
rise_tau_seconds
} else {
fall_tau_seconds
};
integrate_power(previous, block_power, block_samples, sample_rate, tau)
}
pub struct SlidingPeak {
window_samples: u64,
sample_index: u64,
candidates: VecDeque<(u64, f64)>,
}
impl SlidingPeak {
pub fn new(window_samples: usize) -> Self {
let window_samples = window_samples.max(1);
Self {
window_samples: window_samples as u64,
sample_index: 0,
candidates: VecDeque::with_capacity(window_samples + 1),
}
}
pub fn push(&mut self, power: f64) -> f64 {
while self.candidates.back().is_some_and(|entry| entry.1 <= power) {
self.candidates.pop_back();
}
self.candidates.push_back((self.sample_index, power));
let oldest = self
.sample_index
.saturating_sub(self.window_samples.saturating_sub(1));
while self
.candidates
.front()
.is_some_and(|entry| entry.0 < oldest)
{
self.candidates.pop_front();
}
self.sample_index = self.sample_index.wrapping_add(1);
self.candidates.front().map_or(0.0, |entry| entry.1)
}
}
#[derive(Clone, Copy)]
struct StereoBiquad {
coeffs: BiquadCoeffs,
@@ -396,6 +478,27 @@ mod tests {
assert!(((lower * upper).sqrt() - center).abs() < 0.001);
}
#[test]
fn legacy_sixth_order_repeats_three_identical_sections() {
let sections = design_legacy_repeated_band(1_000.0, 48_000, 6, 6);
assert_eq!(sections.len(), 3);
assert_eq!(sections[0].b0, sections[1].b0);
assert_eq!(sections[1].a2, sections[2].a2);
assert!(cascade_db(&sections, 1_000.0, 48_000).abs() < 0.001);
}
#[test]
fn legacy_and_butterworth_characteristics_remain_distinct() {
let factor = 2.0f32.powf(1.0 / 12.0);
let legacy = design_legacy_repeated_band(1_000.0, 48_000, 6, 6);
let butterworth =
design_fractional_octave_band(1_000.0, 1_000.0 / factor, 1_000.0 * factor, 48_000, 3);
let legacy_lower = cascade_db(&legacy, 1_000.0 / factor, 48_000);
let butterworth_lower = cascade_db(&butterworth, 1_000.0 / factor, 48_000);
assert!(legacy_lower < -8.5);
assert!((butterworth_lower + 3.0103).abs() < 0.08);
}
#[test]
fn neighboring_third_octave_centers_are_suppressed() {
let factor = 2.0f32.powf(1.0 / 6.0);
@@ -443,6 +546,42 @@ mod tests {
}
}
#[test]
fn impulse_integration_uses_fast_rise_and_slow_fall() {
let sample_rate = 48_000;
let risen = integrate_power_asymmetric(0.0, 1.0, 1_680, sample_rate, 0.035, 1.5);
let fallen = integrate_power_asymmetric(risen, 0.0, 1_680, sample_rate, 0.035, 1.5);
assert!((risen - (1.0 - (-1.0f64).exp())).abs() < 1.0e-6);
assert!(fallen > risen * 0.97);
}
#[test]
fn ten_millisecond_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 < 480 {
let count = block_size.min(480 - processed);
value = integrate_power(value, 1.0, count, sample_rate, 0.010);
processed += count;
}
value
}
assert!((run(48) - run(128)).abs() < 1.0e-12);
assert!((run(128) - (1.0 - (-1.0f64).exp())).abs() < 1.0e-6);
}
#[test]
fn sliding_peak_retains_a_transient_for_exact_window() {
let mut detector = SlidingPeak::new(4);
assert_eq!(detector.push(1.0), 1.0);
assert_eq!(detector.push(0.1), 1.0);
assert_eq!(detector.push(0.2), 1.0);
assert_eq!(detector.push(0.3), 1.0);
assert_eq!(detector.push(0.4), 0.4);
}
#[test]
fn weighting_is_normalized_and_directionally_correct() {
for mode in ["a", "c"] {
+233 -33
View File
@@ -17,7 +17,7 @@ use crate::{
config::PhoenixConfig,
model::{
InputSource, MeterFrame, PhoenixGlobalConfig, PhoenixGlobalConfigEnvelope,
PhoenixRtaConfig, ServiceStatus,
PhoenixRtaConfig, ServiceStatus, SpectroFrame,
},
};
@@ -28,6 +28,8 @@ pub struct AppState {
seq: Arc<AtomicU64>,
actual_sample_rate: Arc<AtomicU64>,
metrics_tx: broadcast::Sender<Arc<MeterFrame>>,
spectro_tx: broadcast::Sender<Arc<SpectroFrame>>,
spectro_subscribers: Arc<AtomicU64>,
restart_token: Arc<AtomicU64>,
rta_config: Arc<RwLock<PhoenixRtaConfig>>,
global_config: Arc<RwLock<PhoenixGlobalConfig>>,
@@ -53,6 +55,7 @@ pub(crate) struct NativeWavRecorder {
}
const NATIVE_RECORDING_EDGE_FADE_FRAMES: usize = 128;
static CONFIG_WRITE_TOKEN: AtomicU64 = AtomicU64::new(1);
fn apply_native_recording_edge_fades(pcm_bytes: &mut [u8], channels: u16) {
let ch = usize::from(channels.max(1));
@@ -95,9 +98,10 @@ impl AppState {
// 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 (spectro_tx, _) = broadcast::channel(8);
let initial_global_config = load_global_config(&config);
let initial_rta_config =
apply_global_to_rta(config.default_rta_config(), &initial_global_config);
apply_global_to_rta(load_rta_config(&config), &initial_global_config);
let configured_sample_rate = config.sample_rate;
Self {
config,
@@ -105,6 +109,8 @@ impl AppState {
seq: Arc::new(AtomicU64::new(0)),
actual_sample_rate: Arc::new(AtomicU64::new(configured_sample_rate as u64)),
metrics_tx,
spectro_tx,
spectro_subscribers: Arc::new(AtomicU64::new(0)),
restart_token: Arc::new(AtomicU64::new(0)),
rta_config: Arc::new(RwLock::new(initial_rta_config)),
global_config: Arc::new(RwLock::new(initial_global_config)),
@@ -117,6 +123,22 @@ impl AppState {
self.metrics_tx.subscribe()
}
pub fn subscribe_spectro(&self) -> broadcast::Receiver<Arc<SpectroFrame>> {
self.spectro_tx.subscribe()
}
pub fn spectro_subscriber_connected(&self) {
self.spectro_subscribers.fetch_add(1, Ordering::SeqCst);
}
pub fn spectro_subscriber_disconnected(&self) {
let _ =
self.spectro_subscribers
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
Some(count.saturating_sub(1))
});
}
pub async fn input(&self) -> InputSource {
*self.current_input.read().await
}
@@ -142,11 +164,15 @@ impl AppState {
pub async fn set_rta_config(&self, config: PhoenixRtaConfig) -> PhoenixRtaConfig {
let normalized = normalize_rta_config(config);
*self.rta_config.write().await = normalized.clone();
let _ = persist_rta_config(&self.config, &normalized).await;
let current = self.global_config.read().await.clone();
let updated_global = PhoenixGlobalConfig {
fft_size: normalized.fft_size,
input_source: current.input_source,
rta_bpo_mode: current.rta_bpo_mode.clone(),
// RTA resolution has one authoritative value. Keeping the old
// global value here allowed /rta-config and /global-config to
// disagree until the service was restarted.
rta_bpo_mode: normalized.bpo.clone(),
input_offset_db_l: normalized.input_offset_db_l,
input_offset_db_r: normalized.input_offset_db_r,
mono_input: normalized.mono_input,
@@ -157,9 +183,9 @@ impl AppState {
panel_dividers_enabled: current.panel_dividers_enabled,
ppm_din_attack_ms: normalized.ppm_din_attack_ms,
ppm_din_decay_db_per_s: normalized.ppm_din_decay_db_per_s,
ppm_din_fast_attack: normalized.ppm_din_fast_attack,
ppm_ebu_attack_ms: normalized.ppm_ebu_attack_ms,
ppm_ebu_decay_db_per_s: normalized.ppm_ebu_decay_db_per_s,
rms_integration: normalized.rms_integration.clone(),
lufs_i_window_min: normalized.lufs_i_window_min,
lufs_i_norm_enabled: normalized.lufs_i_norm_enabled,
ppm_din_loudness_boxes: current.ppm_din_loudness_boxes,
@@ -221,6 +247,10 @@ impl AppState {
self.global_config.read().await.clone()
}
pub async fn persist_current_rta_config(&self) -> anyhow::Result<()> {
persist_rta_config(&self.config, &self.rta_config().await).await
}
pub fn global_config_revision(&self) -> u64 {
self.global_config_rev.load(Ordering::SeqCst)
}
@@ -239,6 +269,15 @@ impl AppState {
let normalized = normalize_global_config(payload, &self.config);
let current_global = self.global_config.read().await.clone();
if current_global == normalized {
// A legacy or racing /rta-config request may have changed the
// runtime engine without changing the persisted global config.
// Re-apply the authoritative global fields even when persistence
// itself does not need an update.
let repaired_rta = {
let current = self.rta_config.read().await.clone();
apply_global_to_rta(current, &normalized)
};
*self.rta_config.write().await = repaired_rta;
return Ok(PhoenixGlobalConfigEnvelope {
revision: self.global_config_revision(),
config: current_global,
@@ -268,6 +307,8 @@ impl AppState {
seq: self.seq.clone(),
actual_sample_rate: self.actual_sample_rate.clone(),
metrics_tx: self.metrics_tx.clone(),
spectro_tx: self.spectro_tx.clone(),
spectro_subscribers: self.spectro_subscribers.clone(),
restart_token: self.restart_token.clone(),
});
}
@@ -321,6 +362,14 @@ fn normalize_rta_config(mut config: PhoenixRtaConfig) -> PhoenixRtaConfig {
"fft" => "fft".to_string(),
_ => "iir".to_string(),
};
config.filterbank = match config.filterbank.trim().to_ascii_lowercase().as_str() {
"butterworth" | "butter" => "butterworth".to_string(),
_ => "legacy".to_string(),
};
config.detector = match config.detector.trim().to_ascii_lowercase().as_str() {
"peak" => "peak".to_string(),
_ => "average".to_string(),
};
config.fft_size = match config.fft_size {
2048 | 4096 | 8192 | 16384 => config.fft_size,
n if n < 3072 => 2048,
@@ -358,8 +407,16 @@ fn normalize_rta_config(mut config: PhoenixRtaConfig) -> PhoenixRtaConfig {
"impulse" => "impulse".to_string(),
"medium" => "medium".to_string(),
"slow" => "slow".to_string(),
"average" => "average".to_string(),
"peak" => "peak".to_string(),
// Compatibility with configurations saved before detector and response
// became separate settings.
"peak" => {
config.detector = "peak".to_string();
"fast".to_string()
}
"average" => {
config.detector = "average".to_string();
"fast".to_string()
}
_ => "fast".to_string(),
};
config.order = config.order.clamp(2, 8);
@@ -368,26 +425,45 @@ fn normalize_rta_config(mut config: PhoenixRtaConfig) -> PhoenixRtaConfig {
}
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
// Fast and Slow are named standard responses, not free tuning controls.
// Keep the serialized fields for old clients, but normalize their values.
config.tau_fast = 0.125;
config.tau_slow = 1.0;
config.rta_peak_hold_mode = match config
.rta_peak_hold_mode
.trim()
.to_ascii_lowercase()
.as_str()
{
"off" => "off".to_string(),
"fall" => "fall".to_string(),
"manual" => "manual".to_string(),
"auto" => "auto".to_string(),
_ => "fall".to_string(),
};
let tau_slow = if config.tau_slow.is_finite() {
config.tau_slow
let requested_hold = if config.rta_peak_hold_seconds.is_finite() {
config.rta_peak_hold_seconds
} else {
1.0
2.5
};
config.rta_peak_hold_seconds = if config.layout == "rtw" {
if requested_hold >= 3.25 {
4.0
} else {
2.5
}
} else {
requested_hold.clamp(0.0, 30.0)
};
config.rta_peak_decay_db_per_second = if config.rta_peak_decay_db_per_second.is_finite() {
config.rta_peak_decay_db_per_second.clamp(1.0, 60.0)
} else {
20.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 {
@@ -404,13 +480,17 @@ fn normalize_rta_config(mut config: PhoenixRtaConfig) -> PhoenixRtaConfig {
// compatibility with older clients without allowing silent mistuning.
config.ppm_din_attack_ms = 10.0;
config.ppm_din_decay_db_per_s = 20.0 / 1.5;
config.ppm_din_fast_attack = !!config.ppm_din_fast_attack;
config.ppm_ebu_attack_ms = 10.0;
config.ppm_ebu_decay_db_per_s = 24.0 / 2.8;
config.rms_integration = normalize_rms_integration(&config.rms_integration);
config.lufs_i_window_min = config.lufs_i_window_min.clamp(1, 10);
config.lufs_i_norm_enabled = !!config.lufs_i_norm_enabled;
config.correlation_response_s =
crate::correlation::normalize_response_seconds(config.correlation_response_s);
config.correlation_silence_threshold_rms_dbfs =
crate::correlation::normalize_silence_threshold_db(
config.correlation_silence_threshold_rms_dbfs,
);
config.xy_points = match config.xy_points {
128 | 256 | 512 | 1024 | 2048 => config.xy_points,
n if n < 192 => 128,
@@ -492,9 +572,9 @@ fn normalize_global_config(
config.panel_dividers_enabled = !!config.panel_dividers_enabled;
config.ppm_din_attack_ms = 10.0;
config.ppm_din_decay_db_per_s = 20.0 / 1.5;
config.ppm_din_fast_attack = !!config.ppm_din_fast_attack;
config.ppm_ebu_attack_ms = 10.0;
config.ppm_ebu_decay_db_per_s = 24.0 / 2.8;
config.rms_integration = normalize_rms_integration(&config.rms_integration);
config.lufs_i_window_min = config.lufs_i_window_min.clamp(1, 10);
config.lufs_i_norm_enabled = !!config.lufs_i_norm_enabled;
config.ppm_din_loudness_boxes = !!config.ppm_din_loudness_boxes;
@@ -663,9 +743,6 @@ fn apply_global_to_rta(
) -> PhoenixRtaConfig {
config.fft_size = global.fft_size;
config.bpo = global.rta_bpo_mode.clone();
if config.bpo != "1_3" && config.layout == "rtw" {
config.layout = "iec".to_string();
}
config.input_offset_db_l = global.input_offset_db_l;
config.input_offset_db_r = global.input_offset_db_r;
config.mono_input = global.mono_input;
@@ -673,15 +750,23 @@ fn apply_global_to_rta(
config.lr_fractional_delay_samples = global.lr_fractional_delay_samples;
config.ppm_din_attack_ms = global.ppm_din_attack_ms;
config.ppm_din_decay_db_per_s = global.ppm_din_decay_db_per_s;
config.ppm_din_fast_attack = global.ppm_din_fast_attack;
config.ppm_ebu_attack_ms = global.ppm_ebu_attack_ms;
config.ppm_ebu_decay_db_per_s = global.ppm_ebu_decay_db_per_s;
config.rms_integration = global.rms_integration.clone();
config.lufs_i_window_min = global.lufs_i_window_min;
config.lufs_i_norm_enabled = global.lufs_i_norm_enabled;
config.xy_points = global.xy_points;
normalize_rta_config(config)
}
fn normalize_rms_integration(value: &str) -> String {
match value.trim().to_ascii_lowercase().as_str() {
"slow" => "slow".to_string(),
"window" | "window300" | "none" => "window".to_string(),
_ => "fast".to_string(),
}
}
fn load_global_config(runtime: &PhoenixConfig) -> PhoenixGlobalConfig {
let fallback = normalize_global_config(runtime.default_global_config(), runtime);
let Ok(raw) = std::fs::read_to_string(&runtime.global_config_path) else {
@@ -693,20 +778,60 @@ fn load_global_config(runtime: &PhoenixConfig) -> PhoenixGlobalConfig {
normalize_global_config(parsed, runtime)
}
fn load_rta_config(runtime: &PhoenixConfig) -> PhoenixRtaConfig {
let fallback = normalize_rta_config(runtime.default_rta_config());
let Ok(raw) = std::fs::read_to_string(&runtime.rta_config_path) else {
return fallback;
};
let Ok(parsed) = serde_json::from_str::<PhoenixRtaConfig>(&raw) else {
return fallback;
};
normalize_rta_config(parsed)
}
async fn persist_rta_config(
runtime: &PhoenixConfig,
config: &PhoenixRtaConfig,
) -> anyhow::Result<()> {
persist_config_atomic(&runtime.rta_config_path, config, "Phoenix RTA config").await
}
async fn persist_global_config(
runtime: &PhoenixConfig,
config: &PhoenixGlobalConfig,
) -> anyhow::Result<()> {
let path = runtime.global_config_path.clone();
persist_config_atomic(&runtime.global_config_path, config, "Phoenix config").await
}
async fn persist_config_atomic<T: serde::Serialize>(
path: &std::path::Path,
config: &T,
label: &str,
) -> anyhow::Result<()> {
let path = path.to_path_buf();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.await
.with_context(|| format!("failed to create Phoenix config dir {}", parent.display()))?;
.with_context(|| format!("failed to create {label} dir {}", parent.display()))?;
}
let json = serde_json::to_vec_pretty(config)?;
fs::write(&path, json)
let token = CONFIG_WRITE_TOKEN.fetch_add(1, Ordering::Relaxed);
let temp_name = format!(
".{}.tmp-{}-{}",
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or("global-config.json"),
std::process::id(),
token
);
let temp_path = path.with_file_name(temp_name);
fs::write(&temp_path, json)
.await
.with_context(|| format!("failed to write Phoenix config {}", path.display()))?;
.with_context(|| format!("failed to write temporary {label} {}", temp_path.display()))?;
if let Err(err) = fs::rename(&temp_path, &path).await {
let _ = fs::remove_file(&temp_path).await;
return Err(err).with_context(|| format!("failed to replace {label} {}", path.display()));
}
Ok(())
}
@@ -715,7 +840,7 @@ mod tests {
use super::*;
#[test]
fn rtw_profile_is_always_iir_third_octave() {
fn rtw_profile_is_iir_and_retains_selected_resolution() {
let config = normalize_rta_config(PhoenixRtaConfig {
engine: "fft".to_string(),
bpo: "1_12".to_string(),
@@ -725,7 +850,8 @@ mod tests {
..PhoenixRtaConfig::default()
});
assert_eq!(config.engine, "iir");
assert_eq!(config.bpo, "1_3");
assert_eq!(config.filterbank, "legacy");
assert_eq!(config.bpo, "1_12");
assert_eq!(config.order, 6);
assert_eq!(config.freq_range, "norm");
}
@@ -734,6 +860,7 @@ mod tests {
fn phoenix_profile_retains_explicit_extensions() {
let config = normalize_rta_config(PhoenixRtaConfig {
engine: "fft".to_string(),
filterbank: "butterworth".to_string(),
bpo: "1_12".to_string(),
order: 8,
freq_range: "lf".to_string(),
@@ -742,6 +869,7 @@ mod tests {
..PhoenixRtaConfig::default()
});
assert_eq!(config.engine, "fft");
assert_eq!(config.filterbank, "butterworth");
assert_eq!(config.bpo, "1_12");
assert_eq!(config.order, 8);
assert_eq!(config.freq_range, "lf");
@@ -749,12 +877,84 @@ mod tests {
}
#[test]
fn non_third_octave_global_selection_leaves_locked_rtw_profile() {
fn rta_peak_hold_config_is_normalized() {
let rtw = normalize_rta_config(PhoenixRtaConfig {
rta_peak_hold_mode: "MANUAL".to_string(),
rta_peak_hold_seconds: 3.7,
rta_peak_decay_db_per_second: 25.0,
rta_peak_reset_token: 12,
layout: "rtw".to_string(),
..PhoenixRtaConfig::default()
});
assert_eq!(rtw.rta_peak_hold_mode, "manual");
assert_eq!(rtw.rta_peak_hold_seconds, 4.0);
assert_eq!(rtw.rta_peak_decay_db_per_second, 25.0);
assert_eq!(rtw.rta_peak_reset_token, 12);
let extension = normalize_rta_config(PhoenixRtaConfig {
rta_peak_hold_mode: "invalid".to_string(),
rta_peak_hold_seconds: 45.0,
layout: "iec".to_string(),
..PhoenixRtaConfig::default()
});
assert_eq!(extension.rta_peak_hold_mode, "fall");
assert_eq!(extension.rta_peak_hold_seconds, 30.0);
}
#[test]
fn legacy_peak_mode_migrates_to_peak_detector_with_fast_response() {
let config = normalize_rta_config(PhoenixRtaConfig {
detector: String::new(),
integration: "peak".to_string(),
..PhoenixRtaConfig::default()
});
assert_eq!(config.detector, "peak");
assert_eq!(config.integration, "fast");
}
#[test]
fn legacy_average_mode_migrates_to_average_detector_with_fast_response() {
let config = normalize_rta_config(PhoenixRtaConfig {
detector: String::new(),
integration: "average".to_string(),
tau_fast: 0.12,
tau_slow: 0.8,
..PhoenixRtaConfig::default()
});
assert_eq!(config.detector, "average");
assert_eq!(config.integration, "fast");
assert_eq!(config.tau_fast, 0.125);
assert_eq!(config.tau_slow, 1.0);
}
#[test]
fn non_third_octave_global_selection_retains_rtw_profile() {
let mut global = PhoenixGlobalConfig::default();
global.rta_bpo_mode = "1_12".to_string();
let config = apply_global_to_rta(PhoenixRtaConfig::default(), &global);
assert_eq!(config.layout, "iec");
assert_eq!(config.layout, "rtw");
assert_eq!(config.bpo, "1_12");
assert_eq!(config.engine, "iir");
}
#[test]
fn global_octave_resolution_repairs_a_divergent_runtime_config() {
let mut global = PhoenixGlobalConfig::default();
global.rta_bpo_mode = "1_6".to_string();
let runtime = PhoenixRtaConfig {
bpo: "1_12".to_string(),
..PhoenixRtaConfig::default()
};
let repaired = apply_global_to_rta(runtime, &global);
assert_eq!(repaired.bpo, "1_6");
}
#[test]
fn rms_integration_rejects_non_rms_impulse_mode() {
assert_eq!(normalize_rms_integration("fast"), "fast");
assert_eq!(normalize_rms_integration("slow"), "slow");
assert_eq!(normalize_rms_integration("window300"), "window");
assert_eq!(normalize_rms_integration("none"), "window");
assert_eq!(normalize_rms_integration("impulse"), "fast");
}
}
+184
View File
@@ -0,0 +1,184 @@
//! Sample-continuous true-peak detector according to ITU-R BS.1770 Annex 2.
//!
//! The four polyphase FIR branches are the reference 4x interpolation filter
//! specified by BS.1770. Keeping the twelve input samples in a persistent ring
//! makes the result independent of ALSA capture and WebSocket block boundaries.
const PHASES: usize = 4;
const TAPS: usize = 12;
// ITU-R BS.1770 Annex 2, Table 2: coefficients for 4x oversampling.
const INTERPOLATOR: [[f32; TAPS]; PHASES] = [
[
0.001_708_984_4,
-0.010_986_328,
0.019_653_32,
-0.033_203_125,
0.059_448_242,
-0.137_329_1,
0.972_167_97,
0.188_598_63,
-0.071_289_06,
0.037_597_656,
-0.021_362_305,
0.010_986_328,
],
[
-0.029_174_805,
0.029_296_875,
-0.051_757_813,
0.089_111_33,
-0.166_503_9,
0.465_087_9,
0.779_785_16,
-0.200_317_38,
0.101_562_5,
-0.058_227_54,
0.033_081_055,
-0.018_920_898,
],
[
-0.018_920_898,
0.033_081_055,
-0.058_227_54,
0.101_562_5,
-0.200_317_38,
0.779_785_16,
0.465_087_9,
-0.166_503_9,
0.089_111_33,
-0.051_757_813,
0.029_296_875,
-0.029_174_805,
],
[
0.010_986_328,
-0.021_362_305,
0.037_597_656,
-0.071_289_06,
0.188_598_63,
0.972_167_97,
-0.137_329_1,
0.059_448_242,
-0.033_203_125,
0.019_653_32,
-0.010_986_328,
0.001_708_984_4,
],
];
#[derive(Clone, Debug)]
pub struct TruePeakDetector {
history: [f32; TAPS],
next: usize,
}
impl Default for TruePeakDetector {
fn default() -> Self {
Self {
history: [0.0; TAPS],
next: 0,
}
}
}
impl TruePeakDetector {
pub fn process(&mut self, sample: f32) -> f32 {
self.history[self.next] = sample;
self.next = (self.next + 1) % TAPS;
let mut peak = sample.abs();
for phase in &INTERPOLATOR {
let mut interpolated = 0.0f32;
for (tap, &coefficient) in phase.iter().enumerate() {
let index = (self.next + tap) % TAPS;
interpolated += self.history[index] * coefficient;
}
peak = peak.max(interpolated.abs());
}
peak
}
}
#[cfg(test)]
mod tests {
use super::*;
fn run_in_blocks(samples: &[f32], block_size: usize) -> f32 {
let mut detector = TruePeakDetector::default();
let mut peak = 0.0f32;
for block in samples.chunks(block_size) {
for &sample in block {
peak = peak.max(detector.process(sample));
}
}
// Drain the fixed FIR delay without resetting its sample history.
for _ in 0..TAPS {
peak = peak.max(detector.process(0.0));
}
peak
}
fn sine(divisor: f32, amplitude: f32, phase_degrees: f32) -> Vec<f32> {
let phase = phase_degrees.to_radians();
let count = 4_800usize;
let fade = 480usize;
(0..count)
.map(|index| {
let angle = 2.0 * std::f32::consts::PI * index as f32 / divisor + phase;
let edge = index.min(count - 1 - index);
let taper = if edge < fade {
let x = edge as f32 / fade as f32;
0.5 - 0.5 * (std::f32::consts::PI * x).cos()
} else {
1.0
};
angle.sin() * amplitude * taper
})
.collect()
}
fn dbtp(value: f32) -> f32 {
20.0 * value.max(1.0e-12).log10()
}
fn assert_ebu_true_peak(divisor: f32, amplitude: f32, phase_degrees: f32, expected: f32) {
let measured = dbtp(run_in_blocks(&sine(divisor, amplitude, phase_degrees), 128));
let low = expected - 0.4;
let high = expected + 0.2;
assert!(
(low..=high).contains(&measured),
"measured {measured:.4} dBTP, expected {expected:.1} dBTP (+0.2/-0.4)"
);
}
#[test]
fn result_is_independent_of_capture_block_boundaries() {
let samples = sine(48_000.0 / 11_025.0, 0.9, 17.761_692);
let reference = run_in_blocks(&samples, 1);
for size in [64, 127, 128, 192, 511] {
assert!((run_in_blocks(&samples, size) - reference).abs() < 1.0e-7);
}
}
#[test]
fn detects_an_intersample_peak_above_sample_peak() {
let samples = sine(48_000.0 / 11_025.0, 0.9, 17.761_692);
let sample_peak = samples
.iter()
.fold(0.0f32, |peak, value| peak.max(value.abs()));
let detected = run_in_blocks(&samples, 128);
assert!(detected > sample_peak + 0.001);
}
#[test]
fn passes_ebu_tech_3341_true_peak_tests_15_to_19() {
// EBU Tech 3341 v4, minimum-requirements tests 15-19. Frequency is
// expressed as a divisor of fs, so these remain valid at every rate.
assert_ebu_true_peak(4.0, 0.50, 0.0, -6.0);
assert_ebu_true_peak(4.0, 0.50, 45.0, -6.0);
assert_ebu_true_peak(6.0, 0.50, 60.0, -6.0);
assert_ebu_true_peak(8.0, 0.50, 67.5, -6.0);
assert_ebu_true_peak(4.0, 1.41, 45.0, 3.0);
}
}
+138
View File
@@ -0,0 +1,138 @@
//! Standard-volume-indicator style full-wave detector and moving-coil model.
const RESONANCE_HZ: f64 = 2.1;
const Q: f64 = 0.62;
const RECTIFIED_TO_RMS: f64 = std::f64::consts::PI / (2.0 * std::f64::consts::SQRT_2);
#[derive(Clone, Copy, Debug, Default)]
struct ChannelState {
x1: f64,
x2: f64,
y1: f64,
y2: f64,
}
#[derive(Clone, Debug)]
pub struct VuMeter {
sample_rate: u32,
b0: f64,
b1: f64,
b2: f64,
a1: f64,
a2: f64,
left: ChannelState,
right: ChannelState,
}
impl VuMeter {
pub fn new(sample_rate: u32) -> Self {
let mut meter = Self {
sample_rate: 0,
b0: 0.0,
b1: 0.0,
b2: 0.0,
a1: 0.0,
a2: 0.0,
left: ChannelState::default(),
right: ChannelState::default(),
};
meter.ensure_sample_rate(sample_rate);
meter
}
pub fn ensure_sample_rate(&mut self, sample_rate: u32) {
let sample_rate = sample_rate.max(8_000);
if self.sample_rate == sample_rate {
return;
}
self.sample_rate = sample_rate;
let omega = 2.0 * std::f64::consts::PI * RESONANCE_HZ / f64::from(sample_rate);
let cosine = omega.cos();
let alpha = omega.sin() / (2.0 * Q);
let a0 = 1.0 + alpha;
self.b0 = (1.0 - cosine) * 0.5 / a0;
self.b1 = (1.0 - cosine) / a0;
self.b2 = self.b0;
self.a1 = -2.0 * cosine / a0;
self.a2 = (1.0 - alpha) / a0;
self.left = ChannelState::default();
self.right = ChannelState::default();
}
pub fn process(&mut self, left: f32, right: f32) -> (f32, f32) {
let coefficients = (self.b0, self.b1, self.b2, self.a1, self.a2);
let left = Self::process_channel(&mut self.left, f64::from(left.abs()), coefficients);
let right = Self::process_channel(&mut self.right, f64::from(right.abs()), coefficients);
(left as f32, right as f32)
}
fn process_channel(
state: &mut ChannelState,
rectified: f64,
(b0, b1, b2, a1, a2): (f64, f64, f64, f64, f64),
) -> f64 {
let input = rectified * RECTIFIED_TO_RMS;
let output = b0 * input + b1 * state.x1 + b2 * state.x2 - a1 * state.y1 - a2 * state.y2;
state.x2 = state.x1;
state.x1 = input;
state.y2 = state.y1;
state.y1 = output;
output.max(0.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sine(index: usize, sample_rate: u32) -> f32 {
(2.0 * std::f32::consts::PI * 1_000.0 * index as f32 / sample_rate as f32).sin()
}
#[test]
fn moving_coil_step_reaches_99_percent_and_has_standard_overshoot() {
let sample_rate = 48_000;
let reference = std::f32::consts::FRAC_1_SQRT_2;
let mut meter = VuMeter::new(sample_rate);
let mut at_300_ms = 0.0;
let mut maximum = 0.0f32;
for index in 0..sample_rate as usize {
let output = meter.process(sine(index, sample_rate), 0.0).0;
maximum = maximum.max(output);
if index + 1 == sample_rate as usize * 3 / 10 {
at_300_ms = output;
}
}
assert!((at_300_ms / reference - 0.99).abs() < 0.015);
let overshoot = maximum / reference - 1.0;
assert!((0.01..=0.015).contains(&overshoot), "overshoot={overshoot}");
let mut released = 0.0;
for _ in 0..sample_rate as usize * 3 / 10 {
released = meter.process(0.0, 0.0).0;
}
assert!(released < reference * 0.02);
}
#[test]
fn result_is_independent_of_capture_period() {
fn run(period: usize) -> f32 {
let sample_rate = 48_000;
let mut meter = VuMeter::new(sample_rate);
let samples: Vec<f32> = (0..sample_rate as usize)
.map(|index| sine(index, sample_rate))
.collect();
let mut result = 0.0;
for block in samples.chunks(period) {
for &sample in block {
result = meter.process(sample, sample).0;
}
}
result
}
let reference = run(1);
for period in [64, 127, 128, 192, 512] {
assert!((run(period) - reference).abs() < 1.0e-7);
}
}
}
+38 -9
View File
@@ -1,16 +1,45 @@
// Aktiviert den bestehenden Tab mit Ziel-URL (ohne Reload). Falls nicht offen, wird er geöffnet.
const ANALYZER_URL = 'http://localhost:8088/';
const VOLUMIO_URL = 'http://localhost:3000/';
function matchingTabs(tabs, url) {
return tabs.filter(tab => tab.url && tab.url.startsWith(url));
}
// Keep one Analyzer and one Volumio tab. Chromium starts on Analyzer, so
// Volumio can load in the background without delaying the first visible UI.
function ensureDualTabs() {
chrome.tabs.query({ currentWindow: true }, tabs => {
const analyzerTabs = matchingTabs(tabs, ANALYZER_URL);
const volumioTabs = matchingTabs(tabs, VOLUMIO_URL);
const duplicates = [...analyzerTabs.slice(1), ...volumioTabs.slice(1)]
.map(tab => tab.id)
.filter(id => Number.isInteger(id));
if (duplicates.length > 0) chrome.tabs.remove(duplicates);
if (analyzerTabs.length === 0) chrome.tabs.create({ url: ANALYZER_URL, active: true });
if (volumioTabs.length === 0) chrome.tabs.create({ url: VOLUMIO_URL, active: false });
});
}
// Activate an existing application tab without reloading it.
function activateOrCreate(url) {
chrome.tabs.query({}, tabs => {
const t = tabs.find(tt => tt.url && tt.url.startsWith(url));
if (t) { chrome.tabs.update(t.id, {active: true}); }
else { chrome.tabs.create({url}); }
chrome.tabs.query({ currentWindow: true }, tabs => {
const tab = matchingTabs(tabs, url)[0];
if (tab) chrome.tabs.update(tab.id, { active: true });
else chrome.tabs.create({ url, active: true });
});
}
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg && msg.cmd === 'toggle') {
if (msg.from === 'volumio') activateOrCreate('http://localhost:8088/');
else activateOrCreate('http://localhost:3000/');
sendResponse({ok:true});
if (!msg) return;
if (msg.cmd === 'bootstrap') {
ensureDualTabs();
sendResponse({ ok: true });
return;
}
if (msg.cmd === 'toggle') {
if (msg.from === 'volumio') activateOrCreate(ANALYZER_URL);
else activateOrCreate(VOLUMIO_URL);
sendResponse({ ok: true });
}
});
+40 -1
View File
@@ -1 +1,40 @@
(function(){try{const e="3000"===location.port,t=document.createElement("button");function o(){chrome.runtime.sendMessage({cmd:"toggle",from:e?"volumio":"analyzer"},(()=>{}))}t.textContent=e?"Analyzer":"Volumio",Object.assign(t.style,{position:"fixed",right:"10px",top:"10px",zIndex:999999,font:"bold 14px ui-monospace,monospace",padding:"6px 10px",background:"#0b0b12",color:"#ddd",border:"1px solid #333",borderRadius:"8px",cursor:"pointer",opacity:"0.85"}),t.onmouseenter=()=>t.style.opacity="1",t.onmouseleave=()=>t.style.opacity="0.85",t.onclick=o,document.body.appendChild(t),window.addEventListener("keydown",(t=>{"t"===t.key.toLowerCase()&&(t.preventDefault(),o())}),{capture:!0})}catch(e){}})();
(function () {
try {
const isVolumio = location.port === '3000';
const button = document.createElement('button');
const toggle = () => {
chrome.runtime.sendMessage(
{ cmd: 'toggle', from: isVolumio ? 'volumio' : 'analyzer' },
() => {},
);
};
chrome.runtime.sendMessage({ cmd: 'bootstrap' }, () => {});
button.textContent = isVolumio ? 'Analyzer' : 'Volumio';
Object.assign(button.style, {
position: 'fixed',
right: '10px',
top: '10px',
zIndex: 999999,
font: 'bold 14px ui-monospace,monospace',
padding: '6px 10px',
background: '#0b0b12',
color: '#ddd',
border: '1px solid #333',
borderRadius: '8px',
cursor: 'pointer',
opacity: '0.85',
});
button.onmouseenter = () => { button.style.opacity = '1'; };
button.onmouseleave = () => { button.style.opacity = '0.85'; };
button.onclick = toggle;
document.body.appendChild(button);
window.addEventListener('keydown', event => {
if (event.key.toLowerCase() === 't') {
event.preventDefault();
toggle();
}
}, { capture: true });
} catch (_) {}
})();
+592
View File
@@ -0,0 +1,592 @@
<div class="opt-group">
<details class="changelog-version" open>
<summary>3.8.6 (Phoenix 1.0)</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 27.07.2026</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li><strong>Analysegrundlage:</strong> Die tiefgehende Code- und DSP-Prüfung, durch die die nachfolgenden Fehler und Verbesserungsmöglichkeiten gefunden werden konnten, wurde durch den Umstieg auf das LLM-Modell GPT-5.6 SOL ermöglicht.</li>
<li><strong>Echtzeit-Datenweg:</strong> WebSocket-Verarbeitung auf „latest value wins“ umgestellt. Messwerte, Spektrogramm sowie Goniometer/Waveform verwenden getrennte, begrenzte Datenwege; alte Frames können keine anwachsende Anzeigeverzögerung mehr bilden.</li>
<li><strong>Browserlast:</strong> Single-Slot-Puffer und Sequenzprüfung für Mess-, Spektrogramm- und Visualisierungsdaten ergänzt. Große Rohsamplefelder werden nicht mehr in jedem JSON-Messpaket wiederholt.</li>
<li><strong>Spektrogramm:</strong> Langzeitstillstand und stotterndes Nachholen beseitigt. Inkrementelles Spaltenzeichnen, Worker-ACK, Watchdog/Neustart und begrenztes Überspringen veralteter Spalten ergänzt. 0,5×, 1×, 2×, 4× und 6× besitzen nun eine feste, FFT- und DPI-unabhängige Zeitbasis.</li>
<li><strong>DIN-/EBU-PPM:</strong> Blockunabhängige Quasi-Peak-Detektoren mit bandbegrenzter Interpolation, korrekter Attack- und Rücklaufballistik sowie automatischen Tonburst-, Frequenzgang- und Polaritätstests implementiert. Eine zweite Browser-Anstiegsballistik und der alte nicht normgerechte DIN-Fast-Attack-Sonderweg wurden vollständig entfernt.</li>
<li><strong>RTW-RTA:</strong> RTW-Profil verbindlich auf die IIR-Oktavteilband-Filterbank festgelegt. Bis zur Vergleichsmessung am realen RTW-Gerät sind die bisherige Phoenix/RTW-Charakteristik und vollständige Butterworth-Bandpässe sechster Ordnung getrennt auswählbar.</li>
<li><strong>RTA-Detektor und Reaktionszeit:</strong> Average (RMS) und Peak (gleitendes 10-ms-Maximum) sind nun getrennt von Fast, Medium, Slow und Impulse wählbar. Fast wurde auf 125 ms korrigiert; Impulse verwendet 35 ms Anstieg und 1,5 s Rücklauf. Die IIR-Detektoren arbeiten samplegenau und unabhängig von der ALSA-Periodengröße. Alte Einstellungen werden automatisch migriert.</li>
<li><strong>RTA-Auflösung:</strong> Umschaltung zwischen 1/3, 1/6 und 1/12 repariert. Alle drei Auflösungen bleiben im RTW-Profil aktiv und verwenden dessen IIR-Filterbank, Darstellung und Ballistik.</li>
<li><strong>Hardwareparameter:</strong> Tatsächlich von ALSA ausgehandelte Samplerate, Perioden- und Puffergröße werden nun durch DSP, Aufnahme, Status und Browser verwendet; die feste 48-kHz-Annahme wurde entfernt.</li>
<li><strong>Goniometer:</strong> Nur neue XY-Samplepaare werden mit einer periodengrößenunabhängigen mittleren Rate von 60 Hz übertragen. Punktbegrenzung wirkt bereits auf den Transport; begrenzte und wiederverwendbare Spurpuffer reduzieren Speicher- und Zeichenlast.</li>
<li><strong>Goniometer-Persistenz:</strong> Reproduzierbare Fast-, Medium- und Slow-Profile sowie ein freies Phoenix-Fade ergänzt. Künstliche Bézier-Verformung der Messspur entfernt; AGC und Silence-Gate arbeiten zeit- beziehungsweise fensterbasiert.</li>
<li><strong>Korrelation:</strong> Kontinuierliche DSP-Messung aus L², R² und L·R mit den wählbaren Ansprechzeiten 0,5, 1,0 und 2,5 Sekunden. Der neue 0,5-Sekunden-Modus lässt den Korrelationswürfel näher am realen RTW PortaMonitor reagieren. Der konfigurierbare Mono-RMS-Silence-Threshold wird nun bis in den Audiokern übertragen und setzt den Würfel bei Grundrauschen unterhalb der gewählten Schwelle zuverlässig auf Neutralstellung. Bildratenabhängige Doppelglättung bleibt entfernt; Negative-Peak-Memory, Marker und manueller Reset sind weiterhin vorhanden.</li>
<li><strong>Phasenrad:</strong> Bandpass, Hilbert-Transformation und energiegewichtete L/R-Phasenmittelung laufen samplekontinuierlich im Audiokern statt auf ausgedünnten Browser-XY-Punkten. Die Nachleuchtspur unterteilt große Winkeländerungen nun polar in maximal zwei Grad große Segmente; dadurch werden schnelle Phasenbewegungen als sauberer Kreisbogen statt als sichtbares Acht- oder Sechzehneck gezeichnet. Paketgrenzen beeinflussen den Winkel nicht; Glättung und AGC arbeiten zeitbasiert, und die doppelte AGC-Verstärkung bleibt entfernt.</li>
<li><strong>True Peak, RMS und VU:</strong> True Peak verwendet den vierphasigen Referenz-FIR aus ITU-R BS.1770, besteht die EBU-Testfälle 15 bis 19 und zeigt Übersteuerungen in allen Ansichten bis +6 dBTP an. True RMS integriert samplekontinuierlich im Leistungsbereich mit Fast (125 ms), Slow (1 s) oder einem gleitenden 300-ms-Fenster; Browser-Doppelglättung und der RMS-fremde Impulse-Modus wurden entfernt. VU verwendet ein Moving-Coil-Modell mit 300-ms-Sprungantwort und 1 bis 1,5 % Überschwingen; der fälschliche Standardoffset von +1,92 dB wurde auf 0 dB korrigiert.</li>
<li><strong>Qualitätssicherung:</strong> Automatische Regressionstests für PPM, RTA, A/C/Z, mehrere Sampleraten, Korrelationssignale, Goniometertaktung, Binärprotokolle sowie Spektrogramm-Zeitbasis und Langlauf ergänzt.</li>
<li><strong>Gerätesicherung:</strong> Versioniertes Gesamtbackup und geprüfte Wiederherstellung für globale Geräteeinstellungen, vollständige RTA-Konfiguration, Software-Presets und Layout-Slots ergänzt. Konfigurationsdateien werden atomar ersetzt; bei einem Restore-Fehler wird der vorherige Zustand wiederhergestellt.</li>
<li><strong>Internes Pi-Display:</strong> Die Phoenix-eigenen lokalen Einstellungen des Chromium-Kiosks werden gezielt und cachefrei mit dem Gerät synchronisiert, im Gesamtbackup gesichert und nach einem Restore ausschließlich auf dem internen Display wiederhergestellt. Externe Browserprofile bleiben unberührt.</li>
<li><strong>GUI-Aufräumarbeiten:</strong> Split- und Quad-View teilen sich gemeinsame interne Bausteine; Canvas-Caches sind begrenzt, doppelte Zeichenhelfer vereinheitlicht, Optionscode aufgeteilt, direkte Stildefinitionen reduziert und der Changelog wird erst beim Öffnen geladen. Das sichtbare Layout bleibt erhalten.</li>
<li><strong>Sicht-Auswahl:</strong> Die dynamische Breite des Sicht-Dropdowns wiederhergestellt, damit Meter-Slots und weitere Bedienelemente nicht mehr unnötig in eine zweite Zeile umbrechen.</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version" open>
<summary>3.3.5 (Phoenix 1.0)</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 28.04.2026</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li>Stoppuhr: Löschfunktion verbessert. Die X-Buttons neben den einzelnen Zeiten lassen sich jetzt über einen separaten X-Button ein- und ausblenden; Reset und Löschmodus sind optisch getrennt.</li>
<li>Online Update: Hinweis ergänzt, dass die Funktion nur für die reine Analyzer-Installation gedacht ist; Volumio-Systeme sollen per Snapshot bzw. manuell aktualisiert werden.</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version" open>
<summary>3.3.4 (Phoenix 1.0)</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 24.04.2026</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li>Studio-Clock und Clock-Screensaver beim Sekundenblinken angepasst: Der Doppelpunkt läuft jetzt mit exakt 50 % Duty Cycle und schaltet auf der Sekunde an sowie in der zweiten Halbsekunde wieder aus.</li>
<li>Online Update robuster gemacht: Update-Skripte laufen jetzt mit sicherer Fallback-Locale, damit fehlendes de_DE.UTF-8 auf Volumio keine bash-setlocale-Warnung mehr als Fehler anzeigt.</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version" open>
<summary>3.3.2 (Phoenix 1.0)</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 21.04.2026</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li>INFO: Ein extern beobachtetes Start-/WebSocket-Problem in älteren Safari-Versionen lag nicht an Phoenix selbst, sondern an einem Safari-/WebKit-Bug; nach Safari-Update funktionierte der Analyzer wieder normal.</li>
<li>Recorder-Liste auf große Browserfenster verbessert: Die sichtbare Listenhöhe passt sich jetzt dynamisch an die tatsächlich verfügbare Fläche an, statt früh auf eine starre Anzahl sichtbarer Einträge zu begrenzen.</li>
<li>Recorder-Layout unten entschärft: Vor Timer und Buttons bleibt nun mehr Reserve, damit der letzte sichtbare Eintrag nicht in den Button-Bereich hineinragt, bevor gescrollt werden muss.</li>
<li>Stoppuhr-Liste ebenfalls dynamisiert: Die Anzahl sichtbarer Zeiten wird jetzt direkt aus der verfügbaren Meter-Höhe berechnet und reagiert sauberer auf unterschiedlich große Browserfenster.</li>
<li>Stoppuhr-Layout bei kleiner Höhe geglättet: Die unteren Start-/Reset-/Auto-Buttons schrumpfen bei knapper Höhe mit, damit die Liste beim Verkleinern nicht mehr schlagartig verschwindet.</li>
<li>Phoenix-Metrics-Stream robuster gemacht: Bei kurzzeitigem Rückstau werden verpasste Frames jetzt verworfen, ohne den WebSocket sofort zu beenden; zusätzlich wurde der interne Metrics-Puffer vergrößert.</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version" open>
<summary>3.3.1 (Phoenix 1.0)</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 20.04.2026</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li>Layout- und Preset-System weiter ausgebaut: Presets wurden im Optionen-Menü auf eine eigene Ebene gehoben und die Layout-Slots lassen sich zusätzlich direkt aktivieren.</li>
<li>Layout-Boot per URL stabilisiert: Toolbar, aktiver View und geladene Ansicht bleiben bei <code>?layout=1</code> und den übrigen Layout-Slots jetzt konsistenter synchron.</li>
<li>Layout-Speicherung korrigiert: Die Slot-Belegungen der Views werden beim Speichern eines Layout-Slots jetzt zuverlässig mitgesichert und beim Laden wiederhergestellt.</li>
<li>INFO: Ein zwischenzeitlich beobachtetes externes Start-/WebSocket-Problem in älteren Safari-Versionen lag nicht an Phoenix selbst, sondern an einem Safari-/WebKit-Bug; nach einem Safari-Update funktionierte der Analyzer wieder normal.</li>
<li>Weitere Anzeigeoptionen globalisiert: RTA-Oktaven, Peak-History-Optionen, Phase-Wheel-Optionen, Classic-Needles-Quelle, historische RTW-Loudness sowie die konfigurierbaren Farben laufen nun browserübergreifend über die globale Phoenix-Konfiguration.</li>
<li>Quad-/Registry-Pfad korrigiert: Peak History bleibt auch im Quad-View mit gültigen Live-Daten versorgt und fällt dort nicht mehr scheinbar auf 0 zurück.</li>
<li>Online-Update ergänzt: Phoenix kann eine feste Update-ZIP laden, in einen Stage-Ordner vorbereiten und beim Neustart mit Backup, Health-Check und automatischem Rollback auf den alten Stand umschalten.</li>
<li>Online-Update weiter gehärtet: Vor dem Neustart der Kiosk-Browser werden nur die Asset-Caches geleert, damit nach einem Update zuverlässig der neue Web-Stand geladen wird, ohne die eigentlichen Einstellungen zu verlieren.</li>
<li>Online-Update-Logging ergänzt: Download-, Entpack-, Stage-, Neustart- und Rollback-Schritte werden jetzt in eine gemeinsame Update-Logdatei geschrieben und können zusätzlich über einen Phoenix-Endpunkt direkt abgefragt werden.</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version" open>
<summary>3.3 (Phoenix 1.0)</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 20.04.2026</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li>Software-Presets weiter ausgebaut: Default, Claus und Michael lassen sich jetzt robuster speichern, laden, exportieren und importieren.</li>
<li>Preset-Speicherung bleibt aus Sicherheitsgründen auf den Analyzer direkt beschränkt; externe Browser dürfen Presets laden, aber nicht überschreiben.</li>
<li>Phoenix-URL-/Host-Pfad für Presets gehärtet: Wenn eine gespeicherte Loopback-URL für den aktuellen Browser nicht passt, wird sauber auf den aktuellen Analyzer-Host zurückgefallen.</li>
<li>Preset-Ladepfad stabilisiert: Benutzer-Presets übernehmen jetzt auch den getrennt gehaltenen UI-/View-Zustand zuverlässiger und laden nach Profil-/Cache-Reset wieder nachvollziehbar.</li>
<li>Neue Layout-Slots ergänzt: Vier separate Layout-Presets speichern reine Ansichts-/Fensterbelegungen, unabhängig von globalen Software-Presets.</li>
<li>Layout-Slots werden außerhalb des Browser-Profils serverseitig gespeichert und können per URL-Parameter wie <code>?layout=1</code> direkt in einem Fenster geladen werden.</li>
<li>Options-Menü neu geordnet: Unter <code>Meters → Presets</code> liegen jetzt sowohl die Software-Presets als auch die neuen Layout-Slots gebündelt an einer Stelle.</li>
<li>Erststart-Dialog überarbeitet: statt des alten Kalibrierhinweises erscheint nun eine neutrale Willkommensmeldung zum Phoenix Analyzer.</li>
<li>Header-Farbpfad vereinheitlicht: Header-Text/-Zahlen entkoppelt von Balkenfarben und zusätzlich über eine globale Header-Textfarbe einstellbar.</li>
<li>Waveform-Farben reagieren jetzt wieder korrekt auf Änderungen in den Optionen, auch im Worker-Pfad.</li>
<li>Split-/Quad-Views erweitert: Waveform und Spectrogram können dort als Plot verwendet werden; eingebettete Geometrie und Darstellung wurden dafür nachgezogen.</li>
<li>Weitere UI-/Detailkorrekturen: Peak-History-Geometrie, RTA-/Meter-Beschriftungen, PPM-DIN-%-Skala, Recorder-/Stoppuhr-Header sowie diverse kleinere Konsistenzfehler wurden bereinigt.</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version" open>
<summary>3.1 (Phoenix 1.0)</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 18.04.2026</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li>GUI-Performance systematisch überarbeitet: statische Layer werden projektweit gecacht statt pro Frame neu gezeichnet.</li>
<li>Renderloop bereinigt: Hintergrund-Layer gecacht und Zeichenpfad stärker auf echte Audio-/UI-Änderungen ausgerichtet.</li>
<li>Spectrogram deutlich entlastet: Phoenix liefert Spectro-Spalten nur noch bei echten neuen FFT-Schritten, Worker-Takt reduziert und interne Renderauflösung angepasst.</li>
<li>Waveform deutlich entlastet: Worker-Takt reduziert, interne Renderauflösung angepasst und Grid-Layer gecacht.</li>
<li>Peak History deutlich entlastet: Grid, Rahmen und Zeit-/Werteskalen werden gecacht; Verlauf und Live-Meter bleiben dynamisch.</li>
<li>Peak History in der Geometrie nachkorrigiert: `now` rechts sichtbar, linke Endzeit wird explizit gesetzt, Füllung läuft bis zum unteren Rahmen, Verlauf kann unter den letzten Tick weiterlaufen und der Plot beginnt links ohne künstliche Rahmenlücke.</li>
<li>Meter-Views weiter entschlackt: feste Skalen, Ticks, Referenzlinien und Labels von VU, PPM DIN, PPM EBU, RMS, TP und LUFS werden nicht mehr jedes Frame neu gerastert.</li>
<li>Phase Wheel entlastet: tote Trace-Berechnung entfernt und Phase-Trail intern ausgedünnt.</li>
<li>Goniometer/XY weiter bereinigt: RTW-Look beibehalten, Alpha-/Trail-Dichte entschlackt und echte XY-History in Phoenix so angepasst, dass `XY Punkte` wieder sinnvoll wirkt.</li>
<li>Split- und Quad-View bereinigt: statische Containerteile, leere Plots und Meterhüllen werden gecacht statt pro Frame neu aufgebaut.</li>
<li>Recorder- und Classic-Needles-View weiter entschlackt: statische Bühnen, Rahmen und Instrumentenkörper gecacht; beim Recorder zusätzlich unnötige HUD-Layout-Schreiberei reduziert.</li>
<li>RTA/Realtime entlastet: Plot-Grid, Rahmen und rechte Meterhülle werden gecacht, während Balken/Line und Hold/Peak live bleiben.</li>
<li>Projektweit weiterer Cleanup: tote Frontend-Restlogik entfernt, Referenz-Configs für Analyzer/Volumio ergänzt und Doku zu Analyzer-vs.-Volumio-Pfaden/Ports nachgezogen.</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version" open>
<summary>3.0.1 (Phoenix 1.0)</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 18.04.2026</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li>PPM/VU/RMS-Alignment korrigiert: PPM DIN, PPM EBU, VU und RMS liegen bei den vorgesehenen Referenzpegeln wieder konsistent zueinander.</li>
<li>RTW-Loudness-Boxen auf dokumentierbaren Momentary-Pfad umgestellt: 400 ms Integration, K-gewichtete Signalbasis, pro Kanal; keine freie ungefilterte Hauskurve mehr.</li>
<li>RTW-Loudness-Offset global gemacht: Der Offset wird jetzt in Phoenix gespeichert und zwischen verbundenen Browsern synchron gehalten.</li>
<li>RTW-Oktavwahl lokalisiert: Die sichtbare RTW-Auflösung 1/3, 1/6 und 1/12 ist jetzt browserlokal und schaltet andere Sessions nicht mehr um.</li>
<li>Fehler in der lokalen 1/6-Oktavdarstellung behoben: Center-Frequenzen und Bandwerte laufen wieder sauber synchron.</li>
<li>Goniometer weiter auf klassische RTW-Anmutung geschärft: dichtere Spur, dickere Linien, geglättete Kurven; dieser Stil ist jetzt fest der Standard.</li>
<li>Volumio-/Raspberry-Pi-Startpfad robuster gemacht: Toolchain-, Netzwerk-, Build- und Session-Handling wurden für frische Geräte und getrennte Volumio-Services nachgezogen.</li>
<li>Build-Kompatibilität für ältere Zielsysteme nachgezogen: Lockfile-/Toolchain-Themen, ALSA-/pkg-config-Abhängigkeiten und plattformspezifische ALSA-Frame-Typen abgefangen.</li>
<li>Volumio-Pfad weiter bereinigt: Analyzer-GUI läuft dort getrennt auf eigenem Port, `toggle-ext` liegt im Phoenix-Baum und der Tab-Umschalter zeigt wieder auf die richtigen Ziele.</li>
<li>Projektlayout weiter bereinigt: `toggle-ext` und die Volumio-spezifischen Startdateien liegen jetzt konsistent im Phoenix-Baum.</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version" open>
<summary>3.0 (Phoenix 1.0)</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 18.04.2026</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li>Kompletter Architektur-Umbau: Der Analyzer läuft jetzt nativ auf Phoenix statt auf WebAudio/AudioWorklet im Browser.</li>
<li>Phoenix ist neue Single Source of Truth für Audio, Analyse und globale Gerätekonfiguration; die Web-GUI dient nur noch als Anzeige- und Bedienoberfläche.</li>
<li>Audio-Pfad vollständig in Rust/ALSA verlagert: Browser greift nicht mehr selbst auf Audio-Hardware zu.</li>
<li>Analysepfade nach Phoenix übertragen: Meter, RMS, VU, PPM DIN/EBU, True Peak, LUFS/LRA, RTW-Loudness-Boxen, RTA, Spectrogram, Waveform, XY und Phase Wheel werden jetzt nativ im Backend erzeugt.</li>
<li>Recorder komplett auf Phoenix umgestellt: native Aufnahme in WAV, MP3 und WebM/Opus ohne Browser-Chunking oder nachträgliches Zusammensetzen im Frontend.</li>
<li>Direktes Speichern über Phoenix ergänzt: Aufnahmen können ohne Chrome-Download direkt auf dem Gerät im Dateisystem abgelegt werden.</li>
<li>Globale Optionen in Phoenix verankert: geräterelevante Parameter wie Kalibrierung, LUFS-/PPM-Verhalten, Recorder-Defaults und Screensaver-Verhalten werden zentral gespeichert und an verbundene Browser synchronisiert.</li>
<li>Waveform- und XY-Datenpfad auf native Phoenix-Frames umgestellt; Browser verarbeitet keine eigenen Audio-Samples mehr.</li>
<li>Normnähe der Messpfade überarbeitet: True Peak, LUFS, VU sowie mehrere Ballistik- und Bewertungswege rechnerisch an die relevanten Standards angenähert.</li>
<li>Komplette Bereinigung des alten WebAudio-Stacks: Browser-Recorder, MediaRecorder-/Chunk-Logik, AudioWorklet-Altpfade und nicht mehr benötigte Umschaltlogik wurden entfernt.</li>
<li>Deployment/Boot für Raspberry Pi neu strukturiert: separater Webservice, Kiosk-Startskripte, automatischer Phoenix-Build bei Quellcode-Änderungen sowie Build-Logging beim Start.</li>
<li>Projektstruktur auf Phoenix konsolidiert: Runtime-Skripte, `www` und systemd-Units liegen jetzt gemeinsam unter dem Phoenix-Baum.</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version" open>
<summary>2.9</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 09.04.2026</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li>Recorder: Auto-Split-Threshold jetzt als gekoppelte Zahlenfelder in dBFS und PPM DIN einstellbar.</li>
<li>Stoppuhr: Auto-Threshold jetzt ebenfalls als gekoppelte Zahlenfelder in dBFS und PPM DIN einstellbar.</li>
<li>Optionen: Zusätzlichen Scroll-Puffer im Optionen-Menü ergänzt, damit untere Eingabefelder trotz On-Screen-Tastatur weiter nach oben gezogen werden können.</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version" open>
<summary>2.8 (HOTFIX)</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 25.03.2026</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li>Audio/Timing: Zusätzlichen Display-/Interpolationspuffer entfernt; Anzeigen werden wieder direkt aus dem AudioWorklet gespeist.</li>
<li>Low Latency: Worklet-UI-Intervall auf 8 ms reduziert und Renderpfad auf 60 FPS gestellt.</li>
<li>Performance: Audio- und Analysepfade berechnen nur noch, was im aktuellen View und in den sichtbaren Slots wirklich benötigt wird.</li>
<li>Cold Start: XY-, RTA-, Waveform- und Meter-Zustände werden beim Umschalten sauber zurückgesetzt, damit keine alten Daten kurz wieder auftauchen.</li>
<li>Performance: FFT- sowie LUFS-/Box-Analyser laufen nur noch bei tatsächlichem Sichtbarkeitsbedarf.</li>
<li>RTA: Neue Option für die Balken-Grundfarbe; Peak-Bereiche bleiben weiterhin rot.</li>
<li>RTA: „Real Time Hold“ und „Real Time Decay“ wirken jetzt tatsächlich auf die Balkenanzeige; höherer Decay lässt die Balken schneller zurückfallen.</li>
<li>UI: Zahlenfelder committen jetzt sauber per Change/Blur/Enter; Eingaben über On-Screen-Keyboard und VNC springen nicht mehr vorzeitig auf Min-/Max-Werte.</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version" open>
<summary>2.5</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 22.03.2026</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li>Fix: Classic Needles im Quad-View werden nun korrekt angezeigt.</li>
<li>Audio/Timing: Umstellung auf Audio-Zeitstempel und frame-synchron berechnete Display-Pakete zur Glättung der Anzeige und Reduktion von Drift/Jitter.</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version" open>
<summary>2.3.7</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 04.02.2026</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li>Fix: Audio-Pfad bleibt zuverlässig aktiv und verhindert „Stall“ nach langem Idle/Screensaver.</li>
<li>Fix: Auto-Recovery bei „Audio lost“ nach Screensaver-Wake und User-Activity.</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version" open>
<summary>2.3.6</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 04.02.2026</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li>Fix: Loudness/LUFS rechter Kanal korrekt normalisiert.</li>
<li>Optimierung: LRA/Loudness ohne wiederholte Array-Allokationen (Ringpuffer + Scratch-Buffer) für weniger GC/CPU-Spikes.</li>
<li>Fix: Config-Load ist jetzt idempotent (verhindert doppeltes Laden beim Start/Options-Init).</li>
<li>PPM DIN: Peak-Hold implementiert (rein visuell), steuerbar über PPM_DIN_HOLD_MS und PPM_DIN_HOLD_DECAY_DB_PER_S.</li>
<li>Peak History: Historie als Ringpuffer statt copyWithin-Shift (weniger CPU, stabilere Darstellung).</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version" open>
<summary>2.3.4</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 04.03.2026</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
</ul>
</div>
</div>
</details>
<details class="changelog-version" open>
<summary>2.3.2</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 02.02.2026</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li>Peak History: Nach dem Beenden des Bildschirmschoners wird die History nicht mehr in einem Frame mit einem Wert „aufgefüllt“ (kein Backfill nach Pause); nutzt nur frische Audiodaten.</li>
<li>XY / Korrelation: Neue Option „Sofortige Nullstellung wenn kein Pegel“ (setzt den Würfel bei Stille sofort auf 0, ohne Hold/Drift).</li>
<li>Stoppuhr (SlotMeter): Fix für CanvasState/FontLeak, der nach Aktivierung die SkalenSchrift in anderen Views riesig gemacht hat.</li>
<li>Audio/Timing: Anzeige-Update-Intervall reduziert und an die effektive RenderFPS gekoppelt (verringert Jitter/Drift, verhindert MessageBacklogs).</li>
<li>Default: FFT_SIZE auf 4096 reduziert (weniger CPU; betrifft v.a. Spectrogram/LUFS/FFTRTA).</li>
<li>Performance: Keine FFTBerechnung mehr, wenn kein Spectrogram aktiv ist und der Real Time Analyzer auf IIR läuft.</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version" open>
<summary>2.3.1</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 30.01.2026</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li>XY / Korrelation: Nach dem Beenden des Bildschirmschoners wird die Anzeige sauber zurückgesetzt und nur mit frischen Audiodaten berechnet (kein Sprung auf +1 ohne Signal).</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version" open>
<summary>2.3</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 26.01.2026</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li>QuadView: KlickVerhalten wählbar (direkt wechseln oder PopupAnsicht).</li>
<li>QuadView: PopupAnsicht rendert exakt wie die EinzelView (Layout/Skalen identisch); Tap/ESC schließt.</li>
<li>SplitView: KlickVerhalten wählbar (direkt wechseln oder PopupAnsicht).</li>
<li>SplitView: PopupAnsicht rendert exakt wie die EinzelView; Tap/ESC schließt.</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version" open>
<summary>2.1</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 26.01.2026</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li>SplitView: kompaktere HUDBedienung (schmalere PlotDropdowns, Trennzeichen „|“).</li>
<li>SplitView: SlotZuordnung über Button + Popup (zeigt nur aktive Slots).</li>
<li>SplitView: Popup schließt per Klick außerhalb oder ESC.</li>
<li>SplitView: „Slot:“ wird nur angezeigt, wenn mindestens ein Slot aktiv ist.</li>
<li>Neu: QuadView (4 unabhängige Plots im 2×2Raster).</li>
<li>QuadView: PlotAuswahl über Popup; eigene SlotKonfiguration (unabhängig vom SplitView).</li>
<li>Phase Wheel: PhaseBox im SplitView korrekt positioniert (stabil am PlotPanel, mit Abstand; Box kleiner).</li>
<li>Bildschirmschoner: RecorderUI/Modals sowie SlotPopup werden zuverlässig ausgeblendet.</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version" open>
<summary>2.0</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 22.01.2026</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li>Neu: SplitView (zwei Plots nebeneinander, Links/Rechts wählbar).</li>
<li>SplitView: optionale MeterSlots (03) mit frei wählbarer Position (links/mitte/rechts).</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version" open>
<summary>1.8</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 17.01.2026</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li>LUFS (Integrated): IZeitfenster wird korrekt gespeichert und angewendet.</li>
<li>LUFS (Integrated): I bleibt bei Stille (unter 70 LUFS Gate) stehen und fällt nicht weiter ab.</li>
<li>Phasenrad: Zahlenanzeige folgt dem Zeiger besser (ruhiger, ohne Nachkommastelle).</li>
<li>Neu: Stoppuhr als auswählbarer Slot (Start/Stop/Reset/Auto, optional 7SegmentAnzeige).</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version" open>
<summary>1.7.8</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 16.01.2026</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li>LUFS (Integrated): Zeitfenster per Slider (110 min) einstellbar.</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version" open>
<summary>1.7.7</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 09.01.2026</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li>Input-Offset: kanalgetrennte Einstellung (L/R).</li>
<li>LUFS: Lesbarkeit der Skala angepasst.</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version" open>
<summary>1.7.5</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 31.12.2025</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li>Phase Wheel: Phasenrad gespiegelt (z.B. +90° liegt rechts).</li>
<li>PPM DIN: Ballistik im eigenen AudioWorklet; Anzeige/Ballistik nach IEC 60268-10 Type I (DIN) korrigiert.</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version" open>
<summary>1.7.3</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 29.12.2025</p>
<p class="changelog-heading">Neuerungen:</p>
<ul class="changelog-list">
<li>LUFS: Skalen-Zahlen &amp; Skalen-Striche in eigener Farbe einstellbar.</li>
<li>Phase Wheel: Phasenanzeige (Box links oben) größer und weiter links; Zahl zusätzlich geglättet und Update-Rate begrenzt (max. 10×/s) für bessere Lesbarkeit.</li>
<li>PPM DIN: RTW-Loudness-Offset als Schieberegler (7 … +7 dB).</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version" open>
<summary>1.7 (Auslieferungsversion)</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 21.12.2025</p>
<p class="changelog-heading">Neuerungen:</p>
<ul class="changelog-list">
<li>Slots können auf „(leer)“ gesetzt werden: der Slot wird ausgeblendet und die verbleibenden Anzeigen rücken nach (z.B. nutzt der Real Time Analyzer dann die volle Breite).</li>
<li>Peak History und Classic Needles benötigen einen belegten Slot; dort ist „(leer)“ deaktiviert.</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version" open>
<summary>1.6</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 19.12.2025</p>
<p class="changelog-heading">Neuerungen:</p>
<ul class="changelog-list">
<li>Neuer View: „Classic Needles“.</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version" open>
<summary>1.5.5</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 08.12.2025</p>
<p class="changelog-heading">Neuerungen:</p>
<ul class="changelog-list">
<li>Silence-Gate für Goniometer &amp; Korrelation (RMS-basiert, Threshold einstellbar im XY-Menü).</li>
<li>Header-Werte pro Kanal: sauber zentriert über den Balken, Farbumschlag pro Kanal erst ab Rot-Schwelle.</li>
<li>Anzeige der vollen Pegel (auch unterhalb der Skala) mit sanfter Glättung der Zahlen.</li>
<li>RMS-Meter: hält jetzt den letzten gültigen Wert, kein kurzer Drop mehr auf den Floor bei sporadisch fehlenden RMS-Paketen.</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version" open>
<summary>1.5</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 05.12.2025</p>
<p class="changelog-heading">Peak History Neu hinzugefügt:</p>
<ul class="changelog-list">
<li>Neuer „Peak History“-View: verlaufende Pegelanzeige mit Meter rechts daneben.</li>
<li>Konfigurierbare Scroll-Geschwindigkeit (½×, 1×, 2×, 4×, 6×) wie beim Spectrogram.</li>
<li>Farbgenaue Linie: Rot erst ab Warnschwelle (z.B. PPM_DIN_RED_START), Segment-Splitting genau auf der Schwelle.</li>
<li>Optionale Flächenfüllung unter/über der Linie in Linienfarbe; invertierbar.</li>
<li>Zeitskala passt sich der Scroll-Geschwindigkeit an (länger bei ½×, kürzer bei 2×/4×/6×).</li>
<li>Peak-History-Optionen im Menü: Scroll-Speed, Fläche füllen, Fläche invertieren.</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version">
<summary>1.2</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 28.11.2025</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li>Studio Clock: neue Stile „Analog + PPM“ und „Digital + PPM“; optional LED-Band für PPM.</li>
<li>PPM in der Clock nutzt DIN-Skala, Offsets und Ballistik identisch zum PPM-DIN-Meter.</li>
<li>PPM-Skalenfarben vereinheitlicht (DIN-Orange), Warnfarbe nur oberhalb 0&nbsp;dB.</li>
<li>Neue Uhr-Funktion: Studio Clock mit Stilen „Analog“, „Digital“, „Analog + PPM“, „Digital + PPM“, optional LED-Band.</li>
<li>Neuer Bildschirmschoner: DVD/Starfield/Clock, Vorschau ignoriert Pegel (endet nur per Klick/Touch), Optionen gebündelt.</li>
<li>Clock- und Screensaver-Optionen sind unabhängig im Menü steuerbar.</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version">
<summary>1.1.4.1</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 26.11.2025</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li>Goniometer-Korrelationswürfel: Clamping angepasst (bleibt sauber innerhalb des Balkens)</li>
<li>PPM DIN: Skala renormiert (Bottom sitzt am Balkenfuß), %-Label korrigiert</li>
<li>PPM DIN: Loudness-Boxen verlängerte Skala (bis 70 dB), sichtbarer Teil nur oberhalb 50 dB</li>
<li>Input-Offset: Slider auf 0,5-dB-Schritte gestellt</li>
<li>Recorder: Einmaliger Hinweis/Bestätigung bei Erstaufruf (WAV-Limit, WebM/Opus-Hinweis, Beta)</li>
<li>LUFS: Farben pro I/M/S separat einstellbar</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version">
<summary>1.1.4</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 25.11.2025</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li>XY-Optionen wiederhergestellt und zugeordnet (Punkte/Stil/Korrelation/Gain/Fade/Gap)</li>
<li>XY Default-Punkte auf 1024 gesetzt; Hinweise ergänzt</li>
<li>Spectrogram/Waveform/Phase Wheel: Beschreibungen ergänzt</li>
<li>Spectrogram: Worker stabilisiert (Ringpuffer, Draw-Loop-Fix)</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version">
<summary>1.1.3.2</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 25.11.2025</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li>Fix: Spectrogram-Worker stabilisiert (kein Stoppen nach wenigen Sekunden)</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version">
<summary>1.1.3.1</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 25.11.2025</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li>Fix: RTW-Loudness-Boxen im PPM DIN Meter (fehlendes Timestamp-Init behoben)</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version">
<summary>1.1.3</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 25.11.2025</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li>Optionen-Überschrift umbenannt &amp; unterstrichen</li>
<li>RMS: IEC-Zeitkonstanten (Impulse/Fast/Slow) auswählbar, EWMA entfernt</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version">
<summary>1.1.2</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 25.11.2025</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li>Einführung: Changelog im Optionen-Menü</li>
<li>Korrektur: PPM DIN &amp; PPM EBU ohne doppelte Ballistik (UI-Glättung entfernt)</li>
<li>Korrektur: RMS-Anzeige ohne zusätzliche Glättung</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version">
<summary>1.1</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 25.11.2025</p>
<p class="changelog-heading">Änderungen:</p>
<ul class="changelog-list">
<li>Hinzugefügt: RTW-Style Loudnessanzeige auf PPM DIN Meter</li>
</ul>
</div>
</div>
</details>
<details class="changelog-version">
<summary>1.0</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Erste Veröffentlichung: 21.11.2025</p>
</div>
</div>
</details>
</div>
+70 -7
View File
@@ -1,7 +1,12 @@
// core/audio.js — Phoenix Audio-Init, Metrics-WS, Waveform/Spectrogram buffers
// Wird von main.js mit `initAudio(env)` gestartet. Nutzt env.config (CONFIG) und env.meters.
import { applyPhoenixGlobalConfig, buildPhoenixGlobalConfigPayload, saveConfig } from './config.js';
import {
applyPhoenixGlobalConfig,
buildPhoenixGlobalConfigPayload,
normalizeCorrelationResponseSeconds,
saveConfig,
} from './config.js';
import { decodePhoenixSpectroBuffer, decodePhoenixVisualsBuffer } from './binary_protocol.js';
import { getRtwCenters } from './rtw_centers.js';
@@ -9,6 +14,7 @@ import { getRtwCenters } from './rtw_centers.js';
let phoenixSocket = null;
let phoenixSpectroSocket = null;
let phoenixSpectroRetryTimer = null;
let phoenixSpectroDemanded = false;
let pendingSpectroBuffer = null;
let phoenixSpectroRaf = 0;
let phoenixVisualsSocket = null;
@@ -99,6 +105,7 @@ function closePhoenixSocket() {
} catch (_) {}
}
phoenixSpectroSocket = null;
phoenixSpectroDemanded = false;
pendingSpectroBuffer = null;
if (phoenixSpectroRaf) cancelAnimationFrame(phoenixSpectroRaf);
phoenixSpectroRaf = 0;
@@ -253,6 +260,7 @@ function copyPhoenixSpectroBins(audioState, spectro, frameDelta = 1) {
}
function openPhoenixSpectroSocket(baseUrl, env) {
if (!phoenixSpectroDemanded) return;
if (phoenixSpectroSocket && (
phoenixSpectroSocket.readyState === WebSocket.OPEN
|| phoenixSpectroSocket.readyState === WebSocket.CONNECTING
@@ -269,7 +277,7 @@ function openPhoenixSpectroSocket(baseUrl, env) {
const retry = () => {
if (phoenixSpectroSocket === socket) phoenixSpectroSocket = null;
if (!phoenixSocket || phoenixSocket.readyState !== WebSocket.OPEN) return;
if (!phoenixSpectroDemanded || !phoenixSocket || phoenixSocket.readyState !== WebSocket.OPEN) return;
if (phoenixSpectroRetryTimer) clearTimeout(phoenixSpectroRetryTimer);
phoenixSpectroRetryTimer = setTimeout(() => {
phoenixSpectroRetryTimer = null;
@@ -280,6 +288,31 @@ function openPhoenixSpectroSocket(baseUrl, env) {
socket.onclose = retry;
}
function setPhoenixSpectroDemand(baseUrl, env, demanded) {
const next = !!demanded;
phoenixSpectroDemanded = next;
if (next) {
if (phoenixSocket?.readyState === WebSocket.OPEN) openPhoenixSpectroSocket(baseUrl, env);
return;
}
if (phoenixSpectroRetryTimer) clearTimeout(phoenixSpectroRetryTimer);
phoenixSpectroRetryTimer = null;
if (phoenixSpectroSocket) {
const socket = phoenixSpectroSocket;
phoenixSpectroSocket = null;
try {
socket.onopen = null;
socket.onmessage = null;
socket.onerror = null;
socket.onclose = null;
socket.close();
} catch (_) {}
}
pendingSpectroBuffer = null;
if (phoenixSpectroRaf) cancelAnimationFrame(phoenixSpectroRaf);
phoenixSpectroRaf = 0;
}
function scheduleSpectroBufferPump(env) {
if (phoenixSpectroRaf || !pendingSpectroBuffer) return;
phoenixSpectroRaf = requestAnimationFrame(() => {
@@ -416,6 +449,11 @@ async function applyIncomingAudioPacket(env, packet, CONFIG, sampleTs = performa
if (typeof d.correlationNegativePeak === 'number') {
env.audio.correlationNegativePeak = d.correlationNegativePeak;
}
env.audio.phaseAngleRad = typeof d.phaseAngleRad === 'number' ? d.phaseAngleRad : null;
env.audio.phaseSeq = Number.isFinite(seq) ? seq : ((env.audio.phaseSeq || 0) + 1);
if (typeof d.phaseCoherence === 'number') env.audio.phaseCoherence = d.phaseCoherence;
if (typeof d.phaseLevel === 'number') env.audio.phaseLevel = d.phaseLevel;
if (typeof d.phasePeak === 'number') env.audio.phasePeak = d.phasePeak;
if (d.waveL && env.audio.pushWaveSamples) {
const channelCount = d.waveChannels || (d.waveR ? 2 : 1);
env.audio.sampleRate = d.sampleRate || env.audio.sampleRate || 48000;
@@ -474,6 +512,10 @@ function buildPhoenixMeterPacket(frame) {
const ppmBoxR = Number(frame?.ppm_box_r);
const correlation = Number(frame?.correlation);
const correlationNegativePeak = Number(frame?.correlation_negative_peak);
const phaseAngleRad = Number(frame?.phase_angle_rad);
const phaseCoherence = Number(frame?.phase_coherence);
const phaseLevel = Number(frame?.phase_level);
const phasePeak = Number(frame?.phase_peak);
const xyL = Array.isArray(frame?.xy_l) ? frame.xy_l : null;
const xyR = Array.isArray(frame?.xy_r) ? frame.xy_r : null;
const waveL = Array.isArray(frame?.wave_l) && frame.wave_l.length ? frame.wave_l : null;
@@ -487,6 +529,10 @@ function buildPhoenixMeterPacket(frame) {
correlationNegativePeak: Number.isFinite(correlationNegativePeak)
? Math.max(-1, Math.min(1, correlationNegativePeak))
: 0,
phaseAngleRad: Number.isFinite(phaseAngleRad) ? phaseAngleRad : null,
phaseCoherence: Number.isFinite(phaseCoherence) ? Math.max(0, Math.min(1, phaseCoherence)) : 0,
phaseLevel: Number.isFinite(phaseLevel) ? Math.max(0, phaseLevel) : 0,
phasePeak: Number.isFinite(phasePeak) ? Math.max(0, phasePeak) : 0,
rmsL: Number.isFinite(rmsL) ? rmsL : -120,
rmsR: Number.isFinite(rmsR) ? rmsR : -120,
tpL: Number.isFinite(tpL) ? tpL : -120,
@@ -516,6 +562,9 @@ function buildPhoenixMeterPacket(frame) {
xyR,
rta: rta ? {
engine: String(rta?.engine || 'iir'),
filterbank: String(rta?.filterbank || 'legacy'),
detector: String(rta?.detector || 'average'),
response: String(rta?.response || 'fast'),
bands_avg: Array.isArray(rta?.bands_avg) ? rta.bands_avg : (Array.isArray(rta?.bands) ? rta.bands : []),
bands_peak: Array.isArray(rta?.bands_peak) ? rta.bands_peak : [],
bands: Array.isArray(rta?.bands) ? rta.bands : (Array.isArray(rta?.bands_avg) ? rta.bands_avg : []),
@@ -868,9 +917,11 @@ export function buildRtaRuntimeConfig(CONFIG = {}) {
const bpoMode = CONFIG.RTA_BPO_MODE || '1_3';
const layout = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'rtw' : 'iec';
const rtwProfile = layout === 'rtw';
const runtimeBpoMode = rtwProfile ? '1_3' : bpoMode;
const runtimeBpoMode = bpoMode;
return {
engine: rtwProfile ? 'iir' : (CONFIG.RTA_ENGINE || 'iir'),
filterbank: CONFIG.RTA_IIR_FILTERBANK === 'butterworth' ? 'butterworth' : 'legacy',
detector: CONFIG.RTA_DETECTOR === 'peak' ? 'peak' : 'average',
fftSize: CONFIG.FFT_SIZE || 4096,
monoInput: !!CONFIG.MONO_INPUT,
lrFractionalDelayEnabled: !!CONFIG.LR_FRACTIONAL_DELAY_ENABLED,
@@ -879,20 +930,28 @@ export function buildRtaRuntimeConfig(CONFIG = {}) {
freqRange: rtwProfile ? 'norm' : (CONFIG.RTA_FREQ_RANGE || 'norm'),
weighting: CONFIG.RTA_WEIGHTING || 'z',
order: rtwProfile ? 6 : (CONFIG.RTA_IIR_ORDER || 6),
tauFast: CONFIG.RTA_IIR_TAU_FAST || 0.12,
tauFast: CONFIG.RTA_IIR_TAU_FAST || 0.125,
tauSlow: CONFIG.RTA_IIR_TAU_SLOW || 1.0,
integration: CONFIG.RTA_INTEGRATION || 'fast',
layout,
rtaPeakHoldMode: ['off', 'auto', 'fall', 'manual'].includes(CONFIG.RTA_PEAK_HOLD_MODE)
? CONFIG.RTA_PEAK_HOLD_MODE
: 'fall',
rtaPeakHoldSeconds: Number.isFinite(CONFIG.RTA_PEAK_HOLD_SEC) ? CONFIG.RTA_PEAK_HOLD_SEC : 2.5,
rtaPeakDecayDbPerSecond: Number.isFinite(CONFIG.RTA_PEAK_DECAY_DB_PER_S) ? CONFIG.RTA_PEAK_DECAY_DB_PER_S : 20,
rtaPeakResetToken: Math.max(0, Math.floor(Number(CONFIG.RTA_PEAK_RESET_TOKEN) || 0)),
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: 10,
ppmDinDecayDbPerS: 20 / 1.5,
ppmDinFastAttack: !!CONFIG.PPM_DIN_FAST_ATTACK,
ppmEbuAttackMs: 10,
ppmEbuDecayDbPerS: 24 / 2.8,
lufsIWindowMin: Number.isFinite(CONFIG.LUFS_I_WINDOW_MIN) ? CONFIG.LUFS_I_WINDOW_MIN : 4,
lufsINormEnabled: !!CONFIG.LUFS_I_NORM_ENABLED,
correlationResponseS: Number(CONFIG.CORR_RESPONSE_S) >= 1.75 ? 2.5 : 1.0,
correlationResponseS: normalizeCorrelationResponseSeconds(CONFIG.CORR_RESPONSE_S),
correlationSilenceThresholdRmsDbfs: Number.isFinite(CONFIG.CORR_SILENCE_THRESHOLD_RMS_DBFS)
? Math.max(-90, Math.min(-40, CONFIG.CORR_SILENCE_THRESHOLD_RMS_DBFS))
: -75,
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)
@@ -1038,6 +1097,7 @@ async function initPhoenixAudio(env) {
? cfg
: ((typeof env?.getProcessingProfile === 'function') ? env.getProcessingProfile() : null);
const profile = rawProfile || {};
setPhoenixSpectroDemand(baseUrl, env, profile.needSpectro);
if (!profile.needXy) {
env.audio.xyL = null;
env.audio.xyR = null;
@@ -1052,6 +1112,9 @@ async function initPhoenixAudio(env) {
resetWaveformStateFallback(env.audio.waveformFallback);
}
};
env.audio.updateProcessingConfig(
(typeof env?.getProcessingProfile === 'function') ? env.getProcessingProfile() : null,
);
const pushPhoenixGlobalConfig = async () => {
const response = await requestPhoenixGlobalConfigUpdate(baseUrl, buildPhoenixGlobalConfigPayload());
@@ -1100,7 +1163,7 @@ async function initPhoenixAudio(env) {
};
socket.onopen = () => {
openPhoenixSpectroSocket(baseUrl, env);
if (phoenixSpectroDemanded) openPhoenixSpectroSocket(baseUrl, env);
openPhoenixVisualsSocket(baseUrl, env);
finish(true);
};
+18
View File
@@ -0,0 +1,18 @@
// Gemeinsame Erzeugung temporärer Canvas-Flächen für Views und Meter.
export function createCanvasSurface(width, height) {
const w = Math.max(1, Math.ceil(width));
const h = Math.max(1, Math.ceil(height));
if (typeof OffscreenCanvas === 'function') {
const canvas = new OffscreenCanvas(w, h);
const ctx = canvas.getContext('2d');
if (ctx) return { canvas, ctx, width: w, height: h };
}
if (typeof document !== 'undefined') {
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (ctx) return { canvas, ctx, width: w, height: h };
}
return null;
}
+77 -34
View File
@@ -1,5 +1,13 @@
// core/config.js — zentrale CONFIG + Presets + Persistenz
export function normalizeCorrelationResponseSeconds(value) {
const seconds = Number(value);
if (!Number.isFinite(seconds) || seconds <= 0) return 1.0;
if (seconds < 0.75) return 0.5;
if (seconds < 1.75) return 1.0;
return 2.5;
}
function isLoopbackHost(host) {
const h = String(host || '').trim().toLowerCase();
return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '[::1]';
@@ -50,6 +58,7 @@ const CONFIG = {
REALTIME_BAR_HOLD_MS: 800,
REALTIME_BAR_DECAY_DB_PER_S: 20,
RTA_ENGINE: 'iir', // 'fft' | 'iir'
RTA_IIR_FILTERBANK: 'legacy', // 'legacy' | 'butterworth'
RTA_FREQ_RANGE: 'norm', // 'norm' | 'lf'
RTA_BPO_MODE: '1_3',
RTA_WEIGHTING: 'z', // 'z' | 'a' | 'c'
@@ -58,14 +67,16 @@ const CONFIG = {
RTA_BAR_LAYOUT: 'rtw', // 'iec' | 'rtw'
RTA_BAR_BASE_COLOR: '#ffe066',
HEADER_TEXT_COLOR: '#ffe066',
RTA_INTEGRATION: 'fast', // 'impulse' | 'fast' | 'medium' | 'slow' | 'average' | 'peak'
RTA_DETECTOR: 'average', // 'average' | 'peak'
RTA_INTEGRATION: 'fast', // 'impulse' | 'fast' | 'medium' | 'slow'
RTA_BALLISTICS_MODE: 'average', // 'average' | 'peak' | 'both'
RTA_PEAK_HOLD_MODE: 'auto', // 'off' | 'auto' | 'manual'
RTA_PEAK_HOLD_MODE: 'fall', // 'off' | 'auto' | 'fall' | 'manual'
RTA_PEAK_HOLD_SEC: 2.5,
RTA_PEAK_DECAY_DB_PER_S: 20,
RTA_PEAK_RESET_TOKEN: 0,
RTA_DISPLAY_HOLD_SEC: 0,
RTA_IIR_ORDER: 6,
RTA_IIR_TAU_FAST: 0.12,
RTA_IIR_TAU_FAST: 0.125,
RTA_IIR_TAU_SLOW: 1.0,
SPECTRO_GAMMA: 0.4,
SPECTRO_SCROLL_MODE: 1, // 0.5=Langsam, 1=Normal, 2=Licht, 4=Lächerlich, 6=Wahnsinnig
@@ -103,7 +114,7 @@ const CONFIG = {
VU_REF_IS_PLUS4: true,
// 0 VU soll +4 dBu entsprechen → DIGITAL_REF + 4 dB.
VU_DBFS_REF: -14.0,
VU_OFFSET_DB: 1.92,
VU_OFFSET_DB: 0.0,
VU_HEADER_SHOW_VALUE: false,
TP_OFFSET_DB: 0.0,
TP_HEADER_SHOW_VALUE: false,
@@ -116,8 +127,9 @@ const CONFIG = {
PPM_RED_BAR_ONLY: true,
TP_RED_BAR_ONLY: true,
RMS_RED_BAR_ONLY: true,
RMS_TC_MODE: 'fast', // 'impulse' (35 ms), 'fast' (125 ms), 'slow' (1000 ms), 'none'
RMS_TC_MS: null, // optional override in ms (falls gesetzt, überschreibt den Modus)
// True-RMS integration is performed sample-continuously on linear power in
// the backend. "window" is a rectangular sliding 300-ms measurement.
RMS_TC_MODE: 'fast', // 'fast', 'slow', 'window'
VU_COLOR_NORMAL: '#ffe066',
VU_COLOR_WARN: '#ff3b3b',
PPM_DIN_COLOR_NORMAL: '#ffe066',
@@ -158,9 +170,6 @@ const CONFIG = {
PPM_DIN_RED_START: 0,
// Fixed RTW/DIN profile: 10 ms integration and 20 dB return in 1.5 s.
PPM_DIN_ATTACK_MS: 10,
// Non-normative: when enabled, PPM DIN attack becomes instant (no integration).
// Useful to compensate perceived meter lag caused by device / pipeline latency.
PPM_DIN_FAST_ATTACK: false,
PPM_DIN_DECAY_DB_PER_S: 20 / 1.5,
// Hold time for DIN PPM: verlängert auf 1000 ms (1 s), damit der PeakHold
// auch bei kurzen Transienten deutlich sichtbar bleibt. Gemäß
@@ -188,6 +197,7 @@ const CONFIG = {
CORR_ZERO_ON_SILENCE: false,
CORR_SILENCE_THRESHOLD_RMS_DBFS: -75,
CORR_RESPONSE_S: 1.0,
CORR_NEGATIVE_MARKER_MODE: 'memory',
CORR_RESET_TOKEN: 0,
XY_POINTS: 1024,
XY_STYLE: 'lines',
@@ -285,11 +295,6 @@ const CONFIG_DEFAULTS = JSON.parse(JSON.stringify(CONFIG));
function applyRtaBpoSelection(value) {
const mode = ['1_3', '1_6', '1_12'].includes(String(value)) ? String(value) : '1_3';
CONFIG.RTA_BPO_MODE = mode;
// RTW remains the original 31-band third-octave reference profile. Finer
// resolutions are Phoenix extensions and switch visibly to the IEC profile.
if (mode !== '1_3' && CONFIG.RTA_BAR_LAYOUT === 'rtw') {
CONFIG.RTA_BAR_LAYOUT = 'iec';
}
return mode;
}
const SOFTWARE_PRESET_IDS = Object.freeze(['default', 'claus', 'michael']);
@@ -590,9 +595,9 @@ const PHOENIX_GLOBAL_OPTION_KEYS = Object.freeze([
'LR_FRACTIONAL_DELAY_SAMPLES',
'PPM_DIN_ATTACK_MS',
'PPM_DIN_DECAY_DB_PER_S',
'PPM_DIN_FAST_ATTACK',
'PPM_EBU_ATTACK_MS',
'PPM_EBU_DECAY_DB_PER_S',
'RMS_TC_MODE',
'LUFS_I_WINDOW_MIN',
'LUFS_I_NORM_ENABLED',
'PPM_DIN_LOUDNESS_BOXES',
@@ -680,11 +685,12 @@ const BROADCAST_STANDARDS = {
};
const STORAGE_KEY = 'analyzer_config';
const VU_OFFSET_ALIGNMENT_DB = 1.92;
const VU_OFFSET_ALIGNMENT_MIGRATION_KEY = 'migrate_vu_offset_alignment_plus2_v1';
const VU_OFFSET_DEFAULT_DB = 0.0;
const VU_OFFSET_ZERO_MIGRATION_KEY = 'migrate_vu_offset_default_zero_v2';
const INPUT_OFFSET_DB_MIN = -60;
const INPUT_OFFSET_DB_MAX = 20;
const LR_DELAY_ZERO_MIGRATION_KEY = 'migrate_lr_delay_zero_to_0_05_v1';
const RTA_PEAK_FALL_DEFAULT_MIGRATION_KEY = 'migrate_rta_peak_fall_default_v1';
let configLoadedOnce = false;
function normalizeSoftwarePresetId(value) {
@@ -776,9 +782,16 @@ function loadConfig(opts = {}) {
let saved = null;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
if (!raw) {
localStorage.setItem(RTA_PEAK_FALL_DEFAULT_MIGRATION_KEY, '1');
return;
}
saved = JSON.parse(raw);
for (const k of Object.keys(saved)) if (k in CONFIG) CONFIG[k] = saved[k];
if (!localStorage.getItem(RTA_PEAK_FALL_DEFAULT_MIGRATION_KEY)) {
if (CONFIG.RTA_PEAK_HOLD_MODE === 'auto') CONFIG.RTA_PEAK_HOLD_MODE = 'fall';
localStorage.setItem(RTA_PEAK_FALL_DEFAULT_MIGRATION_KEY, '1');
}
if (typeof saved.PPM_TOP === 'number' && !('PPM_EBU_TOP' in saved)) CONFIG.PPM_EBU_TOP = saved.PPM_TOP;
if (typeof saved.PPM_BOTTOM === 'number' && !('PPM_EBU_BOTTOM' in saved)) CONFIG.PPM_EBU_BOTTOM = saved.PPM_BOTTOM;
if (typeof saved.PPM_RED_START === 'number' && !('PPM_EBU_RED_START' in saved)) CONFIG.PPM_EBU_RED_START = saved.PPM_RED_START;
@@ -789,17 +802,34 @@ 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_BPO_MODE !== '1_3') {
CONFIG.RTA_BAR_LAYOUT = 'iec';
CONFIG.RTA_IIR_FILTERBANK = CONFIG.RTA_IIR_FILTERBANK === 'butterworth'
? 'butterworth'
: 'legacy';
if (CONFIG.RTA_INTEGRATION === 'peak') {
CONFIG.RTA_DETECTOR = 'peak';
CONFIG.RTA_INTEGRATION = 'fast';
} else if (CONFIG.RTA_INTEGRATION === 'average') {
CONFIG.RTA_DETECTOR = 'average';
CONFIG.RTA_INTEGRATION = 'fast';
}
CONFIG.RTA_DETECTOR = CONFIG.RTA_DETECTOR === 'peak' ? 'peak' : 'average';
if (!['impulse', 'fast', 'medium', 'slow'].includes(CONFIG.RTA_INTEGRATION)) {
CONFIG.RTA_INTEGRATION = 'fast';
}
CONFIG.RTA_IIR_TAU_FAST = 0.125;
CONFIG.RTA_IIR_TAU_SLOW = 1.0;
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;
}
CONFIG.RTA_PEAK_HOLD_MODE = ['off', 'auto', 'fall', 'manual'].includes(CONFIG.RTA_PEAK_HOLD_MODE)
? CONFIG.RTA_PEAK_HOLD_MODE
: 'fall';
CONFIG.RTA_PEAK_DECAY_DB_PER_S = Math.max(1, Math.min(60, Number(CONFIG.RTA_PEAK_DECAY_DB_PER_S) || 20));
CONFIG.RTA_PEAK_RESET_TOKEN = Math.max(0, Math.floor(Number(CONFIG.RTA_PEAK_RESET_TOKEN) || 0));
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.
@@ -821,21 +851,23 @@ function loadConfig(opts = {}) {
if (!Number.isFinite(CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB)) CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB = 0;
CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB = Math.max(-7, Math.min(7, CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB));
try {
const needsVuMigration = !localStorage.getItem(VU_OFFSET_ALIGNMENT_MIGRATION_KEY);
const needsVuMigration = !localStorage.getItem(VU_OFFSET_ZERO_MIGRATION_KEY);
const savedVuOffset = Number(saved?.VU_OFFSET_DB);
const oldDefaultOffset = Number(CONFIG_DEFAULTS?.VU_OFFSET_DB);
const looksLikeOldDefault = !Number.isFinite(savedVuOffset)
|| Math.abs(savedVuOffset - oldDefaultOffset) < 0.02
const looksLikeOldAutomaticOffset = !Number.isFinite(savedVuOffset)
|| Math.abs(savedVuOffset - 1.92) < 0.02
|| Math.abs(savedVuOffset - (-0.08)) < 0.02
|| Math.abs(savedVuOffset - (-0.05)) < 0.02;
if (needsVuMigration && looksLikeOldDefault) {
CONFIG.VU_OFFSET_DB = VU_OFFSET_ALIGNMENT_DB;
if (needsVuMigration && looksLikeOldAutomaticOffset) {
CONFIG.VU_OFFSET_DB = VU_OFFSET_DEFAULT_DB;
}
if (needsVuMigration) localStorage.setItem(VU_OFFSET_ALIGNMENT_MIGRATION_KEY, '1');
if (needsVuMigration) localStorage.setItem(VU_OFFSET_ZERO_MIGRATION_KEY, '1');
} catch (_) {}
if (!Number.isFinite(CONFIG.LUFS_I_WINDOW_MIN)) CONFIG.LUFS_I_WINDOW_MIN = CONFIG_DEFAULTS.LUFS_I_WINDOW_MIN ?? 4;
CONFIG.LUFS_I_WINDOW_MIN = Math.max(1, Math.min(10, Math.round(CONFIG.LUFS_I_WINDOW_MIN)));
CONFIG.RMS_TC_MODE = (CONFIG.RMS_TC_MODE === 'slow' || CONFIG.RMS_TC_MODE === 'window')
? CONFIG.RMS_TC_MODE
: 'fast';
CONFIG.LUFS_I_NORM_ENABLED = !!CONFIG.LUFS_I_NORM_ENABLED;
CONFIG.STOPWATCH_DISPLAY_STYLE = (CONFIG.STOPWATCH_DISPLAY_STYLE === 'seven') ? 'seven' : 'mono';
@@ -852,7 +884,6 @@ function loadConfig(opts = {}) {
if (!Number.isFinite(CONFIG.LR_FRACTIONAL_DELAY_SAMPLES)) CONFIG.LR_FRACTIONAL_DELAY_SAMPLES = CONFIG_DEFAULTS.LR_FRACTIONAL_DELAY_SAMPLES;
CONFIG.LR_FRACTIONAL_DELAY_SAMPLES = Math.max(-1.5, Math.min(1.5, Number(CONFIG.LR_FRACTIONAL_DELAY_SAMPLES)));
CONFIG.PHOENIX_BASE_URL = normalizePhoenixBaseUrlForCurrentClient(CONFIG.PHOENIX_BASE_URL || CONFIG_DEFAULTS.PHOENIX_BASE_URL || DEFAULT_PHOENIX_BASE_URL);
CONFIG.PPM_DIN_FAST_ATTACK = !!CONFIG.PPM_DIN_FAST_ATTACK;
// Migrate stale profiles: detector ballistics are standards profiles,
// not user-adjustable display preferences.
CONFIG.PPM_DIN_ATTACK_MS = 10;
@@ -948,7 +979,11 @@ function loadConfig(opts = {}) {
const fallback = Number.isFinite(CONFIG.GONIO_DISPLAY_GAIN_DB) ? CONFIG.GONIO_DISPLAY_GAIN_DB : 0;
CONFIG.GONIO_MANUAL_GAIN_DB = Math.max(-35, Math.min(35, Math.round(fallback / 5) * 5));
}
CONFIG.CORR_RESPONSE_S = Number(CONFIG.CORR_RESPONSE_S) >= 1.75 ? 2.5 : 1.0;
CONFIG.CORR_RESPONSE_S = normalizeCorrelationResponseSeconds(CONFIG.CORR_RESPONSE_S);
const corrNegativeMarkerMode = String(CONFIG.CORR_NEGATIVE_MARKER_MODE || 'memory').toLowerCase();
CONFIG.CORR_NEGATIVE_MARKER_MODE = ['memory', 'hold', 'off'].includes(corrNegativeMarkerMode)
? corrNegativeMarkerMode
: 'memory';
CONFIG.CORR_RESET_TOKEN = Math.max(0, Math.floor(Number(CONFIG.CORR_RESET_TOKEN) || 0));
const persistenceMode = String(CONFIG.GONIO_PERSISTENCE_MODE || 'fast').toLowerCase();
CONFIG.GONIO_PERSISTENCE_MODE = ['fast', 'medium', 'slow', 'custom'].includes(persistenceMode)
@@ -1027,6 +1062,12 @@ function buildPhoenixGlobalConfigPayload() {
return (mode === '1_3' || mode === '1_6' || mode === '1_12') ? mode : '1_6';
};
const phaseAmplitudeMode = normalizePhaseAmplitudeMode(CONFIG.PHASE_AMPLITUDE_MODE);
const rmsIntegration = (() => {
const mode = String(CONFIG.RMS_TC_MODE || '').trim().toLowerCase();
if (mode === 'slow' || mode === 'window') return mode;
if (mode === 'none' || mode === 'window300') return 'window';
return 'fast';
})();
return {
fftSize,
rtaBpoMode: normalizeRtaBpoMode(CONFIG.RTA_BPO_MODE),
@@ -1037,9 +1078,9 @@ function buildPhoenixGlobalConfigPayload() {
lrFractionalDelaySamples: Number.isFinite(CONFIG.LR_FRACTIONAL_DELAY_SAMPLES) ? CONFIG.LR_FRACTIONAL_DELAY_SAMPLES : (CONFIG_DEFAULTS.LR_FRACTIONAL_DELAY_SAMPLES ?? 0.996),
ppmDinAttackMs: 10,
ppmDinDecayDbPerS: 20 / 1.5,
ppmDinFastAttack: !!CONFIG.PPM_DIN_FAST_ATTACK,
ppmEbuAttackMs: 10,
ppmEbuDecayDbPerS: 24 / 2.8,
rmsIntegration,
lufsIWindowMin: Number.isFinite(CONFIG.LUFS_I_WINDOW_MIN) ? CONFIG.LUFS_I_WINDOW_MIN : (CONFIG_DEFAULTS.LUFS_I_WINDOW_MIN ?? 4),
lufsINormEnabled: !!CONFIG.LUFS_I_NORM_ENABLED,
ppmDinLoudnessBoxes: !!CONFIG.PPM_DIN_LOUDNESS_BOXES,
@@ -1126,7 +1167,6 @@ function applyPhoenixGlobalConfig(payload = {}) {
}
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';
}
@@ -1135,9 +1175,12 @@ function applyPhoenixGlobalConfig(payload = {}) {
if ('spectroScrollMode' in payload) CONFIG.SPECTRO_SCROLL_MODE = normalizeSpectroScroll(payload.spectroScrollMode);
CONFIG.PPM_DIN_ATTACK_MS = 10;
CONFIG.PPM_DIN_DECAY_DB_PER_S = 20 / 1.5;
if ('ppmDinFastAttack' in payload) CONFIG.PPM_DIN_FAST_ATTACK = !!payload.ppmDinFastAttack;
CONFIG.PPM_EBU_ATTACK_MS = 10;
CONFIG.PPM_EBU_DECAY_DB_PER_S = 24 / 2.8;
if ('rmsIntegration' in payload) {
const mode = String(payload.rmsIntegration || '').trim().toLowerCase();
CONFIG.RMS_TC_MODE = (mode === 'slow' || mode === 'window') ? mode : 'fast';
}
const lufsWin = Number(payload.lufsIWindowMin);
if (Number.isFinite(lufsWin)) CONFIG.LUFS_I_WINDOW_MIN = Math.max(1, Math.min(10, Math.round(lufsWin)));
if ('lufsINormEnabled' in payload) CONFIG.LUFS_I_NORM_ENABLED = !!payload.lufsINormEnabled;
@@ -1270,7 +1313,7 @@ function applyBroadcastStandard(name) {
CONFIG.VU_RED_START = p.VU_RED_START;
// Offsets zurücksetzen (wie bisher)
CONFIG.VU_OFFSET_DB = VU_OFFSET_ALIGNMENT_DB;
CONFIG.VU_OFFSET_DB = VU_OFFSET_DEFAULT_DB;
CONFIG.TP_OFFSET_DB = 0;
CONFIG.RMS_OFFSET_DB = 0;
+11 -17
View File
@@ -1,5 +1,6 @@
// core/registry.js — Lazy Loader + Safe Runner für Meter
// Lädt Meter-Module on-demand (oder via registerMeter) und kapselt Fehler.
import { createCanvasSurface } from './canvas_surface.js';
const cache = {
meters: new Map(), // id -> module
@@ -7,26 +8,12 @@ const cache = {
const meterRenderCache = new Map(); // key -> surface
const METER_CACHE_MARGIN = 32;
const METER_RENDER_CACHE_LIMIT = 48;
let activeFrameStamp = 0;
const CAN_USE_OFFSCREEN = typeof OffscreenCanvas === 'function';
function createCacheSurface(width, height) {
const w = Math.max(1, Math.ceil(width));
const h = Math.max(1, Math.ceil(height));
if (CAN_USE_OFFSCREEN) {
const canvas = new OffscreenCanvas(w, h);
const ctx = canvas.getContext('2d');
if (ctx) return { canvas, ctx, width: w, height: h, stamp: -1 };
}
if (typeof document !== 'undefined') {
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (ctx) return { canvas, ctx, width: w, height: h, stamp: -1 };
}
return null;
const surface = createCanvasSurface(width, height);
return surface ? { ...surface, stamp: -1 } : null;
}
function cacheKeyForMeter(id, rect) {
@@ -40,10 +27,17 @@ function cacheKeyForMeter(id, rect) {
function ensureCacheSurface(id, rect) {
const key = cacheKeyForMeter(id, rect);
let surface = meterRenderCache.get(key);
if (surface) {
meterRenderCache.delete(key);
meterRenderCache.set(key, surface);
}
if (!surface) {
surface = createCacheSurface(rect.w + METER_CACHE_MARGIN * 2, rect.h + METER_CACHE_MARGIN * 2);
if (!surface) return null;
meterRenderCache.set(key, surface);
while (meterRenderCache.size > METER_RENDER_CACHE_LIMIT) {
meterRenderCache.delete(meterRenderCache.keys().next().value);
}
}
if (!surface.canvas || !surface.ctx) return null;
const w = Math.max(1, Math.ceil(rect.w + METER_CACHE_MARGIN * 2));
+20 -10
View File
@@ -138,22 +138,18 @@ export function buildBandBinMapping(bands, nyq, binCount) {
const bins = [];
const start = Math.max(0, Math.floor((band.fLo / nyq) * binCount));
const end = Math.min(binCount - 1, Math.ceil((band.fHi / nyq) * binCount));
let weightSum = 0;
for (let i = start; i <= end; i++) {
const binStart = i * binWidth;
const binEnd = binStart + binWidth;
const overlap = Math.max(0, Math.min(binEnd, band.fHi) - Math.max(binStart, band.fLo));
if (overlap > 0) {
bins.push({ index: i, weight: overlap });
weightSum += overlap;
bins.push({ index: i, weight: Math.min(1, overlap / binWidth) });
}
}
if (!bins.length) {
const idx = Math.max(0, Math.min(binCount - 1, Math.round((band.center / nyq) * binCount)));
bins.push({ index: idx, weight: 1 });
weightSum = 1;
}
bins.forEach((b) => { b.weight /= weightSum || 1; });
result.push({
center: band.center,
fLo: band.fLo,
@@ -254,7 +250,9 @@ export function drawDbfsGridRect(ctx, x0, y0, w, h, CONFIG, nyq, opts = {}) {
colorMinor = 'rgba(30,42,53,.55)',
labelColor = '#8fd3d4',
freqTicks = null,
freqTickPositions = null,
freqMin = 20,
xLeftExtension = 0,
refDb = null,
refStyle = 'rgba(0,231,255,0.35)',
refDash = [4, 3],
@@ -265,6 +263,12 @@ export function drawDbfsGridRect(ctx, x0, y0, w, h, CONFIG, nyq, opts = {}) {
const _g = Number(CONFIG && CONFIG.AXIS_GUTTER_LEFT);
const gutterL = Number.isFinite(_g) ? Math.max(8, _g) : 14;
const requestedExtension = Number(xLeftExtension);
const leftExtension = Number.isFinite(requestedExtension)
? Math.max(0, Math.min(gutterL, requestedExtension))
: 0;
const contentX = x0 - leftExtension;
const contentW = w + leftExtension;
ctx.save();
ctx.strokeStyle = '#00e7ff'; ctx.lineWidth = 2; ctx.strokeRect(x0 - gutterL, y0, w + gutterL, h);
@@ -276,7 +280,7 @@ export function drawDbfsGridRect(ctx, x0, y0, w, h, CONFIG, nyq, opts = {}) {
const y = yFromDbfs(v, y0, y0 + h, CONFIG);
const labelY = Math.min(y0 + h - 4, Math.max(y0 + 12, y + 4));
ctx.strokeStyle = colorMajor; ctx.lineWidth = 1.2;
ctx.beginPath(); ctx.moveTo(x0, y); ctx.lineTo(x0 + w, y); ctx.stroke();
ctx.beginPath(); ctx.moveTo(contentX, y); ctx.lineTo(contentX + contentW, y); ctx.stroke();
ctx.textAlign = 'right'; ctx.fillText(String(v), (x0 - gutterL) - 6, labelY);
}
@@ -286,8 +290,8 @@ export function drawDbfsGridRect(ctx, x0, y0, w, h, CONFIG, nyq, opts = {}) {
ctx.strokeStyle = refStyle;
ctx.setLineDash(Array.isArray(refDash) ? refDash : [4, 3]);
ctx.beginPath();
ctx.moveTo(x0, yRef);
ctx.lineTo(x0 + w, yRef);
ctx.moveTo(contentX, yRef);
ctx.lineTo(contentX + contentW, yRef);
ctx.stroke();
ctx.setLineDash([]);
}
@@ -303,9 +307,15 @@ export function drawDbfsGridRect(ctx, x0, y0, w, h, CONFIG, nyq, opts = {}) {
const logMin = Math.log10(minFreq);
const logSpan = Math.max(1e-6, Math.log10(nyq) - logMin);
for (const f of ticks) {
const x = x0 + ((Math.log10(Math.max(f, minFreq)) - logMin) / logSpan) * w;
const requestedPosition = freqTickPositions && typeof freqTickPositions === 'object'
? Number(freqTickPositions[String(f)])
: NaN;
const fraction = Number.isFinite(requestedPosition)
? Math.max(0, Math.min(1, requestedPosition))
: ((Math.log10(Math.max(f, minFreq)) - logMin) / logSpan);
const x = contentX + fraction * contentW;
// Linken Rand nicht doppeln: keine Vertikal-Linie auf dem linken Plot-Rand
if (x > x0 + 0.75) {
if (x > contentX + 0.75) {
ctx.strokeStyle = '#131b24';
ctx.beginPath(); ctx.moveTo(x, y0); ctx.lineTo(x, y0 + h); ctx.stroke();
}
+155 -728
View File
File diff suppressed because it is too large Load Diff
+42 -5
View File
@@ -51,6 +51,7 @@ import * as stopwatch from './meters/stopwatch.js';
// Options UI
import { setupOptions } from './ui/options.js';
import { setupKioskBrowserBackup } from './ui/kiosk_browser_backup.js';
// --- Canvas & Context ---------------------------------------------------------
const C = document.getElementById('cv');
@@ -716,6 +717,7 @@ function buildProcessingProfile(viewId = style) {
const profile = {
needXy: false,
needRta: false,
needSpectro: false,
needVu: false,
needPpmDin: false,
needPpmEbu: false,
@@ -728,6 +730,8 @@ function buildProcessingProfile(viewId = style) {
for (const plotId of plotIds) {
if (plotId === 'realtime') {
profile.needRta = true;
} else if (plotId === 'spectrogram') {
profile.needSpectro = true;
} else if (plotId === 'goniometer-rtw') {
profile.needXy = true;
profile.needRms = true;
@@ -792,6 +796,7 @@ function setSlotForView(viewId, idx, value){
try { saveConfig?.(); } catch (_) {}
try { env?.audio?.updatePhoenixGlobalConfig?.(); } catch (_) {}
}
requestRender('processing-profile-change');
}
function resetAllSlotsToDefaults() {
@@ -804,6 +809,7 @@ function resetAllSlotsToDefaults() {
saveSlotState(slotsState);
saveSlotCustomizedState({});
refreshSlotEditors();
requestRender('processing-profile-change');
}
function initSlotSelect(sel, idx){
if (!sel) return;
@@ -1007,11 +1013,13 @@ initSplitPlotSelect(splitLeftSel, (val) => {
const clean = sanitizeSplitPlotId(val) ?? 'none';
CONFIG.SPLIT_VIEW_LEFT = clean;
saveConfig();
requestRender('processing-profile-change');
});
initSplitPlotSelect(splitRightSel, (val) => {
const clean = sanitizeSplitPlotId(val) ?? 'none';
CONFIG.SPLIT_VIEW_RIGHT = clean;
saveConfig();
requestRender('processing-profile-change');
});
function clampMeterCount3(v) {
@@ -1053,6 +1061,7 @@ function writeMeterToConfig(prefix, idx, val) {
const key = getMeterKey(prefix, idx);
CONFIG[key] = sanitizeSlotId(val) ?? 'vu';
saveConfig();
requestRender('processing-profile-change');
}
function setSplitMeterPopupOpen(open) {
@@ -1098,10 +1107,10 @@ function syncQuadPlotPopup() {
if (quadPlotSelBR) quadPlotSelBR.value = sanitizeSplitPlotId(CONFIG.QUAD_VIEW_BR) ?? 'none';
}
initSplitPlotSelect(quadPlotSelTL, (val) => { CONFIG.QUAD_VIEW_TL = sanitizeSplitPlotId(val) ?? 'phase-wheel'; saveConfig(); });
initSplitPlotSelect(quadPlotSelTR, (val) => { CONFIG.QUAD_VIEW_TR = sanitizeSplitPlotId(val) ?? 'realtime'; saveConfig(); });
initSplitPlotSelect(quadPlotSelBL, (val) => { CONFIG.QUAD_VIEW_BL = sanitizeSplitPlotId(val) ?? 'goniometer-rtw'; saveConfig(); });
initSplitPlotSelect(quadPlotSelBR, (val) => { CONFIG.QUAD_VIEW_BR = sanitizeSplitPlotId(val) ?? 'none'; saveConfig(); });
initSplitPlotSelect(quadPlotSelTL, (val) => { CONFIG.QUAD_VIEW_TL = sanitizeSplitPlotId(val) ?? 'phase-wheel'; saveConfig(); requestRender('processing-profile-change'); });
initSplitPlotSelect(quadPlotSelTR, (val) => { CONFIG.QUAD_VIEW_TR = sanitizeSplitPlotId(val) ?? 'realtime'; saveConfig(); requestRender('processing-profile-change'); });
initSplitPlotSelect(quadPlotSelBL, (val) => { CONFIG.QUAD_VIEW_BL = sanitizeSplitPlotId(val) ?? 'goniometer-rtw'; saveConfig(); requestRender('processing-profile-change'); });
initSplitPlotSelect(quadPlotSelBR, (val) => { CONFIG.QUAD_VIEW_BR = sanitizeSplitPlotId(val) ?? 'none'; saveConfig(); requestRender('processing-profile-change'); });
if (splitMeterBtn) {
splitMeterBtn.addEventListener('click', () => {
@@ -1692,6 +1701,11 @@ const audioState = {
xyL: null,
xyR: null,
xySeq: 0,
phaseAngleRad: null,
phaseCoherence: 0,
phaseLevel: 0,
phasePeak: 0,
phaseSeq: 0,
rtaData: null,
rmsDb: { L: -120, R: -120, mono: -120 },
updateRtaConfig: null,
@@ -1857,10 +1871,12 @@ function handleSplitTapUp(x, y) {
if (env?.splitPopup?.open) {
env.splitPopup.open = false;
env.splitPopup.viewId = null;
requestRender('processing-profile-change');
return;
}
env.splitPopup.open = true;
env.splitPopup.viewId = target;
requestRender('processing-profile-change');
return;
}
env.switchView?.(target);
@@ -1877,6 +1893,7 @@ function handleQuadTapUp(x, y) {
// Popup ist offen: Tap innerhalb/außerhalb schließt
env.quadPopup.open = false;
env.quadPopup.viewId = null;
requestRender('processing-profile-change');
return;
}
if (isInAnyMeterHitRect(x, y)) return;
@@ -1889,6 +1906,7 @@ function handleQuadTapUp(x, y) {
if (env?.quadPopup) {
env.quadPopup.open = true;
env.quadPopup.viewId = target;
requestRender('processing-profile-change');
}
return;
}
@@ -1912,6 +1930,7 @@ C.addEventListener('pointerdown', (e) => {
if (!env?.splitPopup?.open) return;
env.splitPopup.open = false;
env.splitPopup.viewId = null;
requestRender('processing-profile-change');
try { e.preventDefault?.(); } catch (_) {}
try { e.stopPropagation?.(); } catch (_) {}
}, { capture: true, passive: false });
@@ -1968,6 +1987,7 @@ C.addEventListener('pointerdown', (e) => {
if (pointInRect(x, y, box)) return;
env.quadPopup.open = false;
env.quadPopup.viewId = null;
requestRender('processing-profile-change');
try { e.preventDefault?.(); } catch (_) {}
try { e.stopPropagation?.(); } catch (_) {}
}, { capture: true, passive: false });
@@ -2051,6 +2071,9 @@ if (peakHistScrollSel) {
}
if (resetPeakBtn) {
resetPeakBtn.addEventListener('click', () => {
CONFIG.RTA_PEAK_RESET_TOKEN = Math.max(0, Math.floor(Number(CONFIG.RTA_PEAK_RESET_TOKEN) || 0)) + 1;
saveConfig();
try { env.audio.updateRtaConfig?.(); } catch (_) {}
try {
if (typeof window.resetRealTimeAnalyzerPeakHold === 'function') {
window.resetRealTimeAnalyzerPeakHold();
@@ -2612,9 +2635,14 @@ let audioRecoverInFlight = false;
let lastAudioRecoverAt = 0;
let renderDirty = true;
let lastRenderedAudioSeq = 0;
let processingProfileDirty = true;
let appliedProcessingProfileView = '';
const DATA_ONLY_RENDER_REASONS = new Set(['audio', 'spectro', 'visuals']);
function requestRender(reason = 'ui') {
renderDirty = true;
if (!DATA_ONLY_RENDER_REASONS.has(reason)) processingProfileDirty = true;
env.__lastRenderReason = reason;
}
@@ -2647,7 +2675,15 @@ async function loop(now){
const elapsed = now - lastFrameTime;
const audioOk = env.audio.alive && !audioLost(env);
const renderStyle = getRenderableStyle();
try { env.audio?.updateProcessingConfig?.(buildProcessingProfile(renderStyle)); } catch (_) {}
if (processingProfileDirty || appliedProcessingProfileView !== renderStyle) {
try {
if (typeof env.audio?.updateProcessingConfig === 'function') {
env.audio.updateProcessingConfig(buildProcessingProfile(renderStyle));
processingProfileDirty = false;
appliedProcessingProfileView = renderStyle;
}
} catch (_) {}
}
const targetMs = audioOk ? (1000 / desiredFpsForStyle(renderStyle)) : (1000 / 20);
const target = targetMs;
const currentAudioSeq = Number.isFinite(env.audio?.xySeq) ? env.audio.xySeq : 0;
@@ -2699,6 +2735,7 @@ async function loop(now){
(async function boot(){
try{
const start = async () => {
if (!await setupKioskBrowserBackup()) return;
await new Promise((resolve) => setTimeout(resolve, 2000));
const layoutParam = (() => {
try {
+12 -67
View File
@@ -15,32 +15,25 @@ import { drawCachedStaticLayer } from './static_layer.js';
import { METER_HEADER_FONT } from './scale_helpers.js';
import { HEADER_BG, LABEL_COLOR, OK_COLOR, WARN_COLOR } from '../core/theme.js';
const RMS_FRAME_MS_NOMINAL = 16; // ~60 Hz UI-Refresh
const RMS_FRAME_MS_MAX = 40; // Obergrenze für dt in der Glättung
const RMS_FRAME_MS_SKIP = 250; // Heuristischer Schutz: riesige Gaps komplett skippen
export const id = 'rms';
export function initShared(CONFIG = {}) {
const offset = CONFIG.RMS_OFFSET_DB || 0;
const initVal = -60 + offset;
const initVal = -60;
return {
values: { L: initVal, R: initVal }, // intern immer dBFS RMS aus der Messkette
offset,
values: { L: initVal, R: initVal }, // unveränderte dBFS-RMS-Werte aus dem Backend
offset: 0,
lastValidL: initVal,
lastValidR: initVal,
_smooth: null,
};
}
export function update(packet, shared) {
const offset = shared.offset || 0;
const floor = -60 + offset;
const floor = -60;
if (!Number.isFinite(shared.lastValidL)) shared.lastValidL = floor;
if (!Number.isFinite(shared.lastValidR)) shared.lastValidR = floor;
if (Number.isFinite(packet?.rmsL)) {
const vL = packet.rmsL + offset;
const vL = packet.rmsL;
shared.values.L = vL;
shared.lastValidL = vL;
} else {
@@ -48,7 +41,7 @@ export function update(packet, shared) {
}
if (Number.isFinite(packet?.rmsR)) {
const vR = packet.rmsR + offset;
const vR = packet.rmsR;
shared.values.R = vR;
shared.lastValidR = vR;
} else {
@@ -65,6 +58,7 @@ export function draw(g, rect, CONFIG = {}, shared) {
// Anzeige-Umschaltung & Skala
const isDBU = (MODE === 'dbu');
const offset = Number.isFinite(CONFIG.RMS_OFFSET_DB) ? CONFIG.RMS_OFFSET_DB : 0;
const LOG_MIN = -60;
const LOG_TOP = isDBU ? +24 : 0;
@@ -120,35 +114,9 @@ export function draw(g, rect, CONFIG = {}, shared) {
g.fillRect(rect.x, rect.y - 24, rect.w, 24);
g.restore();
// IEC-ähnliche Zeitkonstanten (Impulse/Fast/Slow) für die Anzeige
const tauMs = resolveRmsTau(CONFIG);
if (tauMs > 0) {
const now = performance.now();
if (!shared._smooth) {
shared._smooth = {
L: shared.values.L,
R: shared.values.R,
lastTs: now - RMS_FRAME_MS_NOMINAL,
};
}
const lastTs = shared._smooth.lastTs ?? (now - RMS_FRAME_MS_NOMINAL);
const dtRawMs = Math.max(0, now - lastTs);
shared._smooth.lastTs = now;
if (dtRawMs <= RMS_FRAME_MS_SKIP) {
const dtUsedMs = Math.min(dtRawMs, RMS_FRAME_MS_MAX); // clamp dt to ignore occasional large gaps
const alpha = 1 - Math.exp(-dtUsedMs / tauMs);
shared._smooth.L += alpha * (shared.values.L - shared._smooth.L);
shared._smooth.R += alpha * (shared.values.R - shared._smooth.R);
}
} else {
shared._smooth = null;
}
// Werte in Anzeigeeinheit clampen
const rawDispL = toDisplay(shared._smooth?.L ?? shared.values.L);
const rawDispR = toDisplay(shared._smooth?.R ?? shared.values.R);
const smooth = smoothHeader(shared, rawDispL, rawDispR);
const rawDispL = toDisplay(shared.values.L + offset);
const rawDispR = toDisplay(shared.values.R + offset);
const vL = clamp(rawDispL, LOG_MIN, LOG_TOP);
const vR = clamp(rawDispR, LOG_MIN, LOG_TOP);
g.save();
@@ -167,11 +135,11 @@ export function draw(g, rect, CONFIG = {}, shared) {
const isRedL = rawDispL > RED_START;
const isRedR = rawDispR > RED_START;
g.fillStyle = isRedL ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
g.fillText(fmt(smooth.L), centerLeft, yText);
g.fillText(fmt(rawDispL), centerLeft, yText);
g.fillStyle = CONFIG.HEADER_TEXT_COLOR || MID_COLOR;
g.fillText('|', centerX, yText);
g.fillStyle = isRedR ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
g.fillText(fmt(smooth.R), centerRight, yText);
g.fillText(fmt(rawDispR), centerRight, yText);
} else {
const title = isDBU ? 'RMS (dBu)' : 'RMS (dBFS RMS)';
g.fillText(title, centerX, rect.y - 12);
@@ -284,7 +252,7 @@ function drawRmsStaticOverlay(g, shared, rect, CONFIG, geom) {
bottomValue: LOG_MIN,
highlightTick: null,
});
const unitLabel = 'dBFS (RMS)';
const unitLabel = isDBU ? 'dBu (RMS)' : 'dBFS (RMS)';
cg.fillStyle = LABEL_COLOR;
cg.textAlign = 'center';
const baseY = rect.y + rect.h + 16;
@@ -303,19 +271,6 @@ function drawRmsStaticOverlay(g, shared, rect, CONFIG, geom) {
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function resolveRmsTau(CONFIG = {}) {
const mode = String(CONFIG.RMS_TC_MODE || 'fast').toLowerCase();
const custom = Number(CONFIG.RMS_TC_MS);
if (Number.isFinite(custom) && custom > 0) return custom;
switch (mode) {
case 'impulse': return 35;
case 'slow': return 1000;
case 'none': return 0;
case 'fast':
default: return 125;
}
}
function drawRedStripeLR(g, leftX, barW, rightX, centerX, mapY, yWarn, yTop) {
const innerRight = leftX + barW;
const gapL = Math.abs(centerX - innerRight);
@@ -491,13 +446,3 @@ function getRmsAlignmentHighlight(CONFIG) {
color: WARN_COLOR,
};
}
function smoothHeader(shared, rawL, rawR, alpha = 0.2) {
if (!shared._header) {
shared._header = { L: rawL, R: rawR };
return shared._header;
}
shared._header.L += alpha * (rawL - shared._header.L);
shared._header.R += alpha * (rawR - shared._header.R);
return shared._header;
}
+2 -20
View File
@@ -1,22 +1,4 @@
const CAN_USE_OFFSCREEN = typeof OffscreenCanvas === 'function';
function createSurface(width, height) {
const w = Math.max(1, Math.ceil(width));
const h = Math.max(1, Math.ceil(height));
if (CAN_USE_OFFSCREEN) {
const canvas = new OffscreenCanvas(w, h);
const ctx = canvas.getContext('2d');
if (ctx) return { canvas, ctx, width: w, height: h };
}
if (typeof document !== 'undefined') {
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (ctx) return { canvas, ctx, width: w, height: h };
}
return null;
}
import { createCanvasSurface } from '../core/canvas_surface.js';
export function drawCachedStaticLayer(targetCtx, shared, layerId, key, x, y, width, height, build) {
if (!targetCtx || !shared || typeof build !== 'function') return false;
@@ -30,7 +12,7 @@ export function drawCachedStaticLayer(targetCtx, shared, layerId, key, x, y, wid
|| layer.height !== h;
if (needsRebuild) {
layer = createSurface(w, h);
layer = createCanvasSurface(w, h);
if (!layer) return false;
layer.key = key;
layer.ctx.save();
+34 -34
View File
@@ -1,25 +1,12 @@
// meters/tp.js — True Peak (dBTP) L/R mit nichtlinearer Skala, Warnschwelle und Glanzkante
// meters/tp.js — True Peak (dBTP) L/R mit Übersteuerungsreserve bis +6 dBTP
import { createPeakHoldState, stepPeakHold } from '../core/utils.js';
import { drawHairlineGrid, METER_HEADER_FONT } from './scale_helpers.js';
import { drawCachedStaticLayer } from './static_layer.js';
import { TRUE_PEAK_MINOR_TICKS, TRUE_PEAK_SCALE } from './true_peak_scale.js';
import { HEADER_BG, LABEL_COLOR, MID_COLOR, WARN_COLOR } from '../core/theme.js';
export const id = 'tp';
const TP_PERCENT_SCALE = [
{ db: -60, percent: '0,00 %', frac: 0.0000 },
{ db: -50, percent: '16,67 %', frac: 0.0373 },
{ db: -40, percent: '33,33 %', frac: 0.1429 },
{ db: -35, percent: '41,67 %', frac: 0.2112 },
{ db: -30, percent: '50,00 %', frac: 0.2857 },
{ db: -25, percent: '58,33 %', frac: 0.3851 },
{ db: -20, percent: '66,67 %', frac: 0.4783 },
{ db: -15, percent: '75,00 %', frac: 0.6087 },
{ db: -10, percent: '83,33 %', frac: 0.7391 },
{ db: -5, percent: '91,67 %', frac: 0.8696 },
{ db: 0, percent: '100,00 %', frac: 1.0000 },
];
export function initShared(CONFIG) {
const now = performance.now();
return {
@@ -42,22 +29,22 @@ export function update(packet, shared) {
shared.values.R = R + (shared.offset || 0);
}
const LOG_MIN = TP_PERCENT_SCALE[0].db;
const LOG_TOP = TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].db;
const LOG_MIN = TRUE_PEAK_SCALE[0].db;
const LOG_TOP = TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].db;
function interpolateFrac(db) {
if (db <= TP_PERCENT_SCALE[0].db) return TP_PERCENT_SCALE[0].frac;
if (db >= TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].db) return TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].frac;
for (let i = 1; i < TP_PERCENT_SCALE.length; i++) {
const prev = TP_PERCENT_SCALE[i - 1];
const curr = TP_PERCENT_SCALE[i];
if (db <= TRUE_PEAK_SCALE[0].db) return TRUE_PEAK_SCALE[0].frac;
if (db >= TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].db) return TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].frac;
for (let i = 1; i < TRUE_PEAK_SCALE.length; i++) {
const prev = TRUE_PEAK_SCALE[i - 1];
const curr = TRUE_PEAK_SCALE[i];
if (db <= curr.db) {
const span = curr.db - prev.db || 1;
const t = (db - prev.db) / span;
return prev.frac + t * (curr.frac - prev.frac);
}
}
return TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].frac;
return TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].frac;
}
export function draw(g, rect, CONFIG, shared) {
@@ -122,13 +109,30 @@ export function draw(g, rect, CONFIG, shared) {
g.font = prevFont;
g.restore();
const yFloor = mapY(LOG_MIN);
const configuredRedStart = Number(CONFIG.TP_RED_START);
const redStart = Math.max(LOG_MIN, Math.min(LOG_TOP, Number.isFinite(configuredRedStart) ? configuredRedStart : -1));
const yRed = mapY(redStart);
const innerW = Math.max(2, barW - 2);
const colNorm = CONFIG.TP_COLOR_NORMAL || MID_COLOR;
const colWarn = CONFIG.TP_COLOR_WARN || WARN_COLOR;
const redOnly = CONFIG.TP_RED_BAR_ONLY !== false;
const drawBar = (x0, val) => {
const yVal = mapY(val);
const colNorm = CONFIG.TP_COLOR_NORMAL;
g.fillStyle = colNorm;
g.fillRect(x0 + 1, yVal, innerW, Math.max(0, yFloor - yVal));
if (val > redStart) {
if (redOnly) {
g.fillStyle = colNorm;
g.fillRect(x0 + 1, yRed, innerW, Math.max(0, yFloor - yRed));
g.fillStyle = colWarn;
g.fillRect(x0 + 1, yVal, innerW, Math.max(0, yRed - yVal));
} else {
g.fillStyle = colWarn;
g.fillRect(x0 + 1, yVal, innerW, Math.max(0, yFloor - yVal));
}
} else {
g.fillStyle = colNorm;
g.fillRect(x0 + 1, yVal, innerW, Math.max(0, yFloor - yVal));
}
// Glanzkante
g.globalAlpha = .12;
g.fillStyle = '#fff';
@@ -211,9 +215,6 @@ function drawTpStaticOverlay(g, shared, rect, CONFIG, geom, mapY) {
});
}
// Balken-Overlay-Ticks auf Basis der Vintage-Prozent-Skala
const EXTRA_MINOR_TICKS = [-47, -19, -18, -17, -16, -14, -13, -12, -11, -9, -8, -7, -6, -4, -3, -2, -1];
function drawOverlayTicksLR(g, leftX, rightX, widthPx, mapY, alignmentTick = null) {
if (!g) return;
@@ -254,14 +255,14 @@ function drawOverlayTicksLR(g, leftX, rightX, widthPx, mapY, alignmentTick = nul
g.beginPath(); g.moveTo(x1R, yPix); g.lineTo(x2R, yPix); g.stroke();
};
for (const point of TP_PERCENT_SCALE) {
for (const point of TRUE_PEAK_SCALE) {
const y = Math.round(mapY(point.db)) + 0.5;
const isHighlight = highlightValue !== null && approx(point.db, highlightValue);
if (isHighlight) highlightMatched = true;
drawMajor(y, isHighlight);
}
for (const db of EXTRA_MINOR_TICKS) {
for (const db of TRUE_PEAK_MINOR_TICKS) {
const y = Math.round(mapY(db)) + 0.5;
const isHighlight = highlightValue !== null && approx(db, highlightValue);
if (isHighlight) highlightMatched = true;
@@ -289,11 +290,10 @@ function drawScale(g, colRect, centerX, mapY, CONFIG) {
g.textAlign = 'center';
g.textBaseline = 'alphabetic';
for (const point of TP_PERCENT_SCALE) {
for (const point of TRUE_PEAK_SCALE) {
const y = mapY(point.db);
if (y < colRect.y || y > colRect.y + colRect.h) continue;
const value = Math.abs(point.db);
const dbLabel = Number.isInteger(value) ? String(value) : value.toFixed(1);
const dbLabel = point.db > 0 ? `+${point.db}` : String(point.db);
g.fillText(dbLabel, centerX, y + 5);
}
g.font = prevFont;
+22
View File
@@ -0,0 +1,22 @@
// Shared dBTP scale for every True-Peak presentation. The final 10% is kept
// above 0 dBTP so intersample overloads remain measurable up to +6 dBTP.
export const TRUE_PEAK_SCALE = Object.freeze([
{ db: -60, frac: 0.0000 },
{ db: -50, frac: 0.0336 },
{ db: -40, frac: 0.1286 },
{ db: -35, frac: 0.1901 },
{ db: -30, frac: 0.2571 },
{ db: -25, frac: 0.3466 },
{ db: -20, frac: 0.4305 },
{ db: -15, frac: 0.5478 },
{ db: -10, frac: 0.6652 },
{ db: -5, frac: 0.7826 },
{ db: 0, frac: 0.9000 },
{ db: +3, frac: 0.9500 },
{ db: +6, frac: 1.0000 },
]);
export const TRUE_PEAK_MINOR_TICKS = Object.freeze([
-47, -19, -18, -17, -16, -14, -13, -12, -11, -9, -8, -7, -6, -4, -3, -2, -1,
+1, +2, +4, +5,
]);
+12
View File
@@ -415,3 +415,15 @@ canvas.waveform-layer{
#optionsPanelNew summary{font-size:17px;padding:8px 4px;}
#optionsPanelNew .opt{font-size:16px;}
.changelog-details { margin: 20px 0 0 5px; }
.changelog-content > .opt-group { margin-top: 0; }
.changelog-version { margin-left: 12px; }
.changelog-date { margin: 0; color: #8fd3d4; }
.changelog-heading { margin: 4px 0 0 12px; color: #8fd3d4; }
.changelog-list { margin: 2px 0 0 24px; color: #b2c7d9; }
.opt-w-110 { width: 110px !important; }
.opt-w-120 { width: 120px !important; }
.opt-w-140 { width: 140px !important; }
.opt-w-150 { width: 150px !important; }
.opt-w-180 { width: 180px !important; }
.opt-w-220 { width: 220px !important; }
+30
View File
@@ -0,0 +1,30 @@
let loadPromise = null;
export function setupLazyChangelog(details, content) {
if (!details || !content || details.dataset.bound === '1') return;
details.dataset.bound = '1';
const load = async () => {
if (content.dataset.loaded === '1') return;
if (!loadPromise) {
loadPromise = fetch('./changelog.html', { cache: 'no-cache' })
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.text();
});
}
try {
content.innerHTML = await loadPromise;
content.dataset.loaded = '1';
} catch (error) {
loadPromise = null;
content.textContent = 'Versionsverlauf konnte nicht geladen werden.';
console.warn('Changelog load error:', error);
}
};
details.addEventListener('toggle', () => {
if (details.open) void load();
});
if (details.open) void load();
}
+92
View File
@@ -0,0 +1,92 @@
const BACKUP_KIND = 'phoenix-device-backup';
const BACKUP_SCHEMA_VERSION = 1;
function setStatus(element, message, isError = false) {
if (!element) return;
element.textContent = message;
element.style.color = isError ? '#ff6b6b' : '';
}
function backupFileName() {
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
return `phoenix-device-backup-${stamp}.json`;
}
function validateBackup(payload) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('Die Datei enthält kein gültiges JSON-Objekt.');
}
if (payload.kind !== BACKUP_KIND) {
throw new Error('Die Datei ist keine Phoenix-Gerätesicherung.');
}
if (Number(payload.schemaVersion) !== BACKUP_SCHEMA_VERSION) {
throw new Error(`Nicht unterstützte Backup-Version: ${payload.schemaVersion ?? '?'}.`);
}
if (!payload.globalConfig || !payload.rtaConfig) {
throw new Error('Gerätekonfiguration oder RTA-Konfiguration fehlt.');
}
return payload;
}
export function setupDeviceBackup({ exportButton, importInput, status, resolveApiBaseUrl }) {
if (!exportButton || !importInput || typeof resolveApiBaseUrl !== 'function') return;
exportButton.addEventListener('click', async () => {
const original = exportButton.textContent;
exportButton.disabled = true;
exportButton.textContent = 'Sichere…';
setStatus(status, 'Gerätekonfiguration wird gelesen…');
try {
const response = await fetch(`${resolveApiBaseUrl()}/api/v1/device-backup?_=${Date.now()}`, {
cache: 'no-store',
});
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error || `HTTP ${response.status}`);
validateBackup(payload);
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = backupFileName();
link.click();
URL.revokeObjectURL(link.href);
const kioskCount = Object.keys(payload.kioskBrowserStorage || {}).length;
setStatus(status, `Backup vollständig heruntergeladen (${kioskCount} Kiosk-Einstellungen).`);
} catch (error) {
console.error('Device backup export error:', error);
setStatus(status, `Backup fehlgeschlagen: ${error?.message || String(error)}`, true);
} finally {
exportButton.disabled = false;
exportButton.textContent = original;
}
});
importInput.addEventListener('change', async () => {
const file = importInput.files?.[0];
if (!file) return;
try {
const payload = validateBackup(JSON.parse(await file.text()));
const confirmed = globalThis.confirm?.(
'Gerätesicherung wiederherstellen?\n\nGlobale Geräteeinstellungen, RTA, Software-Presets, Layout-Slots und die Oberfläche des internen Pi-Displays werden ersetzt. Externe Browser bleiben unverändert.'
);
if (!confirmed) return;
setStatus(status, 'Backup wird geprüft und wiederhergestellt…');
const response = await fetch(`${resolveApiBaseUrl()}/api/v1/device-backup`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
cache: 'no-store',
body: JSON.stringify(payload),
});
const result = await response.json().catch(() => null);
if (!response.ok || result?.ok === false) {
throw new Error(result?.error || `HTTP ${response.status}`);
}
setStatus(status, 'Wiederherstellung abgeschlossen. Oberfläche wird neu geladen.');
setTimeout(() => globalThis.location?.reload?.(), 500);
} catch (error) {
console.error('Device backup restore error:', error);
setStatus(status, `Wiederherstellung fehlgeschlagen: ${error?.message || String(error)}`, true);
} finally {
importInput.value = '';
}
});
}
+162
View File
@@ -0,0 +1,162 @@
const KIOSK_QUERY_VALUE = '1';
const REVISION_KEY = 'analyzer_kiosk_backup_revision_v1';
const SYNC_INTERVAL_MS = 2000;
function isKioskBrowser() {
try {
return new URLSearchParams(globalThis.location?.search || '').get('kiosk') === KIOSK_QUERY_VALUE;
} catch (_) {
return false;
}
}
function isPhoenixStorageKey(key) {
return key !== REVISION_KEY && (
key.startsWith('analyzer_') ||
key.startsWith('MIGRATE_') ||
[
'ppm_din_mode',
'calibration_notice_seen_v1',
'recorder_warning_ack_v1',
'ppm_din_loudness_warn_ack_v1',
'migrate_rta_peak_fall_default_v1',
'migrate_vu_offset_default_zero_v2',
'migrate_lr_delay_zero_to_0_05_v1',
].includes(key)
);
}
function collectStorage() {
const storage = {};
try {
for (let index = 0; index < localStorage.length; index += 1) {
const key = localStorage.key(index);
if (!key || !isPhoenixStorageKey(key)) continue;
const value = localStorage.getItem(key);
if (value !== null) storage[key] = value;
}
} catch (_) {}
return storage;
}
function storageFingerprint(storage) {
return JSON.stringify(Object.entries(storage).sort(([left], [right]) => left.localeCompare(right)));
}
function localRevision() {
try {
const value = Number(localStorage.getItem(REVISION_KEY));
return Number.isSafeInteger(value) && value >= 0 ? value : 0;
} catch (_) {
return 0;
}
}
function setLocalRevision(revision) {
try { localStorage.setItem(REVISION_KEY, String(revision)); } catch (_) {}
}
function applyStorage(storage, revision) {
try {
const remove = [];
for (let index = 0; index < localStorage.length; index += 1) {
const key = localStorage.key(index);
if (key && isPhoenixStorageKey(key)) remove.push(key);
}
remove.forEach((key) => localStorage.removeItem(key));
Object.entries(storage || {}).forEach(([key, value]) => {
if (isPhoenixStorageKey(key) && typeof value === 'string') localStorage.setItem(key, value);
});
setLocalRevision(revision);
return true;
} catch (error) {
console.warn('Kiosk browser restore failed:', error);
return false;
}
}
function apiUrl() {
const hostname = String(globalThis.location?.hostname || '127.0.0.1');
return `http://${hostname}:8789/api/v1/kiosk-browser-config`;
}
async function readRemote() {
const response = await fetch(`${apiUrl()}?_=${Date.now()}`, { cache: 'no-store' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
async function writeRemote(baseRevision, storage) {
const response = await fetch(apiUrl(), {
method: 'POST',
headers: { 'content-type': 'application/json' },
cache: 'no-store',
body: JSON.stringify({ baseRevision, storage }),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const error = new Error(payload?.error || `HTTP ${response.status}`);
error.status = response.status;
throw error;
}
return payload;
}
export async function setupKioskBrowserBackup() {
if (!isKioskBrowser()) return true;
let remote;
try {
remote = await readRemote();
} catch (error) {
console.warn('Kiosk browser backup unavailable:', error);
return true;
}
let revision = Number(remote?.revision) || 0;
const storedRevision = localRevision();
if (revision > storedRevision) {
if (applyStorage(remote?.storage || {}, revision)) {
globalThis.location?.reload?.();
return false;
}
} else if (revision === 0 && Object.keys(remote?.storage || {}).length === 0) {
try {
const result = await writeRemote(0, collectStorage());
revision = Number(result?.revision) || 1;
setLocalRevision(revision);
} catch (error) {
console.warn('Initial kiosk browser backup failed:', error);
}
} else {
setLocalRevision(revision);
}
let fingerprint = storageFingerprint(collectStorage());
let running = false;
globalThis.setInterval(async () => {
if (running) return;
running = true;
try {
const latest = await readRemote();
const latestRevision = Number(latest?.revision) || 0;
if (latestRevision > revision) {
if (applyStorage(latest?.storage || {}, latestRevision)) globalThis.location?.reload?.();
return;
}
const storage = collectStorage();
const nextFingerprint = storageFingerprint(storage);
if (nextFingerprint !== fingerprint) {
const result = await writeRemote(revision, storage);
revision = Number(result?.revision) || (revision + 1);
setLocalRevision(revision);
fingerprint = nextFingerprint;
}
} catch (error) {
if (error?.status !== 409) console.warn('Kiosk browser backup sync failed:', error);
} finally {
running = false;
}
}, SYNC_INTERVAL_MS);
return true;
}
+51 -139
View File
@@ -1,13 +1,15 @@
// ui/options.js — bindet das Options-Panel an CONFIG (lesen/schreiben), Presets, Export/Import
// Erwartet vorhandenes DOM aus index.html. Nutzt localStorage-Key 'analyzer_config'.
import { CONFIG, saveConfig, loadConfig, applyAlignmentProfile, applyRtaBpoSelection, applySoftwarePreset, loadSoftwarePreset, saveSoftwarePreset, loadLayoutPreset, saveLayoutPreset, exportSoftwarePreset, importSoftwarePreset, updateVuReference, resetConfigToDefaults, updateInputOffsetGain } from '../core/config.js';
import { CONFIG, saveConfig, loadConfig, applyAlignmentProfile, applyRtaBpoSelection, applySoftwarePreset, loadSoftwarePreset, saveSoftwarePreset, loadLayoutPreset, saveLayoutPreset, exportSoftwarePreset, importSoftwarePreset, updateVuReference, resetConfigToDefaults, updateInputOffsetGain, normalizeCorrelationResponseSeconds } from '../core/config.js';
import { clamp, clampPow2 } from '../core/utils.js';
import { bindOptionsViewportAssist } from './options_viewport.js';
import { setupLazyChangelog } from './changelog.js';
import { setupDeviceBackup } from './device_backup.js';
// DOM helper
const E = (id) => document.getElementById(id);
const PPM_LOUD_WARN_KEY = 'ppm_din_loudness_warn_ack_v1';
const PPM_DIN_FAST_WARN_KEY = 'ppm_din_fast_attack_warn_ack_v1';
function getPpmDinVisibleBaseOffset() {
const baseMode = (CONFIG.PPM_DIN_MODE === 'al_minus6') ? -6 : -9;
@@ -81,113 +83,26 @@ function showPpmLoudnessWarning(onConfirm) {
wrap.style.display = 'flex';
}
function showPpmDinFastAttackWarning(onConfirm) {
const wrap = E('ppmDinFastWarn');
const btn = E('ppmDinFastWarnBtn');
if (!wrap || !btn) {
if (onConfirm) onConfirm();
return;
}
if (!wrap.dataset.bound) {
btn.addEventListener('click', () => {
try { localStorage.setItem(PPM_DIN_FAST_WARN_KEY, '1'); } catch (_) {}
wrap.style.display = 'none';
if (onConfirm) onConfirm();
});
wrap.addEventListener('click', (e) => {
if (e.target === wrap) wrap.style.display = 'none';
});
wrap.dataset.bound = '1';
}
wrap.style.display = 'flex';
}
export function setupOptions(env) {
// Initial laden
loadConfig();
enforcePhaseAmplitudeConstraints({ persist: true });
syncUI();
wireHandlers(env);
bindOptionsViewportAssist();
bindOptionsViewportAssist(E('optionsPanelNew'));
setupLazyChangelog(E('changelogDetails'), E('changelogContent'));
setupDeviceBackup({
exportButton: E('btnDeviceBackupExport'),
importInput: E('impDeviceBackup'),
status: E('deviceBackupStatus'),
resolveApiBaseUrl: resolvePhoenixApiBaseUrl,
});
bindAlignmentToggle(env);
bindVuRefToggle(env);
// Sichtbarkeit nach View steuern übernimmt main.js — hier nur Export der Sync-Funktion
return { syncUI };
}
let optionsViewportAssistBound = false;
function bindOptionsViewportAssist() {
if (optionsViewportAssistBound) return;
optionsViewportAssistBound = true;
const panel = E('optionsPanelNew');
if (!panel) return;
const setKeyboardInset = () => {
const vv = window.visualViewport;
if (!vv) {
panel.style.setProperty('--options-kb-inset', '0px');
return;
}
const inset = Math.max(0, window.innerHeight - (vv.height + vv.offsetTop));
panel.style.setProperty('--options-kb-inset', `${Math.round(inset)}px`);
};
const revealFocusedField = (target) => {
if (!panel || !target || !panel.contains(target)) return;
const vv = window.visualViewport;
const panelRect = panel.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const keyboardInset = vv ? Math.max(0, window.innerHeight - (vv.height + vv.offsetTop)) : 0;
const visibleTop = panelRect.top + 16;
const visibleBottom = panelRect.bottom - keyboardInset - 16;
if (targetRect.bottom > visibleBottom || targetRect.top < visibleTop) {
const targetCenter = targetRect.top + (targetRect.height / 2);
const visibleCenter = visibleTop + ((visibleBottom - visibleTop) / 2);
const delta = targetCenter - visibleCenter;
panel.scrollTop += delta;
}
};
const onFocus = (ev) => {
const el = ev.target;
if (!(el instanceof HTMLElement)) return;
const type = String(el.getAttribute('type') || '').toLowerCase();
if (el.tagName !== 'INPUT' && el.tagName !== 'SELECT' && el.tagName !== 'TEXTAREA') return;
if (type === 'checkbox' || type === 'range' || type === 'color') return;
setKeyboardInset();
requestAnimationFrame(() => revealFocusedField(el));
setTimeout(() => revealFocusedField(el), 250);
};
const onBlur = () => {
setTimeout(() => {
const active = document.activeElement;
if (!(active instanceof HTMLElement) || !panel.contains(active)) {
panel.style.setProperty('--options-kb-inset', '0px');
}
}, 50);
};
panel.addEventListener('focusin', onFocus);
panel.addEventListener('focusout', onBlur);
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', () => {
setKeyboardInset();
const active = document.activeElement;
if (active instanceof HTMLElement && panel.contains(active)) {
requestAnimationFrame(() => revealFocusedField(active));
}
});
window.visualViewport.addEventListener('scroll', setKeyboardInset);
}
setKeyboardInset();
}
function syncSoftwarePresetControls() {
const presetId = String(E('opt_softwarePreset')?.value || CONFIG.SOFTWARE_PRESET || 'default').toLowerCase();
const isDefault = presetId === 'default';
@@ -237,7 +152,6 @@ function syncUI() {
['opt_ppmDinColWarn', CONFIG.PPM_DIN_COLOR_WARN],
['opt_ppmDinHeaderValue', CONFIG.PPM_DIN_HEADER_SHOW_VALUE, null, null, 'checkbox'],
['opt_ppmDinLoudnessBox', CONFIG.PPM_DIN_LOUDNESS_BOXES, null, null, 'checkbox'],
['opt_ppmDinFastAttack', CONFIG.PPM_DIN_FAST_ATTACK, null, null, 'checkbox'],
['opt_ppmDinLoudOff', CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB ?? 0, 'val_ppmDinLoudOff', (v)=>`${Number(v).toFixed(1)} dB`],
['opt_ppmEbuColNorm', CONFIG.PPM_EBU_COLOR_NORMAL],
['opt_ppmEbuColWarn', CONFIG.PPM_EBU_COLOR_WARN],
@@ -270,7 +184,8 @@ function syncUI() {
['opt_rmsColNorm', CONFIG.RMS_COLOR_NORMAL],
['opt_rmsColWarn', CONFIG.RMS_COLOR_WARN],
['opt_corrResponse', String(Number(CONFIG.CORR_RESPONSE_S) >= 1.75 ? 2.5 : 1)],
['opt_corrResponse', String(normalizeCorrelationResponseSeconds(CONFIG.CORR_RESPONSE_S))],
['opt_corrNegativeMarkerMode', CONFIG.CORR_NEGATIVE_MARKER_MODE || 'memory'],
['opt_xyPoints', String(CONFIG.XY_POINTS)],
['opt_xyStyle', CONFIG.XY_STYLE],
['opt_xySilenceGate', CONFIG.XY_SILENCE_GATE_ENABLED, null, null, 'checkbox'],
@@ -278,6 +193,7 @@ function syncUI() {
['opt_barThin', CONFIG.METER_BAR_THIN, 'val_barThin', (v)=>Number(v).toFixed(2)],
['opt_headerTextColor', CONFIG.HEADER_TEXT_COLOR || '#ffe066'],
['opt_rtaEngine', CONFIG.RTA_ENGINE || 'iir'],
['opt_rtaFilterbank', CONFIG.RTA_IIR_FILTERBANK || 'legacy'],
['opt_rtaBpo', CONFIG.RTA_BPO_MODE || '1_6'],
['opt_rtaOrder', CONFIG.RTA_IIR_ORDER ?? 4],
['opt_rtaDisplayGainFft', CONFIG.RTA_DISPLAY_GAIN_FFT_DB ?? 12, 'val_rtaDisplayGainFft', (v)=>`${v} dB`],
@@ -286,6 +202,7 @@ function syncUI() {
['opt_rtaLayout', CONFIG.RTA_BAR_LAYOUT || 'iec'],
['opt_rtaBarBaseColor', CONFIG.RTA_BAR_BASE_COLOR || '#ffe066'],
['opt_rtaWeighting', CONFIG.RTA_WEIGHTING || 'z'],
['opt_rtaDetector', CONFIG.RTA_DETECTOR || 'average'],
['opt_rtaIntegration', CONFIG.RTA_INTEGRATION || 'fast'],
['opt_rtaBallistics', CONFIG.RTA_BALLISTICS_MODE || 'average'],
['opt_rtaHoldMode', CONFIG.RTA_PEAK_HOLD_MODE || 'auto'],
@@ -302,7 +219,7 @@ function syncUI() {
['opt_waveColorL', CONFIG.WAVEFORM_COLOR_LEFT || '#00e7ff'],
['opt_waveColorR', CONFIG.WAVEFORM_COLOR_RIGHT || '#ff6b81'],
['opt_waveColorDiff', CONFIG.WAVEFORM_COLOR_DIFF || '#00e7ff'],
['opt_rtaTauFast', CONFIG.RTA_IIR_TAU_FAST ?? 0.12],
['opt_rtaTauFast', CONFIG.RTA_IIR_TAU_FAST ?? 0.125],
['opt_rtaTauSlow', CONFIG.RTA_IIR_TAU_SLOW ?? 1.0],
['opt_rtRenderMode', CONFIG.REALTIME_RENDER_STYLE || 'bars'],
['opt_rtHold', CONFIG.REALTIME_BAR_HOLD_MS],
@@ -417,6 +334,12 @@ function syncUI() {
const iWin = E('opt_lufsIWindowMin');
if (iWin) iWin.disabled = !!CONFIG.LUFS_I_NORM_ENABLED;
const rtaResponse = E('opt_rtaIntegration');
if (rtaResponse) rtaResponse.disabled = CONFIG.RTA_DETECTOR === 'peak';
const rtaTauFast = E('opt_rtaTauFast');
if (rtaTauFast) rtaTauFast.disabled = true;
const rtaTauSlow = E('opt_rtaTauSlow');
if (rtaTauSlow) rtaTauSlow.disabled = true;
const mp3Wrap = E('opt_recMp3BitrateWrap');
if (mp3Wrap) mp3Wrap.style.display = (String(CONFIG.RECORD_OUTPUT_FORMAT || 'wav') === 'mp3') ? '' : 'none';
const mp3Sel = E('opt_recMp3Bitrate');
@@ -534,8 +457,8 @@ function wireHandlers(env) {
if (!el) return;
const notifyRta = /^opt_rta/i.test(id)
|| id === 'opt_lufsIWindowMin'
|| id === 'opt_lufsINorm';
const notifyPpm = (id === 'opt_ppmDinFastAttack');
|| id === 'opt_lufsINorm'
|| id === 'opt_xySilenceThr';
const inputType = String(el.type || '').toLowerCase();
const commitOnInput = isCheckbox || inputType === 'range' || inputType === 'color';
@@ -549,9 +472,6 @@ function wireHandlers(env) {
if (notifyRta) {
try { env?.notifyRtaConfig?.(); } catch (_) {}
}
if (notifyPpm) {
try { env?.notifyPpmConfig?.(); } catch (_) {}
}
try { env?.syncRealtimeGain?.(); } catch (_) {}
try { env?.syncWaveformWindow?.(); } catch (_) {}
try { env?.syncWaveformMode?.(); } catch (_) {}
@@ -780,29 +700,6 @@ function wireHandlers(env) {
CONFIG.PPM_DIN_HEADER_SHOW_VALUE = !!v;
return CONFIG.PPM_DIN_HEADER_SHOW_VALUE;
}, null, null, true);
h('opt_ppmDinFastAttack', v => {
const desired = !!v;
const cb = E('opt_ppmDinFastAttack');
const acked = (() => {
try { return localStorage.getItem(PPM_DIN_FAST_WARN_KEY) === '1'; }
catch (_) { return false; }
})();
if (desired && !acked) {
if (cb) cb.checked = false;
CONFIG.PPM_DIN_FAST_ATTACK = false;
showPpmDinFastAttackWarning(() => {
CONFIG.PPM_DIN_FAST_ATTACK = true;
if (cb) cb.checked = true;
try { saveConfig(); } catch (_) {}
try { env?.notifyPpmConfig?.(); } catch (_) {}
notifyPhoenixGlobalConfig();
});
return false;
}
CONFIG.PPM_DIN_FAST_ATTACK = desired;
notifyPhoenixGlobalConfig();
return desired;
}, null, null, true);
h('opt_ppmDinLoudnessBox', v => {
const desired = !!v;
const cb = E('opt_ppmDinLoudnessBox');
@@ -866,9 +763,9 @@ function wireHandlers(env) {
}, null, null, true);
h('opt_rmsTc', v => {
const mode = String(v).toLowerCase();
if (mode === 'impulse' || mode === 'fast' || mode === 'slow' || mode === 'none') {
if (mode === 'fast' || mode === 'slow' || mode === 'window') {
CONFIG.RMS_TC_MODE = mode;
CONFIG.RMS_TC_MS = null;
notifyPhoenixGlobalConfig();
}
return CONFIG.RMS_TC_MODE;
});
@@ -917,10 +814,17 @@ function wireHandlers(env) {
});
h('opt_corrResponse', v => {
CONFIG.CORR_RESPONSE_S = Number(v) >= 1.75 ? 2.5 : 1.0;
CONFIG.CORR_RESPONSE_S = normalizeCorrelationResponseSeconds(v);
try { env?.notifyRtaConfig?.(); } catch (_) {}
return String(CONFIG.CORR_RESPONSE_S);
});
h('opt_corrNegativeMarkerMode', v => {
const mode = String(v || '').toLowerCase();
CONFIG.CORR_NEGATIVE_MARKER_MODE = ['memory', 'hold', 'off'].includes(mode)
? mode
: 'memory';
return CONFIG.CORR_NEGATIVE_MARKER_MODE;
});
const corrResetPeak = E('opt_corrResetPeak');
if (corrResetPeak) corrResetPeak.onclick = () => {
CONFIG.CORR_RESET_TOKEN = Math.max(0, Math.floor(Number(CONFIG.CORR_RESET_TOKEN) || 0)) + 1;
@@ -1074,6 +978,10 @@ function wireHandlers(env) {
CONFIG.RTA_ENGINE = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'iir' : ((v === 'iir') ? 'iir' : 'fft');
return CONFIG.RTA_ENGINE;
});
h('opt_rtaFilterbank', v => {
CONFIG.RTA_IIR_FILTERBANK = v === 'butterworth' ? 'butterworth' : 'legacy';
return CONFIG.RTA_IIR_FILTERBANK;
});
h('opt_rtaBpo', v => {
applyRtaBpoSelection(v);
const layoutControl = E('opt_rtaLayout');
@@ -1104,12 +1012,10 @@ function wireHandlers(env) {
CONFIG.RTA_BAR_LAYOUT = val;
if (val === 'rtw') {
CONFIG.RTA_ENGINE = 'iir';
CONFIG.RTA_BPO_MODE = '1_3';
CONFIG.RTA_IIR_ORDER = 6;
CONFIG.RTA_FREQ_RANGE = 'norm';
const forced = {
opt_rtaEngine: 'iir',
opt_rtaBpo: '1_3',
opt_rtaOrder: '6',
opt_rtaFreq: 'norm',
};
@@ -1130,8 +1036,14 @@ function wireHandlers(env) {
const allowed = new Set(['z','a','c']);
CONFIG.RTA_WEIGHTING = allowed.has(v) ? v : 'z';
});
h('opt_rtaDetector', v => {
CONFIG.RTA_DETECTOR = v === 'peak' ? 'peak' : 'average';
const response = E('opt_rtaIntegration');
if (response) response.disabled = CONFIG.RTA_DETECTOR === 'peak';
return CONFIG.RTA_DETECTOR;
});
h('opt_rtaIntegration', v => {
const allowed = new Set(['impulse','fast','medium','slow','average','peak']);
const allowed = new Set(['impulse','fast','medium','slow']);
CONFIG.RTA_INTEGRATION = allowed.has(v) ? v : 'fast';
return CONFIG.RTA_INTEGRATION;
});
@@ -1141,9 +1053,10 @@ function wireHandlers(env) {
return val;
});
h('opt_rtaHoldMode', v => {
const allowed = new Set(['off','auto','manual']);
CONFIG.RTA_PEAK_HOLD_MODE = allowed.has(v) ? v : 'auto';
const allowed = new Set(['off','auto','fall','manual']);
CONFIG.RTA_PEAK_HOLD_MODE = allowed.has(v) ? v : 'fall';
});
h('opt_rtaDecay', v => { CONFIG.RTA_PEAK_DECAY_DB_PER_S = clamp(+v, 1, 60); });
h('opt_rtaHoldTime', v => {
const requested = clamp(+v, 0, 30);
CONFIG.RTA_PEAK_HOLD_SEC = CONFIG.RTA_BAR_LAYOUT === 'rtw'
@@ -1151,7 +1064,6 @@ function wireHandlers(env) {
: requested;
return CONFIG.RTA_PEAK_HOLD_SEC;
});
h('opt_rtaDecay', v => { CONFIG.RTA_PEAK_DECAY_DB_PER_S = clamp(+v, 1, 60); });
h('opt_rtaDisplayHold', v => { CONFIG.RTA_DISPLAY_HOLD_SEC = clamp(+v, 0, 5); });
h('opt_spectroGamma', v => {
const num = clamp(parseFloat(v), 0.3, 1.2);
@@ -1209,8 +1121,8 @@ function wireHandlers(env) {
CONFIG.WAVEFORM_COLOR_DIFF = v;
notifyPhoenixGlobalConfig();
});
h('opt_rtaTauFast', v => { CONFIG.RTA_IIR_TAU_FAST = clamp(+v, 0.01, 1.0); });
h('opt_rtaTauSlow', v => { CONFIG.RTA_IIR_TAU_SLOW = clamp(+v, 0.1, 5.0); });
h('opt_rtaTauFast', () => { CONFIG.RTA_IIR_TAU_FAST = 0.125; return 0.125; });
h('opt_rtaTauSlow', () => { CONFIG.RTA_IIR_TAU_SLOW = 1.0; return 1.0; });
h('opt_rtRenderMode', v => { CONFIG.REALTIME_RENDER_STYLE = (v === 'line') ? 'line' : 'bars'; });
h('opt_rtHold', v => CONFIG.REALTIME_BAR_HOLD_MS = clamp(+v, 100, 5000));
h('opt_rtDecay', v => CONFIG.REALTIME_BAR_DECAY_DB_PER_S = clamp(+v, 1, 60));
+65
View File
@@ -0,0 +1,65 @@
let bound = false;
// Hält fokussierte Eingabefelder auch bei eingeblendeter Bildschirmtastatur sichtbar.
export function bindOptionsViewportAssist(panel) {
if (bound || !panel) return;
bound = true;
const setKeyboardInset = () => {
const viewport = window.visualViewport;
if (!viewport) {
panel.style.setProperty('--options-kb-inset', '0px');
return;
}
const inset = Math.max(0, window.innerHeight - (viewport.height + viewport.offsetTop));
panel.style.setProperty('--options-kb-inset', `${Math.round(inset)}px`);
};
const revealFocusedField = (target) => {
if (!target || !panel.contains(target)) return;
const viewport = window.visualViewport;
const panelRect = panel.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const keyboardInset = viewport ? Math.max(0, window.innerHeight - (viewport.height + viewport.offsetTop)) : 0;
const visibleTop = panelRect.top + 16;
const visibleBottom = panelRect.bottom - keyboardInset - 16;
if (targetRect.bottom > visibleBottom || targetRect.top < visibleTop) {
const targetCenter = targetRect.top + targetRect.height / 2;
const visibleCenter = visibleTop + (visibleBottom - visibleTop) / 2;
panel.scrollTop += targetCenter - visibleCenter;
}
};
const onFocus = (event) => {
const element = event.target;
if (!(element instanceof HTMLElement)) return;
const type = String(element.getAttribute('type') || '').toLowerCase();
if (!['INPUT', 'SELECT', 'TEXTAREA'].includes(element.tagName)) return;
if (type === 'checkbox' || type === 'range' || type === 'color') return;
setKeyboardInset();
requestAnimationFrame(() => revealFocusedField(element));
setTimeout(() => revealFocusedField(element), 250);
};
panel.addEventListener('focusin', onFocus);
panel.addEventListener('focusout', () => {
setTimeout(() => {
const active = document.activeElement;
if (!(active instanceof HTMLElement) || !panel.contains(active)) {
panel.style.setProperty('--options-kb-inset', '0px');
}
}, 50);
});
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', () => {
setKeyboardInset();
const active = document.activeElement;
if (active instanceof HTMLElement && panel.contains(active)) {
requestAnimationFrame(() => revealFocusedField(active));
}
});
window.visualViewport.addEventListener('scroll', setKeyboardInset);
}
setKeyboardInset();
}
+25 -29
View File
@@ -2,6 +2,7 @@
// Nutzt die bestehenden VU-Werte (inkl. Offsets/Kalibrierung) und zeichnet
// zwei kompakte, horizontale Nadelinstrumente (L/R) mit Slot-Auswahl.
import { drawCachedStaticLayer } from './static_layer.js';
import { TRUE_PEAK_MINOR_TICKS, TRUE_PEAK_SCALE } from '../meters/true_peak_scale.js';
export const id = 'classic-needles';
@@ -47,22 +48,6 @@ const DIN_SCALE = [
{ db: +5, pos: 1.0000 },
];
const TP_PERCENT_SCALE = [
{ db: -60, frac: 0.0000 },
{ db: -50, frac: 0.0373 },
{ db: -40, frac: 0.1429 },
{ db: -35, frac: 0.2112 },
{ db: -30, frac: 0.2857 },
{ db: -25, frac: 0.3851 },
{ db: -20, frac: 0.4783 },
{ db: -15, frac: 0.6087 },
{ db: -10, frac: 0.7391 },
{ db: -5, frac: 0.8696 },
{ db: 0, frac: 1.0000 },
];
const TP_EXTRA_MINOR_TICKS = [-47, -19, -18, -17, -16, -14, -13, -12, -11, -9, -8, -7, -6, -4, -3, -2, -1];
const PPM_EBU_MAJOR_TICKS = [-12, -8, -4, 0, +4, +8, +12];
const PPM_EBU_MINOR_TICKS = [-10, -6, -2, +2, +6, +9, +10];
@@ -122,7 +107,19 @@ export async function render(env, state) {
const raw = readMeterDisplayLR(meterId, meterState, CONFIG, scale, audio);
const targets = { L: scale.valueToNorm(raw.L), R: scale.valueToNorm(raw.R) };
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
smoothNeedle(state, targets, now);
if (meterId === 'rms') {
// RMS ballistics are already measured sample-continuously in the backend.
// A second spring model here would add view-dependent delay and values.
state.smooth.L = targets.L;
state.smooth.R = targets.R;
state.goal.L = targets.L;
state.goal.R = targets.R;
state.vel.L = 0;
state.vel.R = 0;
state.lastTs = now;
} else {
smoothNeedle(state, targets, now);
}
// Box-Abmessungen an den Real-Time-Analyzer anlehnen (gleiche Offsets)
const BOX_LEFT = 0;
const BOX_TOP = Number.isFinite(env?.topInset) ? Number(env.topInset) : 70;
@@ -425,24 +422,24 @@ function getNeedleScaleDescriptor(meterId, CONFIG) {
if (meterId === 'tp') {
const bottom = -60;
const top = 0;
const top = 6;
const redStart = Number.isFinite(CONFIG?.TP_RED_START) ? CONFIG.TP_RED_START : -1;
const warnColor = CONFIG?.TP_COLOR_WARN || '#ff3b3b';
const normalColor = CONFIG?.TP_COLOR_NORMAL || '#ffe066';
const mapRaw = (db) => {
const clamped = clamp(db, bottom, top);
if (clamped <= TP_PERCENT_SCALE[0].db) return TP_PERCENT_SCALE[0].frac;
if (clamped >= TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].db) return TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].frac;
for (let i = 1; i < TP_PERCENT_SCALE.length; i++) {
const prev = TP_PERCENT_SCALE[i - 1];
const curr = TP_PERCENT_SCALE[i];
if (clamped <= TRUE_PEAK_SCALE[0].db) return TRUE_PEAK_SCALE[0].frac;
if (clamped >= TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].db) return TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].frac;
for (let i = 1; i < TRUE_PEAK_SCALE.length; i++) {
const prev = TRUE_PEAK_SCALE[i - 1];
const curr = TRUE_PEAK_SCALE[i];
if (clamped <= curr.db) {
const span = curr.db - prev.db || 1;
const t = (clamped - prev.db) / span;
return prev.frac + t * (curr.frac - prev.frac);
}
}
return TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].frac;
return TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].frac;
};
const valueToNorm = (v) => clamp01(mapRaw(v));
return {
@@ -454,11 +451,10 @@ function getNeedleScaleDescriptor(meterId, CONFIG) {
warnColor,
normalColor,
unitLabel: 'dBTP',
majorTicks: TP_PERCENT_SCALE.map((p) => p.db),
minorTicks: TP_EXTRA_MINOR_TICKS,
majorTicks: TRUE_PEAK_SCALE.map((p) => p.db),
minorTicks: TRUE_PEAK_MINOR_TICKS,
formatMajor: (db) => {
const v = Math.abs(db);
return Number.isInteger(v) ? String(v) : v.toFixed(1);
return db > 0 ? `+${db}` : String(db);
},
valueToNorm,
};
@@ -633,7 +629,7 @@ function readMeterDisplayLR(meterId, shared, CONFIG, scale, audio) {
const isDBU = (mode === 'dbu');
if (isDBU) {
const refDbfs = Number.isFinite(CONFIG?.RMS_REF_DBFS_FOR_REF_DBU) ? CONFIG.RMS_REF_DBFS_FOR_REF_DBU : -18;
const refDbu = Number.isFinite(CONFIG?.RMS_REF_DBU) ? CONFIG.RMS_REF_DBU : +4;
const refDbu = Number.isFinite(CONFIG?.RMS_REF_DBU) ? CONFIG.RMS_REF_DBU : 0;
rawL = (rawL - refDbfs) + refDbu;
rawR = (rawR - refDbfs) + refDbu;
}
+42 -2
View File
@@ -24,6 +24,7 @@ const BASE_ZOOM = 1.0;
const BASE_GONIO_SCALE = (BASE_TARGET / ALIGN_PEAK) * Math.pow(10, -BASE_HEADROOM_DB / 20) * BASE_ZOOM;
const AGC_TARGET_DB = linearToDb(ALIGN_PEAK);
const CORR_SILENCE_THRESHOLD_DEFAULT = -75;
const CORR_HOLD_MS = 4000;
const MAX_TRAIL_FRAMES = 48;
function computePanelSlotWidth(canvasWidth, slotCount = METER_SLOTS, slotGap = 12) {
@@ -48,6 +49,10 @@ export function init() {
staticLayerCanvas: null,
staticLayerCtx: null,
staticLayerKey: '',
corrNegativeMarkerMode: 'memory',
corrHoldPeak: 0,
corrHoldUntil: 0,
corrResetToken: 0,
};
}
@@ -99,9 +104,16 @@ export async function render(env, state) {
// Correlation is measured continuously in the audio DSP. Do not add a
// second, frame-rate-dependent browser integration here.
const corrVisual = Number.isFinite(audio?.correlation) ? clamp1(audio.correlation) : 0;
const corrNegativePeak = Number.isFinite(audio?.correlationNegativePeak)
const corrMemoryPeak = Number.isFinite(audio?.correlationNegativePeak)
? clamp1(audio.correlationNegativePeak)
: 0;
const corrNegativeMarker = resolveCorrelationNegativeMarker(
state,
CONFIG,
corrVisual,
corrMemoryPeak,
frameNow,
);
drawCorrelationBar(
g,
layout.plot.x + layout.plot.w / 2,
@@ -109,7 +121,7 @@ export async function render(env, state) {
Math.round(Math.min(layout.scope.w * 0.75, layout.plot.w - 50)),
18,
corrVisual,
corrNegativePeak,
corrNegativeMarker,
);
if (slots.length) {
@@ -123,6 +135,34 @@ export async function render(env, state) {
}
}
export function resolveCorrelationNegativeMarker(state, CONFIG = {}, current = 0, memoryPeak = 0, nowMs = 0) {
const requestedMode = String(CONFIG.CORR_NEGATIVE_MARKER_MODE || 'memory').toLowerCase();
const mode = ['memory', 'hold', 'off'].includes(requestedMode) ? requestedMode : 'memory';
const resetToken = Math.max(0, Math.floor(Number(CONFIG.CORR_RESET_TOKEN) || 0));
if (state.corrResetToken !== resetToken || state.corrNegativeMarkerMode !== mode) {
state.corrResetToken = resetToken;
state.corrNegativeMarkerMode = mode;
state.corrHoldPeak = 0;
state.corrHoldUntil = 0;
}
if (mode === 'off') return 0;
if (mode === 'memory') return Math.max(-1, Math.min(1, Number(memoryPeak) || 0));
const now = Number.isFinite(nowMs) ? nowMs : 0;
const currentNegative = Math.min(0, Math.max(-1, Math.min(1, Number(current) || 0)));
const holdActive = Number.isFinite(state.corrHoldUntil) && now < state.corrHoldUntil;
if (!holdActive) {
state.corrHoldPeak = currentNegative;
state.corrHoldUntil = currentNegative < -0.001 ? now + CORR_HOLD_MS : 0;
} else if (currentNegative < state.corrHoldPeak) {
state.corrHoldPeak = currentNegative;
state.corrHoldUntil = now + CORR_HOLD_MS;
}
return Number.isFinite(state.corrHoldPeak) ? state.corrHoldPeak : 0;
}
function drawStaticLayer(g, state, rect, layout, CONFIG, slotCount) {
const layer = ensureStaticLayer(state, rect, layout, CONFIG, slotCount);
if (!layer) {
+237
View File
@@ -0,0 +1,237 @@
// Gemeinsame, rein interne Bausteine für Split- und Quad-View.
import * as viewGoni from './goniometer_rtw.js';
import * as viewPhaseWheel from './phase_wheel.js';
import * as viewPanel from './panel.js';
import * as viewRealtime from './realtime.js';
import * as viewClassicNeedles from './classic_needles.js';
import * as viewPeakHistory from './peak_history.js';
import * as viewClock from './clock.js';
import * as viewWaveform from './waveform.js';
import * as viewSpectrogram from './spectrogram.js';
import { FRAME_COLOR, PANEL_BG } from '../core/theme.js';
import { drawCachedStaticLayer } from './static_layer.js';
export const CHILD_VIEWS = {
'realtime': viewRealtime,
'classic-needles': viewClassicNeedles,
'peak-history': viewPeakHistory,
'goniometer-rtw': viewGoni,
'phase-wheel': viewPhaseWheel,
'panel': viewPanel,
'clock': viewClock,
'waveform': viewWaveform,
'spectrogram': viewSpectrogram,
};
const ALLOWED_CHILD_IDS = new Set(['none', ...Object.keys(CHILD_VIEWS)]);
const ALLOWED_METER_IDS = new Set(['none', 'vu', 'ppm-ebu', 'ppm-din', 'tp', 'hifi-peak', 'rms', 'lufs', 'stopwatch']);
const ALLOWED_METER_POSITIONS = new Set(['left', 'center', 'right']);
const METER_GAP = 12;
const METER_PAD_TOP = 15;
const METER_PAD_BOTTOM = 5;
const METER_SLOT_SHRINK = 24;
const METER_EXTRA_BOTTOM_PAD = 6;
const OUTER_GAP = 10;
const SIDE_INNER_GAP = 8;
const METER_W_DEFAULT = 140;
const METER_W_MIN = 90;
const MIN_PLOT_W = 240;
export function sanitizeChildId(value, fallback) {
return ALLOWED_CHILD_IDS.has(value) ? value : fallback;
}
export function sanitizePlotId(value, fallback) {
return ALLOWED_CHILD_IDS.has(value) ? value : fallback;
}
export function sanitizeMeterId(value, fallback) {
const id = String(value || '');
return ALLOWED_METER_IDS.has(id) ? id : fallback;
}
export function sanitizeMeterPos(value, fallback) {
const id = String(value || '');
return ALLOWED_METER_POSITIONS.has(id) ? id : fallback;
}
function clampMeterCount(value) {
const n = Number(value);
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(3, n | 0));
}
export function readMultiViewMeters(config, prefix) {
const count = clampMeterCount(config?.[`${prefix}_METER_COUNT`]);
const defaults = ['vu', 'ppm-din', 'lufs'];
return defaults.map((fallback, index) => {
const number = index + 1;
return {
id: sanitizeMeterId(config?.[`${prefix}_METER_${number}`], fallback),
pos: sanitizeMeterPos(config?.[`${prefix}_METER_${number}_POS`], 'right'),
};
}).slice(0, count);
}
export function groupMetersByPosition(slots) {
const out = { left: [], center: [], right: [] };
for (const slot of slots || []) {
if (!slot) continue;
if (slot.pos === 'left') out.left.push(slot.id);
else if (slot.pos === 'center') out.center.push(slot.id);
else out.right.push(slot.id);
}
return out;
}
export function computeMultiViewBaseLayout(rect, slots, contentTop, contentBottom = 0) {
const contentH = Math.max(0, rect.h - contentTop - contentBottom);
const hasContent = contentH >= 140;
let effectiveSlots = hasContent ? (Array.isArray(slots) ? slots.slice(0, 3) : []) : [];
while (true) {
const grouped = groupMetersByPosition(effectiveSlots);
const leftCount = grouped.left.length;
const centerCount = grouped.center.length;
const rightCount = grouped.right.length;
const totalSlots = leftCount + centerCount + rightCount;
const hasLeftMeters = leftCount > 0;
const hasCenterMeters = centerCount > 0;
const hasRightMeters = rightCount > 0;
const interBlockGaps =
(hasLeftMeters ? SIDE_INNER_GAP : 0) +
(hasRightMeters ? SIDE_INNER_GAP : 0) +
(hasCenterMeters ? 2 * SIDE_INNER_GAP : OUTER_GAP);
const internalGaps =
Math.max(0, leftCount - 1) * METER_GAP +
Math.max(0, centerCount - 1) * METER_GAP +
Math.max(0, rightCount - 1) * METER_GAP;
const remainingForMeters = rect.w - (2 * MIN_PLOT_W + interBlockGaps + internalGaps);
let slotW = totalSlots > 0 ? Math.min(METER_W_DEFAULT, Math.floor(remainingForMeters / totalSlots)) : 0;
if (totalSlots > 0 && slotW < METER_W_MIN && effectiveSlots.length) {
effectiveSlots = effectiveSlots.slice(0, -1);
continue;
}
if (totalSlots > 0) slotW = Math.max(METER_W_MIN, Math.min(METER_W_DEFAULT, slotW));
const groupWidth = (count) => count > 0 ? count * slotW + Math.max(0, count - 1) * METER_GAP : 0;
const leftW = groupWidth(leftCount);
const centerW = groupWidth(centerCount);
const rightW = groupWidth(rightCount);
const plotAvail = Math.max(0, rect.w - interBlockGaps - leftW - centerW - rightW);
const leftPlotW = Math.floor(plotAvail / 2);
const rightPlotW = plotAvail - leftPlotW;
if ((leftPlotW < MIN_PLOT_W || rightPlotW < MIN_PLOT_W) && effectiveSlots.length) {
effectiveSlots = effectiveSlots.slice(0, -1);
continue;
}
let x = 0;
const leftMetersRect = hasLeftMeters ? { x, y: contentTop, w: leftW, h: contentH } : null;
if (leftMetersRect) x += leftW + SIDE_INNER_GAP;
const leftPlotRect = { x, y: 0, w: leftPlotW, h: rect.h };
x += leftPlotW;
let centerMetersRect = null;
if (hasCenterMeters) {
x += SIDE_INNER_GAP;
centerMetersRect = { x, y: contentTop, w: centerW, h: contentH };
x += centerW + SIDE_INNER_GAP;
} else {
x += OUTER_GAP;
}
const rightPlotRect = { x, y: 0, w: rightPlotW, h: rect.h };
x += rightPlotW;
let rightMetersRect = null;
if (hasRightMeters) {
x += SIDE_INNER_GAP;
rightMetersRect = { x, y: contentTop, w: rightW, h: contentH };
}
return {
plots: { left: leftPlotRect, right: rightPlotRect },
meters: { left: leftMetersRect, center: centerMetersRect, right: rightMetersRect },
meterIds: grouped,
slotW,
effectiveSlots,
};
}
}
export async function withClippedSubRect(g, rect, fn) {
g.save();
g.translate(rect.x, rect.y);
g.beginPath();
g.rect(0, 0, rect.w, rect.h);
g.clip();
try { return await fn(); } finally { g.restore(); }
}
export function drawSubframe(g, rect) {
g.save();
g.strokeStyle = FRAME_COLOR;
g.lineWidth = 2;
g.strokeRect(rect.x + 0.5, rect.y + 0.5, Math.max(0, rect.w - 1), Math.max(0, rect.h - 1));
g.restore();
}
export function drawEmptyPlot(state, g, rect, label) {
drawCachedStaticLayer(state, g, 'empty-plot', String(label || '(leer)'), rect, (lg) => {
lg.fillStyle = PANEL_BG;
lg.fillRect(0, 0, rect.w, rect.h);
lg.fillStyle = '#9aa';
lg.textAlign = 'left';
lg.font = 'bold 14px ui-monospace, monospace';
lg.fillText(label || '(leer)', 12, 32);
lg.textAlign = 'start';
});
}
export async function drawMetersPanel(env, state, rect, meterIds, slotW, ownerLabel) {
const { ctx: g, meters, config } = env;
if (!rect || rect.w <= 0 || rect.h <= 0) return;
const ids = Array.isArray(meterIds) ? meterIds.slice(0, 3) : [];
const count = ids.length;
if (!count) return;
drawCachedStaticLayer(state, g, 'meter-panel-shell', 'bg-frame', rect, (lg) => {
lg.fillStyle = PANEL_BG;
lg.fillRect(0, 0, rect.w, rect.h);
drawSubframe(lg, { x: 0, y: 0, w: rect.w, h: rect.h });
});
const n = Math.max(1, Math.min(3, count));
const usedW = n * slotW + (n - 1) * METER_GAP;
const startX = rect.x + Math.max(0, Math.floor((rect.w - usedW) / 2));
const innerHeight = Math.max(40, rect.h - METER_PAD_TOP - METER_PAD_BOTTOM - METER_SLOT_SHRINK - METER_EXTRA_BOTTOM_PAD);
const slotY = rect.y + METER_PAD_TOP + METER_SLOT_SHRINK / 2;
for (let i = 0; i < n; i++) {
const id = sanitizeMeterId(ids[i], 'none');
const r = { x: startX + i * (slotW + METER_GAP), y: slotY, w: slotW, h: innerHeight };
if (id === 'none') {
drawCachedStaticLayer(state, g, 'meter-slot-empty', `${slotW}x${innerHeight}`, r, (lg) => {
lg.strokeStyle = 'rgba(0,231,255,0.25)';
lg.setLineDash([6, 5]);
lg.strokeRect(0.5, 0.5, Math.max(0, r.w - 1), Math.max(0, r.h - 1));
lg.setLineDash([]);
lg.fillStyle = '#9aa';
lg.textAlign = 'center';
lg.font = '12px ui-monospace, monospace';
lg.fillText('(leer)', r.w / 2, 22);
lg.textAlign = 'start';
});
continue;
}
try {
g.save();
g.beginPath();
g.rect(rect.x + 1, rect.y + 1, Math.max(0, rect.w - 2), Math.max(0, rect.h - 2));
g.clip();
await meters.draw(g, r, id, config);
g.restore();
} catch (error) {
g.restore();
console.warn(`${ownerLabel} meter draw error:`, error);
}
}
}
+11 -57
View File
@@ -1,6 +1,7 @@
// views/peak_history.js - Meter-Verlauf (rechter Slot) mit RTA-Layout
import { FRAME_COLOR, GRID_MAJOR_COLOR, GRID_MINOR_COLOR, LABEL_COLOR, MID_COLOR, PANEL_BG, WARN_COLOR } from '../core/theme.js';
import { TRUE_PEAK_SCALE } from '../meters/true_peak_scale.js';
const PLOT = { left: 43, top: 70, right: 0, bottom: 18 };
const METER_WIDTH = 140;
@@ -47,24 +48,10 @@ const DIN_SCALE = [
{ db: +5, pos: 1.0000 },
];
const TP_PERCENT_SCALE = [
{ db: -60, frac: 0.0000 },
{ db: -50, frac: 0.0373 },
{ db: -40, frac: 0.1429 },
{ db: -35, frac: 0.2112 },
{ db: -30, frac: 0.2857 },
{ db: -25, frac: 0.3851 },
{ db: -20, frac: 0.4783 },
{ db: -15, frac: 0.6087 },
{ db: -10, frac: 0.7391 },
{ db: -5, frac: 0.8696 },
{ db: 0, frac: 1.0000 },
];
const VU_TICKS = [-20, -10, -7, -5, -3, 0, 1, 2, 3];
const PPM_DIN_TICKS = [-50, -40, -30, -20, -10, -5, 0, 5];
const PPM_EBU_TICKS = [-12, -8, -4, 0, 4, 8, 12];
const TP_TICKS = [-60, -50, -40, -30, -20, -10, 0];
const TP_TICKS = [-60, -50, -40, -30, -20, -10, 0, 3, 6];
const RMS_DBFS_TICKS = [0, -6, -12, -18, -24, -30, -40, -60];
const RMS_DBU_TICKS = [24, 20, 10, 0, -10, -20, -30, -40, -50, -60];
const LUFS_TICKS = [-50, -40, -30, -23, -18, -10, -5];
@@ -91,7 +78,6 @@ export function init() {
meterLabel: '',
neutralNorm: 0,
neutralValue: 0,
rmsSmooth: null,
staticLayer: null,
};
}
@@ -605,7 +591,6 @@ async function drawMeter(env, plotX, plotY, plotW, plotH, meterRects = null) {
function resolveMeterInfo(env, state, meterId) {
const cfg = env.config || {};
const shared = env.meters?.getState?.(meterId) || null;
if (meterId !== 'rms') state.rmsSmooth = null;
switch (meterId) {
case 'ppm-din':
@@ -615,7 +600,7 @@ function resolveMeterInfo(env, state, meterId) {
case 'tp':
return sampleTp(cfg, shared);
case 'rms':
return sampleRms(cfg, shared, state);
return sampleRms(cfg, shared);
case 'lufs':
return sampleLufs(cfg, shared);
case 'vu':
@@ -715,8 +700,8 @@ function samplePpmEbu(cfg, shared) {
}
function sampleTp(cfg, shared) {
const bottom = TP_PERCENT_SCALE[0].db;
const top = TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].db;
const bottom = TRUE_PEAK_SCALE[0].db;
const top = TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].db;
const effOff = Number(cfg?.TP_OFFSET_DB) || 0;
const offCorr = effOff - (shared?.offset || 0);
const warnVal = Number.isFinite(cfg?.TP_RED_START) ? cfg.TP_RED_START : -1;
@@ -740,21 +725,21 @@ function sampleTp(cfg, shared) {
});
}
function sampleRms(cfg, shared, state) {
function sampleRms(cfg, shared) {
const mode = String(cfg?.RMS_MODE || 'dbfs').toLowerCase();
const isDBU = mode === 'dbu';
const bottom = -60;
const top = isDBU ? 24 : 0;
const refDbfs = Number.isFinite(cfg?.RMS_REF_DBFS_FOR_REF_DBU) ? cfg.RMS_REF_DBFS_FOR_REF_DBU : -18;
const refDbu = Number.isFinite(cfg?.RMS_REF_DBU) ? cfg.RMS_REF_DBU : 0;
const offset = Number.isFinite(cfg?.RMS_OFFSET_DB) ? cfg.RMS_OFFSET_DB : 0;
const warnVal = Number.isFinite(cfg?.RMS_RED_START)
? cfg.RMS_RED_START
: (isDBU ? 20 : 0);
const vL = Number.isFinite(shared?.values?.L) ? shared.values.L : bottom;
const vR = Number.isFinite(shared?.values?.R) ? shared.values.R : bottom;
const base = Math.max(vL, vR);
const smoothed = applyRmsSmoothing(state, base, cfg);
const display = isDBU ? (smoothed - refDbfs + refDbu) : smoothed;
const base = Math.max(vL, vR) + offset;
const display = isDBU ? (base - refDbfs + refDbu) : base;
const val = clamp(display, bottom, top);
const toNorm = (db) => {
const c = clamp(db, bottom, top);
@@ -778,43 +763,12 @@ function sampleRms(cfg, shared, state) {
toNorm,
range: { bottom, top },
ticks: isDBU ? RMS_DBU_TICKS : RMS_DBFS_TICKS,
signature: ['rms', mode, refDbfs, refDbu, cfg?.RMS_TC_MODE, cfg?.RMS_TC_MS].join('|'),
signature: ['rms', mode, refDbfs, refDbu, offset, cfg?.RMS_TC_MODE].join('|'),
hasValue: !!shared,
warnValue: warnVal,
});
}
function applyRmsSmoothing(state, value, cfg) {
const tauMs = resolveRmsTau(cfg);
if (!Number.isFinite(tauMs) || tauMs <= 0) {
state.rmsSmooth = null;
return value;
}
const now = performance.now();
if (!state.rmsSmooth || !Number.isFinite(state.rmsSmooth.value)) {
state.rmsSmooth = { value, lastTs: now };
return value;
}
const dt = Math.max(1 / 240, (now - (state.rmsSmooth.lastTs || now)) / 1000);
state.rmsSmooth.lastTs = now;
const alpha = 1 - Math.exp(-dt / (tauMs / 1000));
state.rmsSmooth.value += alpha * (value - state.rmsSmooth.value);
return state.rmsSmooth.value;
}
function resolveRmsTau(cfg = {}) {
const mode = String(cfg.RMS_TC_MODE || 'fast').toLowerCase();
const custom = Number(cfg.RMS_TC_MS);
if (Number.isFinite(custom) && custom > 0) return custom;
switch (mode) {
case 'impulse': return 35;
case 'slow': return 1000;
case 'none': return 0;
case 'fast':
default: return 125;
}
}
function sampleLufs(cfg, shared) {
const bottom = -50;
const top = -5;
@@ -888,7 +842,7 @@ function interpolateScale(table, db) {
}
function interpolateTp(db) {
return interpolateScale(TP_PERCENT_SCALE, db);
return interpolateScale(TRUE_PEAK_SCALE, db);
}
function deflectionFrac(db) {
+121 -273
View File
@@ -13,33 +13,24 @@ const METER_SLOTS = 3;
const METER_PAD_TOP = 30;
const METER_PAD_BOTTOM = 20;
const METER_EXTRA_BOTTOM_PAD = 6;
const TARGET_POINTS = 1024;
const COLOR_STOPS = [
{ t: 0.0, color: [0, 0, 50] },
{ t: 0.3, color: [0, 120, 180] },
{ t: 0.6, color: [0, 205, 120] },
{ t: 0.8, color: [210, 220, 0] },
{ t: 1.0, color: [255, 120, 0] },
];
const RING_DBFS = [0, -8, -16, -24, -32, -40];
const RING_PPM_DIN = [+5, 0, -10, -20, -30, -40, -50];
const PHASE_GAIN_MIN_DB = -35;
const PHASE_GAIN_MAX_DB = 35;
const PHASE_ALIGN_TARGET = Math.pow(10, -15 / 20);
const PHASE_AGC_TARGET_DB = linearToDb(PHASE_ALIGN_TARGET);
const PHASE_AGC_BASE_GAIN = 1 / PHASE_ALIGN_TARGET;
const PHASE_AGC_ATTACK_S = 0.02;
const PHASE_AGC_RELEASE_DB_PER_S = 12;
const PHASE_LEVEL_THRESHOLD_DB = -60;
const PHASE_LEVEL_THRESHOLD = dbToLinear(PHASE_LEVEL_THRESHOLD_DB);
const PHASE_IDLE_DECAY = 0.85;
const PHASE_PHASE_SMOOTH_ALPHA = 0.08;
const PHASE_RADIUS_SMOOTH_ALPHA = 0.18;
const PHASE_BANDPASS_LOW_HZ = 300;
const PHASE_BANDPASS_HIGH_HZ = 5000;
const PHASE_IDLE_TAU_S = 0.103;
const PHASE_SMOOTH_TAU_S = 0.2;
const PHASE_RADIUS_TAU_S = 0.085;
const PHASE_TRAIL_FADE_MS = 2000;
const PHASE_TRAIL_MIN_STEP_MS = 1000 / 45;
const PHASE_TRAIL_MIN_DIST_PX = 1.5;
const PHASE_TRAIL_MAX_ANGLE_STEP_RAD = (2 * Math.PI) / 180;
const PHASE_TRAIL_MAX_POINTS = 1024;
const PHASE_SECTORS = [
{ startDeg: -30, endDeg: 30, color: 'rgba(72,210,150,0.3)' },
{ startDeg: 30, endDeg: 60, color: 'rgba(255,214,120,0.25)' },
@@ -48,25 +39,11 @@ const PHASE_SECTORS = [
{ startDeg: -180, endDeg: -60, color: 'rgba(255,120,120,0.25)' },
];
// Numerisch stabilere Hilbert-Kernel-Erstellung
const HILBERT_KERNEL = buildHilbertKernel(33);
const HILBERT_HALF = (HILBERT_KERNEL.length - 1) / 2;
// Lookup-Tables für häufig verwendete Werte
const ANGLE_COS = new Float32Array(360);
const ANGLE_SIN = new Float32Array(360);
for (let i = 0; i < 360; i++) {
const rad = (i * Math.PI) / 180;
ANGLE_COS[i] = Math.cos(rad);
ANGLE_SIN[i] = Math.sin(rad);
}
export function init() {
return {
traceBuffer: new Float32Array(0),
ampBuffer: new Float32Array(0),
filteredL: new Float32Array(0),
filteredR: new Float32Array(0),
phaseDisplaySeq: -1,
phaseDisplayLastTs: 0,
phaseConfidence: 0,
currentPhase: null,
currentRadius: 0,
smoothPhase: null,
@@ -74,9 +51,7 @@ export function init() {
phaseAgcEnv: 1e-3,
phaseAgcGainDb: 0,
phaseAgcLastTs: 0,
bandpass: createBandpassState(),
bufferGrowthCount: 0,
maxBufferSize: 0,
phaseAgcSeq: -1,
phaseTrail: [],
staticLayerCanvas: null,
staticLayerKey: '',
@@ -99,13 +74,13 @@ export async function render(env, state) {
const now = getNow();
drawStaticLayer(g, state, rect, layout, CONFIG, slots.length);
const xyData = extractXYData(audio);
const gainCtrl = resolvePhaseGain(state, xyData, CONFIG);
if (xyData.ready) {
const trace = buildWheelTrace(state, xyData, layout.wheel, gainCtrl.gain, CONFIG, audio);
renderWheel(g, trace, layout.wheel, state, CONFIG, now);
const phaseData = extractPhaseData(audio);
if (phaseData.ready) {
const gainCtrl = resolvePhaseGain(state, phaseData, CONFIG);
const updated = updatePhasePointer(state, phaseData, gainCtrl.gain, CONFIG, audio, now);
renderWheel(g, updated, layout.wheel, state, CONFIG, now);
} else {
decayPhasePointer(state);
updatePhasePointer(state, phaseData, 1, CONFIG, audio, now);
trimPhaseTrail(state, CONFIG, now);
drawPhaseTrail(g, layout.wheel, state, CONFIG, now);
drawPhasePointer(g, layout.wheel, state);
@@ -319,85 +294,54 @@ function radToDeg(rad) {
return (rad * 180) / Math.PI;
}
function extractXYData(audio) {
const xyL = audio?.xyL;
const xyR = audio?.xyR;
const isVec = (v) => v && (Array.isArray(v) || ArrayBuffer.isView(v));
const ready = !!(audio?.alive && isVec(xyL) && isVec(xyR) && xyL.length && xyR.length);
function extractPhaseData(audio) {
const angle = Number(audio?.phaseAngleRad);
const level = Number(audio?.phaseLevel);
const peak = Number(audio?.phasePeak);
return {
ready,
xyL,
xyR,
length: ready ? Math.min(xyL.length, xyR.length) : 0,
sampleRate: audio?.sampleRate || 48000,
ready: !!audio?.alive,
angle: Number.isFinite(angle) ? angle : null,
coherence: clamp01(Number(audio?.phaseCoherence) || 0),
level: Number.isFinite(level) ? Math.max(0, level) : 0,
peak: Number.isFinite(peak) ? Math.max(0, peak) : 0,
seq: Number(audio?.phaseSeq) || 0,
};
}
function buildWheelTrace(state, xyData, wheel, gain = 1, CONFIG, audio) {
if (!xyData.ready || !xyData.length) {
decayPhasePointer(state);
return null;
}
const filtered = preparePhaseFilteredBuffers(state, xyData);
function updatePhasePointer(state, phaseData, gain = 1, CONFIG, audio, nowMs) {
const seq = Number(phaseData?.seq) || 0;
if (seq > 0 && state.phaseDisplaySeq === seq) return false;
const now = Number.isFinite(nowMs) ? nowMs : getNow();
const previousTs = Number.isFinite(state.phaseDisplayLastTs) && state.phaseDisplayLastTs > 0
? state.phaseDisplayLastTs
: now - (1000 / 60);
const dt = Math.max(1 / 240, Math.min(0.25, (now - previousTs) / 1000));
state.phaseDisplayLastTs = now;
state.phaseDisplaySeq = seq;
const amplitudeMode = getPhaseAmplitudeMode(CONFIG);
const ringDbValues = getRingDbValues(CONFIG);
const ppmRadiusNorm = amplitudeMode === 'ppm-din'
? computePpmDinRadiusNorm(audio, CONFIG, ringDbValues)
: 0;
const targetPoints = Math.min(TARGET_POINTS, xyData.length);
const step = Math.max(1, Math.floor(xyData.length / targetPoints));
const radius = wheel.radius;
let ampIdx = 0;
let sumSin = 0;
let sumCos = 0;
let sumRadius = 0;
let levelAcc = 0;
const gainLinear = Number.isFinite(gain) ? gain : 1;
for (let i = 0; i < xyData.length; i += step) {
const lRe = clamp1(filtered.L[i]);
const rRe = clamp1(filtered.R[i]);
const lIm = hilbertAt(filtered.L, i);
const rIm = hilbertAt(filtered.R, i);
const phaseL = Math.atan2(lIm, lRe);
const phaseR = Math.atan2(rIm, rRe);
let phaseDiff = phaseL - phaseR;
if (!Number.isFinite(phaseDiff)) continue;
phaseDiff = wrapAngle(phaseDiff);
const angle = phaseDiff - Math.PI / 2;
const magL = Math.min(1, Math.hypot(lRe, lIm));
const magR = Math.min(1, Math.hypot(rRe, rIm));
const amp = Math.min(1, 0.5 * (magL + magR));
const ampScaled = Math.min(1, amp * gainLinear);
const radiusNorm = amplitudeMode === 'ppm-din'
? ppmRadiusNorm
: linearToRadiusNorm(ampScaled, ringDbValues);
ampIdx++;
sumSin += Math.sin(angle);
sumCos += Math.cos(angle);
sumRadius += radiusNorm;
levelAcc += amp * amp;
}
if (ampIdx > 0) {
const invCount = 1 / ampIdx;
const avgAngle = Math.atan2(sumSin * invCount, sumCos * invCount);
const avgRadius = amplitudeMode === 'ppm-din' ? ppmRadiusNorm : (sumRadius * invCount);
const blockLevel = Math.sqrt(levelAcc * invCount);
if (blockLevel >= PHASE_LEVEL_THRESHOLD) {
const prevPhase = Number.isFinite(state.smoothPhase) ? state.smoothPhase : avgAngle;
const prevRadius = Number.isFinite(state.smoothRadius) ? state.smoothRadius : avgRadius;
state.currentPhase = avgAngle;
state.currentRadius = avgRadius;
state.smoothPhase = smoothAngle(prevPhase, avgAngle, PHASE_PHASE_SMOOTH_ALPHA);
state.smoothRadius = lerp(prevRadius, avgRadius, PHASE_RADIUS_SMOOTH_ALPHA);
} else {
decayPhasePointer(state);
}
const targetRadius = amplitudeMode === 'ppm-din'
? ppmRadiusNorm
: linearToRadiusNorm(Math.min(1, phaseData.level * gainLinear), ringDbValues);
if (Number.isFinite(phaseData.angle) && phaseData.level >= PHASE_LEVEL_THRESHOLD) {
const targetPhase = wrapAngle(phaseData.angle - Math.PI / 2);
const prevPhase = Number.isFinite(state.smoothPhase) ? state.smoothPhase : targetPhase;
const prevRadius = Number.isFinite(state.smoothRadius) ? state.smoothRadius : targetRadius;
state.currentPhase = targetPhase;
state.currentRadius = targetRadius;
state.phaseConfidence = phaseData.coherence;
state.smoothPhase = smoothAngle(prevPhase, targetPhase, smoothingAlpha(dt, PHASE_SMOOTH_TAU_S));
state.smoothRadius = lerp(prevRadius, targetRadius, smoothingAlpha(dt, PHASE_RADIUS_TAU_S));
} else {
decayPhasePointer(state);
state.phaseConfidence = 0;
decayPhasePointer(state, dt);
}
return { count: ampIdx, radius };
return true;
}
function renderWheel(g, trace, wheel, state, CONFIG, timestamp) {
@@ -575,31 +519,64 @@ function updatePhaseTrail(state, wheel, CONFIG, now) {
}
const radiusNorm = Math.max(0, Number(state?.smoothRadius) || 0);
if (radiusNorm > 0.002 && Number.isFinite(state?.smoothPhase)) {
const length = radiusNorm * wheel.radius;
const x = wheel.cx + Math.cos(state.smoothPhase) * length;
const y = wheel.cy + Math.sin(state.smoothPhase) * length;
const color = trailColorForAngle(state.smoothPhase);
const phase = wrapAngle(state.smoothPhase);
const point = createPhaseTrailPoint(wheel, phase, radiusNorm, now);
const last = state.phaseTrail.length ? state.phaseTrail[state.phaseTrail.length - 1] : null;
if (!last) {
state.phaseTrail.push({ x, y, t: now, color });
state.phaseTrail.push(point);
} else {
const dt = now - last.t;
const dx = x - last.x;
const dy = y - last.y;
const dx = point.x - last.x;
const dy = point.y - last.y;
const dist2 = dx * dx + dy * dy;
if (dt >= PHASE_TRAIL_MIN_STEP_MS || dist2 >= PHASE_TRAIL_MIN_DIST_PX * PHASE_TRAIL_MIN_DIST_PX || last.color !== color) {
state.phaseTrail.push({ x, y, t: now, color });
if (dt >= PHASE_TRAIL_MIN_STEP_MS || dist2 >= PHASE_TRAIL_MIN_DIST_PX * PHASE_TRAIL_MIN_DIST_PX || last.color !== point.color) {
appendPhaseTrailSegment(state.phaseTrail, wheel, last, phase, radiusNorm, now);
} else {
last.x = x;
last.y = y;
last.x = point.x;
last.y = point.y;
last.t = now;
last.color = color;
last.color = point.color;
last.phase = phase;
last.radiusNorm = radiusNorm;
}
}
}
trimPhaseTrail(state, CONFIG, now);
}
function createPhaseTrailPoint(wheel, phase, radiusNorm, timestamp) {
const length = radiusNorm * wheel.radius;
return {
x: wheel.cx + Math.cos(phase) * length,
y: wheel.cy + Math.sin(phase) * length,
t: timestamp,
color: trailColorForAngle(phase),
phase,
radiusNorm,
};
}
function appendPhaseTrailSegment(trail, wheel, last, phase, radiusNorm, timestamp) {
const startPhase = Number.isFinite(last?.phase) ? last.phase : phase;
const startRadius = Number.isFinite(last?.radiusNorm) ? last.radiusNorm : radiusNorm;
const phaseDelta = wrapAngle(phase - startPhase);
const steps = Math.max(1, Math.ceil(Math.abs(phaseDelta) / PHASE_TRAIL_MAX_ANGLE_STEP_RAD));
const startTime = Number.isFinite(last?.t) ? last.t : timestamp;
for (let step = 1; step <= steps; step++) {
const fraction = step / steps;
const interpolatedPhase = wrapAngle(startPhase + phaseDelta * fraction);
const interpolatedRadius = lerp(startRadius, radiusNorm, fraction);
const interpolatedTime = lerp(startTime, timestamp, fraction);
trail.push(createPhaseTrailPoint(
wheel,
interpolatedPhase,
interpolatedRadius,
interpolatedTime,
));
}
}
function trimPhaseTrail(state, CONFIG, now) {
if (!state.phaseTrail) state.phaseTrail = [];
if (!CONFIG?.PHASE_TRAIL_ENABLED) {
@@ -615,6 +592,9 @@ function trimPhaseTrail(state, CONFIG, now) {
}
}
state.phaseTrail.length = write;
if (state.phaseTrail.length > PHASE_TRAIL_MAX_POINTS) {
state.phaseTrail.splice(0, state.phaseTrail.length - PHASE_TRAIL_MAX_POINTS);
}
}
function drawPhaseTrail(g, wheel, state, CONFIG, now) {
@@ -650,11 +630,6 @@ function trailColorForAngle(angle) {
return '#ff7a78';
}
function clamp1(value) {
if (!Number.isFinite(value)) return 0;
return Math.max(-1, Math.min(1, value));
}
function ringFraction(index, ringCount) {
const n = Math.max(1, ringCount | 0);
const idx = Math.max(0, Math.min(n - 1, index | 0));
@@ -722,113 +697,32 @@ function computePpmDinRadiusNorm(audio, cfg, ringDbValues) {
return dbToRadiusNorm(db, ringDbValues);
}
function createBandpassState() {
return {
sampleRate: 0,
hpAlpha: 0,
lpAlpha: 0,
channels: {
L: { hpX: 0, hpY: 0, lpY: 0 },
R: { hpX: 0, hpY: 0, lpY: 0 },
},
};
}
function ensureBandpassCoeffs(state, sampleRate) {
if (!state.bandpass) state.bandpass = createBandpassState();
const sr = Math.max(8000, Math.round(sampleRate) || 48000);
if (state.bandpass.sampleRate === sr) return;
state.bandpass.sampleRate = sr;
state.bandpass.hpAlpha = computeHighpassAlpha(sr, PHASE_BANDPASS_LOW_HZ);
state.bandpass.lpAlpha = computeLowpassAlpha(sr, PHASE_BANDPASS_HIGH_HZ);
}
function computeHighpassAlpha(sampleRate, cutoff) {
const rc = 1 / (2 * Math.PI * Math.max(1, cutoff));
const dt = 1 / Math.max(1, sampleRate);
return Math.max(0, Math.min(1, rc / (rc + dt)));
}
function computeLowpassAlpha(sampleRate, cutoff) {
const rc = 1 / (2 * Math.PI * Math.max(1, cutoff));
const dt = 1 / Math.max(1, sampleRate);
return Math.max(0, Math.min(1, dt / (rc + dt)));
}
function ensureFilteredBuffers(state, length) {
const neededLength = Math.ceil(length * 1.1); // 10% Puffer für Stabilität
if (!state.filteredL || state.filteredL.length < neededLength) {
state.filteredL = new Float32Array(neededLength);
}
if (!state.filteredR || state.filteredR.length < neededLength) {
state.filteredR = new Float32Array(neededLength);
}
return { L: state.filteredL, R: state.filteredR };
}
function applyBandpassSample(sample, channelState, bandpassState) {
const hpAlpha = bandpassState.hpAlpha;
const lpAlpha = bandpassState.lpAlpha;
if (!Number.isFinite(sample)) sample = 0;
const hpY = hpAlpha * (channelState.hpY + sample - channelState.hpX);
channelState.hpY = Number.isFinite(hpY) ? hpY : 0;
channelState.hpX = sample;
const lpY = lpAlpha * hpY + (1 - lpAlpha) * channelState.lpY;
channelState.lpY = Number.isFinite(lpY) ? lpY : 0;
return lpY;
}
function preparePhaseFilteredBuffers(state, xyData) {
ensureBandpassCoeffs(state, xyData.sampleRate || 48000);
const filtered = ensureFilteredBuffers(state, xyData.length);
// Reset channel states if they contain NaN/Infinity
if (!Number.isFinite(state.bandpass.channels.L.hpY)) {
state.bandpass.channels.L = { hpX: 0, hpY: 0, lpY: 0 };
}
if (!Number.isFinite(state.bandpass.channels.R.hpY)) {
state.bandpass.channels.R = { hpX: 0, hpY: 0, lpY: 0 };
}
for (let i = 0; i < xyData.length; i++) {
filtered.L[i] = applyBandpassSample(
xyData.xyL[i],
state.bandpass.channels.L,
state.bandpass
);
filtered.R[i] = applyBandpassSample(
xyData.xyR[i],
state.bandpass.channels.R,
state.bandpass
);
}
return filtered;
}
function decayPhasePointer(state) {
function decayPhasePointer(state, dt = 1 / 60) {
const prevPhase = Number.isFinite(state.currentPhase) ? state.currentPhase : 0;
const prevRadius = Number.isFinite(state.currentRadius) ? state.currentRadius : 0;
const decayedRadius = prevRadius * PHASE_IDLE_DECAY;
const decayedRadius = prevRadius * Math.exp(-Math.max(0, dt) / PHASE_IDLE_TAU_S);
state.currentPhase = prevPhase;
state.currentRadius = decayedRadius;
const prevSmoothPhase = Number.isFinite(state.smoothPhase) ? state.smoothPhase : prevPhase;
const prevSmoothRadius = Number.isFinite(state.smoothRadius) ? state.smoothRadius : decayedRadius;
state.smoothPhase = smoothAngle(prevSmoothPhase, prevPhase, PHASE_PHASE_SMOOTH_ALPHA * 0.5);
state.smoothRadius = lerp(prevSmoothRadius, decayedRadius, PHASE_RADIUS_SMOOTH_ALPHA);
state.smoothPhase = prevSmoothPhase;
state.smoothRadius = lerp(prevSmoothRadius, decayedRadius, smoothingAlpha(dt, PHASE_RADIUS_TAU_S));
}
function resolvePhaseGain(state, xyData, CONFIG) {
function resolvePhaseGain(state, phaseData, CONFIG) {
const gainDb = clampPhaseGain(CONFIG?.PHASE_DISPLAY_GAIN_DB ?? 0);
const allowAgc = CONFIG?.PHASE_AGC_ENABLED && getPhaseAmplitudeMode(CONFIG) !== 'ppm-din';
if (allowAgc && xyData?.ready) {
const auto = computePhaseAgcGain(state, xyData);
return { gainDb: auto.gainDb, gain: auto.gain * PHASE_AGC_BASE_GAIN };
if (allowAgc && phaseData?.ready) {
const seq = Number(phaseData.seq) || 0;
if (seq > 0 && state.phaseAgcSeq === seq) {
return { gainDb: state.phaseAgcGainDb, gain: dbToLinear(state.phaseAgcGainDb) };
}
const auto = computePhaseAgcGain(state, phaseData);
state.phaseAgcSeq = seq;
return { gainDb: auto.gainDb, gain: auto.gain };
}
state.phaseAgcGainDb = gainDb;
state.phaseAgcSeq = Number(phaseData?.seq) || 0;
return { gainDb, gain: dbToLinear(gainDb) };
}
@@ -857,51 +751,22 @@ function lerp(a, b, t) {
return a + (b - a) * clampedT;
}
function hilbertAt(buffer, idx) {
if (!buffer || idx < 0 || idx >= buffer.length) return 0;
let acc = 0;
for (let k = 0; k < HILBERT_KERNEL.length; k++) {
const src = idx + k - HILBERT_HALF;
if (src < 0 || src >= buffer.length) continue;
const sample = buffer[src];
const kernel = HILBERT_KERNEL[k];
if (Number.isFinite(sample) && Number.isFinite(kernel)) {
acc += sample * kernel;
}
}
return Number.isFinite(acc) ? acc : 0;
export function smoothingAlpha(dt, tau) {
const seconds = Number.isFinite(dt) ? Math.max(0, dt) : 0;
const timeConstant = Number.isFinite(tau) ? Math.max(1e-6, tau) : 1e-6;
return 1 - Math.exp(-seconds / timeConstant);
}
function buildHilbertKernel(size = 33) {
const taps = size % 2 === 0 ? size + 1 : size;
const mid = (taps - 1) / 2;
const kernel = new Float32Array(taps);
for (let n = 0; n < taps; n++) {
const k = n - mid;
// Verbesserte numerische Stabilität + even k = 0 wie idealer Hilbert-Kernel
if (Math.abs(k) < 1e-10 || k % 2 === 0) {
kernel[n] = 0;
continue;
}
const window = 0.54 - 0.46 * Math.cos((2 * Math.PI * n) / Math.max(1, taps - 1));
const value = (2 / (Math.PI * k)) * window;
// Sicherstellen, dass der Wert finite ist
kernel[n] = Number.isFinite(value) ? value : 0;
}
return kernel;
function clamp01(value) {
return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;
}
function computePhaseAgcGain(state, xyData) {
function computePhaseAgcGain(state, phaseData) {
const now = getNow();
const dt = state.phaseAgcLastTs ? Math.max(0, (now - state.phaseAgcLastTs) / 1000) : 0;
state.phaseAgcLastTs = now;
const peak = measurePhasePeak(xyData);
const peak = Math.max(1e-8, Number(phaseData?.peak) || 0);
let env = Number.isFinite(state.phaseAgcEnv) && state.phaseAgcEnv > 0 ? state.phaseAgcEnv : 1e-3;
if (peak >= env) {
@@ -925,23 +790,6 @@ function computePhaseAgcGain(state, xyData) {
return { gainDb, gain: dbToLinear(gainDb) };
}
function measurePhasePeak(xyData) {
if (!xyData || !xyData.ready) return 1e-4; // Verbesserter Default-Wert
let peak = 1e-8; // Höhere Präzision
const len = Math.min(xyData.length, 1000); // Begrenzung für Performance
for (let i = 0; i < len; i++) {
const sample = Math.max(
Math.abs(xyData.xyL[i] || 0),
Math.abs(xyData.xyR[i] || 0)
);
if (sample > peak) peak = sample;
}
return Math.max(1e-8, peak); // Sicherstellen, dass nicht 0 zurückgegeben wird
}
function clampPhaseGain(db) {
let val = Number(db);
if (!Number.isFinite(val)) val = 0;
+13 -284
View File
@@ -1,246 +1,29 @@
// views/quad_view.js — Quad-View: vier Plots (2x2) + optionales Meter-Panel (wie Split-View)
import * as viewGoni from './goniometer_rtw.js';
import * as viewPhaseWheel from './phase_wheel.js';
import * as viewPanel from './panel.js';
import * as viewRealtime from './realtime.js';
import * as viewClassicNeedles from './classic_needles.js';
import * as viewPeakHistory from './peak_history.js';
import * as viewClock from './clock.js';
import * as viewWaveform from './waveform.js';
import * as viewSpectrogram from './spectrogram.js';
import { DEFAULT_TOP_INSET, FRAME_COLOR, PANEL_BG } from '../core/theme.js';
import { DEFAULT_TOP_INSET } from '../core/theme.js';
import {
CHILD_VIEWS,
computeMultiViewBaseLayout,
drawEmptyPlot,
drawMetersPanel,
readMultiViewMeters,
sanitizeChildId,
sanitizePlotId,
withClippedSubRect,
} from './multi_view_shared.js';
export const id = 'quad-view';
const CONTENT_TOP = DEFAULT_TOP_INSET;
const CONTENT_BOTTOM = 0;
const CHILD_VIEWS = {
'realtime': viewRealtime,
'classic-needles': viewClassicNeedles,
'peak-history': viewPeakHistory,
'goniometer-rtw': viewGoni,
'phase-wheel': viewPhaseWheel,
'panel': viewPanel,
'clock': viewClock,
'waveform': viewWaveform,
'spectrogram': viewSpectrogram,
};
const ALLOWED_CHILD_VIEW_IDS = new Set(['none', ...Object.keys(CHILD_VIEWS)]);
const ALLOWED_PLOT_IDS = new Set(['none', 'phase-wheel', 'realtime', 'goniometer-rtw', 'peak-history', 'classic-needles', 'panel', 'clock', 'waveform', 'spectrogram']);
const ALLOWED_METER_IDS = new Set(['none', 'vu', 'ppm-ebu', 'ppm-din', 'tp', 'hifi-peak', 'rms', 'lufs', 'stopwatch']);
const ALLOWED_METER_POSITIONS = new Set(['left', 'center', 'right']);
const OUTER_GAP = 10;
const SIDE_INNER_GAP = 8;
const ROW_GAP = 10;
const METER_GAP = 12;
const METER_W_DEFAULT = 140;
const METER_W_MIN = 90;
const MIN_PLOT_W = 240;
const METER_PAD_TOP = 15;
const METER_PAD_BOTTOM = 5;
const METER_SLOT_SHRINK = 24;
const METER_EXTRA_BOTTOM_PAD = 6;
function sanitizeChildId(val, fallback) {
return ALLOWED_CHILD_VIEW_IDS.has(val) ? val : fallback;
}
function sanitizePlotId(val, fallback) {
return ALLOWED_PLOT_IDS.has(val) ? val : fallback;
}
function sanitizeMeterId(val, fallback) {
const id = String(val || '');
return ALLOWED_METER_IDS.has(id) ? id : fallback;
}
function sanitizeMeterPos(val, fallback) {
const id = String(val || '');
return ALLOWED_METER_POSITIONS.has(id) ? id : fallback;
}
function clampMeterCount(val) {
const n = Number(val);
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(3, n | 0));
}
async function withClippedSubRect(g, rect, fn) {
g.save();
g.translate(rect.x, rect.y);
g.beginPath();
g.rect(0, 0, rect.w, rect.h);
g.clip();
try { return await fn(); } finally { g.restore(); }
}
function drawSubframe(g, rect) {
g.save();
g.strokeStyle = FRAME_COLOR;
g.lineWidth = 2;
g.strokeRect(rect.x + 0.5, rect.y + 0.5, Math.max(0, rect.w - 1), Math.max(0, rect.h - 1));
g.restore();
}
function createStaticLayerCanvas(width, height) {
const w = Math.max(1, width | 0);
const h = Math.max(1, height | 0);
if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(w, h);
const c = document.createElement('canvas');
c.width = w;
c.height = h;
return c;
}
function drawCachedStaticLayer(state, g, layerId, key, rect, build) {
if (!state || !rect || rect.w <= 0 || rect.h <= 0) return;
if (!state.staticLayers) state.staticLayers = new Map();
const fullKey = `${layerId}:${key}:${Math.max(0, rect.w | 0)}x${Math.max(0, rect.h | 0)}`;
let layer = state.staticLayers.get(fullKey);
if (!layer) {
const canvas = createStaticLayerCanvas(rect.w, rect.h);
const ctx = canvas.getContext('2d');
build(ctx, rect);
layer = { canvas };
state.staticLayers.set(fullKey, layer);
}
g.drawImage(layer.canvas, rect.x, rect.y);
}
function drawEmptyPlot(state, g, rect, label) {
drawCachedStaticLayer(state, g, 'empty-plot', String(label || '(leer)'), rect, (lg) => {
lg.fillStyle = PANEL_BG;
lg.fillRect(0, 0, rect.w, rect.h);
lg.fillStyle = '#9aa';
lg.textAlign = 'left';
lg.font = 'bold 14px ui-monospace, monospace';
lg.fillText(label || '(leer)', 12, 32);
lg.textAlign = 'start';
});
}
function readQuadMeters(CONFIG) {
const count = clampMeterCount(CONFIG?.QUAD_VIEW_METER_COUNT);
const slots = [
{
id: sanitizeMeterId(CONFIG?.QUAD_VIEW_METER_1, 'vu'),
pos: sanitizeMeterPos(CONFIG?.QUAD_VIEW_METER_1_POS, 'right'),
},
{
id: sanitizeMeterId(CONFIG?.QUAD_VIEW_METER_2, 'ppm-din'),
pos: sanitizeMeterPos(CONFIG?.QUAD_VIEW_METER_2_POS, 'right'),
},
{
id: sanitizeMeterId(CONFIG?.QUAD_VIEW_METER_3, 'lufs'),
pos: sanitizeMeterPos(CONFIG?.QUAD_VIEW_METER_3_POS, 'right'),
},
].slice(0, count);
return slots;
}
function groupMetersByPosition(slots) {
const out = { left: [], center: [], right: [] };
for (const s of slots || []) {
if (!s) continue;
if (s.pos === 'left') out.left.push(s.id);
else if (s.pos === 'center') out.center.push(s.id);
else out.right.push(s.id);
}
return out;
return readMultiViewMeters(CONFIG, 'QUAD_VIEW');
}
function computeBaseLayout(rect, slots) {
const contentH = Math.max(0, rect.h - CONTENT_TOP - CONTENT_BOTTOM);
const hasContent = contentH >= 140;
const BLOCK_GAP = SIDE_INNER_GAP;
let effectiveSlots = hasContent ? (Array.isArray(slots) ? slots.slice(0, 3) : []) : [];
while (true) {
const grouped = groupMetersByPosition(effectiveSlots);
const leftCount = grouped.left.length;
const centerCount = grouped.center.length;
const rightCount = grouped.right.length;
const totalSlots = leftCount + centerCount + rightCount;
const hasLeftMeters = leftCount > 0;
const hasCenterMeters = centerCount > 0;
const hasRightMeters = rightCount > 0;
const interBlockGaps =
(hasLeftMeters ? BLOCK_GAP : 0) +
(hasRightMeters ? BLOCK_GAP : 0) +
(hasCenterMeters ? (2 * BLOCK_GAP) : OUTER_GAP);
const internalGaps =
Math.max(0, leftCount - 1) * METER_GAP +
Math.max(0, centerCount - 1) * METER_GAP +
Math.max(0, rightCount - 1) * METER_GAP;
const minRequired = 2 * MIN_PLOT_W + interBlockGaps + internalGaps;
const remainingForMeters = rect.w - minRequired;
let slotW = 0;
if (totalSlots > 0) {
slotW = Math.floor(remainingForMeters / totalSlots);
slotW = Math.min(METER_W_DEFAULT, slotW);
}
if (totalSlots > 0 && slotW < METER_W_MIN && effectiveSlots.length) {
effectiveSlots = effectiveSlots.slice(0, -1);
continue;
}
if (totalSlots > 0) slotW = Math.max(METER_W_MIN, Math.min(METER_W_DEFAULT, slotW));
const leftW = hasLeftMeters ? (leftCount * slotW + Math.max(0, leftCount - 1) * METER_GAP) : 0;
const centerW = hasCenterMeters ? (centerCount * slotW + Math.max(0, centerCount - 1) * METER_GAP) : 0;
const rightW = hasRightMeters ? (rightCount * slotW + Math.max(0, rightCount - 1) * METER_GAP) : 0;
const metersTotalW = leftW + centerW + rightW;
const plotAvail = Math.max(0, rect.w - interBlockGaps - metersTotalW);
const leftPlotW = Math.floor(plotAvail / 2);
const rightPlotW = plotAvail - leftPlotW;
const plotFits = leftPlotW >= MIN_PLOT_W && rightPlotW >= MIN_PLOT_W;
if (!plotFits && effectiveSlots.length) {
effectiveSlots = effectiveSlots.slice(0, -1);
continue;
}
let x = 0;
const leftMetersRect = hasLeftMeters ? { x, y: CONTENT_TOP, w: leftW, h: contentH } : null;
if (leftMetersRect) x += leftW + BLOCK_GAP;
const leftPlotRect = { x, y: 0, w: leftPlotW, h: rect.h };
x += leftPlotW;
let centerMetersRect = null;
if (hasCenterMeters) {
x += BLOCK_GAP;
centerMetersRect = { x, y: CONTENT_TOP, w: centerW, h: contentH };
x += centerW + BLOCK_GAP;
} else {
x += OUTER_GAP;
}
const rightPlotRect = { x, y: 0, w: rightPlotW, h: rect.h };
x += rightPlotW;
let rightMetersRect = null;
if (hasRightMeters) {
x += BLOCK_GAP;
rightMetersRect = { x, y: CONTENT_TOP, w: rightW, h: contentH };
}
return {
plots: { left: leftPlotRect, right: rightPlotRect },
meters: { left: leftMetersRect, center: centerMetersRect, right: rightMetersRect },
meterIds: grouped,
slotW,
effectiveSlots,
};
}
return computeMultiViewBaseLayout(rect, slots, CONTENT_TOP, CONTENT_BOTTOM);
}
function splitRectToRows(rect) {
@@ -274,60 +57,6 @@ function ensureChildRectSize(env, rect, child) {
resizeChild(env, { x: 0, y: 0, w: rect.w, h: rect.h }, child);
}
async function drawMetersPanel(env, state, rect, meterIds, slotW) {
const { ctx: g, meters, config: CONFIG } = env;
if (!rect || rect.w <= 0 || rect.h <= 0) return;
const ids = Array.isArray(meterIds) ? meterIds.slice(0, 3) : [];
const count = ids.length;
if (!count) return;
drawCachedStaticLayer(state, g, 'meter-panel-shell', 'bg-frame', rect, (lg) => {
lg.fillStyle = PANEL_BG;
lg.fillRect(0, 0, rect.w, rect.h);
drawSubframe(lg, { x: 0, y: 0, w: rect.w, h: rect.h });
});
const gap = METER_GAP;
const n = Math.max(1, Math.min(3, count));
const usedW = n * slotW + (n - 1) * gap;
const startX = rect.x + Math.max(0, Math.floor((rect.w - usedW) / 2));
const shrink = Math.max(0, METER_SLOT_SHRINK);
const innerHeight = Math.max(40, rect.h - METER_PAD_TOP - METER_PAD_BOTTOM - shrink - METER_EXTRA_BOTTOM_PAD);
const innerOffset = shrink / 2;
const slotY = rect.y + METER_PAD_TOP + innerOffset;
const slotH = innerHeight;
for (let i = 0; i < n; i++) {
const id = sanitizeMeterId(ids[i], 'none');
const r = { x: startX + i * (slotW + gap), y: slotY, w: slotW, h: slotH };
if (id === 'none') {
drawCachedStaticLayer(state, g, 'meter-slot-empty', `${slotW}x${slotH}`, r, (lg) => {
lg.strokeStyle = 'rgba(0,231,255,0.25)';
lg.setLineDash([6, 5]);
lg.strokeRect(0.5, 0.5, Math.max(0, r.w - 1), Math.max(0, r.h - 1));
lg.setLineDash([]);
lg.fillStyle = '#9aa';
lg.textAlign = 'center';
lg.font = '12px ui-monospace, monospace';
lg.fillText('(leer)', r.w / 2, 22);
lg.textAlign = 'start';
});
} else {
try {
g.save();
g.beginPath();
g.rect(rect.x + 1, rect.y + 1, Math.max(0, rect.w - 2), Math.max(0, rect.h - 2));
g.clip();
await meters.draw(g, r, id, CONFIG);
g.restore();
} catch (e) {
g.restore();
console.warn('Quad meter draw error:', e);
}
}
}
}
function destroyChild(child) {
if (!child || child.id === 'none') return;
const mod = CHILD_VIEWS[child.id];
+132 -61
View File
@@ -1,5 +1,6 @@
import { getRtwCenters, resolveRtwBpoValue } from '../core/rtw_centers.js';
import { DEFAULT_TOP_INSET, FRAME_COLOR, MID_COLOR, PANEL_BG, WARN_COLOR } from '../core/theme.js';
import { drawCachedStaticLayer } from './static_layer.js';
// views/realtime.js — IEC-konformer Real-Time Analyzer
@@ -21,6 +22,7 @@ const STATIC_STROKE_PAD = 2;
const PEAK_HOLD_MAP = new Map(Object.entries({
off: 0,
auto: null,
fall: null,
'1s': 1,
'2s': 2,
'4s': 4,
@@ -31,10 +33,9 @@ const PEAK_HOLD_MAP = new Map(Object.entries({
}));
const INTEGRATION_TAU = {
impulse: 0.035,
fast: 0.125,
medium: 0.5,
slow: 1.0,
peak: 0.01,
};
@@ -79,7 +80,7 @@ export async function render(env, state) {
const useNativeFftEngine = !useIirEngine && rtaData && rtaData.engine === 'fft';
const nativeRtaPacket = (useIirEngine || useNativeFftEngine) ? rtaData : null;
const displayRtaPacket = (nativeRtaPacket && CONFIG.RTA_BAR_LAYOUT === 'rtw')
? selectLocalRtwPacket(nativeRtaPacket, nativeRtaPacket.bpo || '1_3')
? selectLocalRtwPacket(nativeRtaPacket, CONFIG.RTA_BPO_MODE || nativeRtaPacket.bpo || '1_3')
: nativeRtaPacket;
const topInset = Number.isFinite(env?.topInset) ? Number(env.topInset) : DEFAULT_TOP_INSET;
@@ -115,6 +116,23 @@ export async function render(env, state) {
const gutterL = Number.isFinite(CONFIG?.AXIS_GUTTER_LEFT)
? Math.max(8, Number(CONFIG.AXIS_GUTTER_LEFT))
: 14;
const rtwBarGrid = CONFIG.RTA_BAR_LAYOUT === 'rtw'
&& (CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'bars';
const rtwGridCenters = rtwBarGrid
? (isVectorLike(displayRtaPacket?.centers)
? Array.from(displayRtaPacket.centers)
: getRtwCenters(CONFIG.RTA_BPO_MODE || '1_3'))
.filter((center) => Number.isFinite(center)
&& center >= freqBounds.min
&& center <= freqBounds.max)
: null;
const freqTickPositions = rtwGridCenters
? buildRtwTickPositions(rtwGridCenters, freqTicks)
: null;
const gridLeftExtension = rtwBarGrid ? gutterL : 0;
const tickPositionKey = freqTickPositions
? freqTicks.map((f) => `${f}:${freqTickPositions[String(f)] ?? 'log'}`).join(',')
: 'log';
const plotPadLeft = gutterL + STATIC_LABEL_PAD_LEFT;
const plotPadBottom = STATIC_LABEL_PAD_BOTTOM;
@@ -133,6 +151,8 @@ export async function render(env, state) {
refDb ?? 'none',
hide20HzTick ? 1 : 0,
plotPadLeft,
gridLeftExtension,
tickPositionKey,
plotPadBottom,
].join('|'),
{ x: plotX - plotPadLeft, y: plotY, w: plotW + plotPadLeft, h: plotH + plotPadBottom },
@@ -147,7 +167,9 @@ export async function render(env, state) {
freqBounds.max,
{
freqTicks,
freqTickPositions,
freqMin: freqBounds.min,
xLeftExtension: gridLeftExtension,
refDb,
refStyle: 'rgba(0,231,255,0.35)',
refDash: [3, 4],
@@ -181,7 +203,7 @@ export async function render(env, state) {
// Native IIR values already contain the selected power-domain
// integration. A second browser attack/hold stage would falsify it.
const display = displayBase;
applyPeakHold(state, integrated, CONFIG, range);
syncNativePeakHold(state, displayRtaPacket, CONFIG, range, state.mapping.length, 'iir');
state.displayLevels = display;
state.currentRange = range;
if (typeof window !== 'undefined') window.__RTA_STATE__ = state;
@@ -197,20 +219,25 @@ export async function render(env, state) {
range,
freqBounds,
ballisticsData?.overlay || null,
ballisticsData?.mode || CONFIG.RTA_BALLISTICS_MODE || 'average'
ballisticsData?.mode || CONFIG.RTA_BALLISTICS_MODE || 'average',
gridLeftExtension,
);
}
} else if (useNativeFftEngine) {
const nativeLevels = mapNativeFftLevels(displayRtaPacket, CONFIG, range, state.mapping.length);
const ballisticsData = mapNativeFftLevels(displayRtaPacket, CONFIG, range, state.mapping.length);
const nativeLevels = ballisticsData?.primary;
if (!nativeLevels || !nativeLevels.length) {
drawWaiting(g, plotX, plotY);
} else {
const integrated = applyIntegration(state, nativeLevels, CONFIG, range);
// Native FFT packets already contain the selected power-domain
// integration. Integrating them again in the browser adds lag and
// changes the measured value.
const integrated = nativeLevels;
const displayBase = applyDisplayHold(state, integrated, CONFIG, range);
const display = (CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'bars'
? applyRealtimeBarBallistics(state, displayBase, CONFIG, range)
: displayBase;
applyPeakHold(state, integrated, CONFIG, range);
syncNativePeakHold(state, displayRtaPacket, CONFIG, range, state.mapping.length, 'fft');
state.displayLevels = display;
state.currentRange = range;
if (typeof window !== 'undefined') window.__RTA_STATE__ = state;
@@ -225,8 +252,9 @@ export async function render(env, state) {
CONFIG,
range,
freqBounds,
null,
CONFIG.RTA_BALLISTICS_MODE || 'average'
ballisticsData?.overlay || null,
ballisticsData?.mode || CONFIG.RTA_BALLISTICS_MODE || 'average',
gridLeftExtension,
);
}
} else if (!analyser || !buf) {
@@ -255,7 +283,8 @@ export async function render(env, state) {
range,
freqBounds,
null,
CONFIG.RTA_BALLISTICS_MODE || 'average'
CONFIG.RTA_BALLISTICS_MODE || 'average',
gridLeftExtension,
);
}
}
@@ -283,10 +312,12 @@ function buildConfigSignature(CONFIG, nyq, binCount) {
CONFIG.RTA_BPO_MODE,
CONFIG.RTA_WEIGHTING,
CONFIG.RTA_BAR_LAYOUT,
CONFIG.RTA_DETECTOR,
CONFIG.RTA_INTEGRATION,
CONFIG.RTA_PEAK_HOLD_MODE,
CONFIG.RTA_PEAK_HOLD_SEC,
CONFIG.RTA_PEAK_DECAY_DB_PER_S,
CONFIG.RTA_PEAK_RESET_TOKEN,
CONFIG.RTA_DISPLAY_HOLD_SEC,
CONFIG.REALTIME_RENDER_STYLE,
CONFIG.REALTIME_BAR_HOLD_MS,
@@ -383,15 +414,14 @@ function computeBandLevels(state, utils, buf, CONFIG, range) {
}
function applyIntegration(state, levels, CONFIG, range) {
const mode = (CONFIG.RTA_INTEGRATION && INTEGRATION_TAU[CONFIG.RTA_INTEGRATION])
const mode = (CONFIG.RTA_INTEGRATION === 'impulse' || INTEGRATION_TAU[CONFIG.RTA_INTEGRATION])
? CONFIG.RTA_INTEGRATION
: 'fast';
const tau = INTEGRATION_TAU[mode] || 0.125;
const peakDetector = CONFIG.RTA_DETECTOR === 'peak';
const now = performance.now();
const last = state.lastIntegrationTs || now;
const dt = Math.max(1 / 240, (now - last) / 1000);
state.lastIntegrationTs = now;
const alpha = (tau > 0 && dt > 0) ? 1 - Math.exp(-dt / tau) : 1;
if (!state.ewma || state.ewma.length !== levels.length) {
state.ewma = new Float32Array(levels.length);
@@ -400,19 +430,23 @@ function applyIntegration(state, levels, CONFIG, range) {
const buffer = state.ewma;
for (let i = 0; i < levels.length; i++) {
const prev = buffer[i];
const tau = peakDetector
? 0.010
: (mode === 'impulse' ? (levels[i] >= prev ? 0.035 : 1.5) : (INTEGRATION_TAU[mode] || 0.125));
const alpha = (tau > 0 && dt > 0) ? 1 - Math.exp(-dt / tau) : 1;
buffer[i] = prev + (levels[i] - prev) * alpha;
}
return buffer;
}
function applyPeakHold(state, levels, CONFIG, range) {
const mode = CONFIG.RTA_PEAK_HOLD_MODE || 'auto';
const mode = CONFIG.RTA_PEAK_HOLD_MODE || 'fall';
const len = levels.length;
const mapped = PEAK_HOLD_MAP.get(mode);
const holdSec = Number.isFinite(mapped)
? Math.max(0, mapped)
: Math.max(0, Number(CONFIG.RTA_PEAK_HOLD_SEC) || 0);
const decayRate = Math.max(0, Number(CONFIG.RTA_PEAK_DECAY_DB_PER_S) || 0);
const decayRate = Math.max(0, Number(CONFIG.RTA_PEAK_DECAY_DB_PER_S) || 20);
const now = performance.now();
const dt = Math.max(0, (now - (state.holdSampleTs || now)) / 1000);
state.holdSampleTs = now;
@@ -437,11 +471,10 @@ function applyPeakHold(state, levels, CONFIG, range) {
continue;
}
if (mapped === Infinity || mode === 'manual') continue;
let allowDecay = mode === 'off';
if (mode === 'auto') {
allowDecay = (now - (state.lastPeakTime[i] || 0)) >= holdSec * 1000;
}
if (allowDecay && decayRate > 0) {
if (mode === 'auto' && (now - (state.lastPeakTime[i] || 0)) >= holdSec * 1000) {
buffer[i] = current;
state.lastPeakTime[i] = now;
} else if (mode === 'fall' && (now - (state.lastPeakTime[i] || 0)) >= holdSec * 1000) {
buffer[i] = Math.max(current, buffer[i] - decayRate * dt);
}
buffer[i] = clamp(buffer[i], range.bottom, range.top);
@@ -449,6 +482,28 @@ function applyPeakHold(state, levels, CONFIG, range) {
return buffer;
}
function syncNativePeakHold(state, packet, CONFIG, range, expectedLen, engine) {
const src = isVectorLike(packet?.bands_peak) && packet.bands_peak.length === expectedLen
? packet.bands_peak
: null;
if (!src) return applyPeakHold(state, state.displayLevels, CONFIG, range);
const gain = engine === 'iir'
? (Number(CONFIG.RTA_DISPLAY_GAIN_IIR_DB ?? 0) || 0)
: (Number(CONFIG.RTA_DISPLAY_GAIN_FFT_DB ?? 0) || 0);
if (!state.peakHold || state.peakHold.length !== expectedLen) {
state.peakHold = new Float32Array(expectedLen);
}
for (let i = 0; i < expectedLen; i++) {
const value = Number(src[i]);
state.peakHold[i] = clamp(
(Number.isFinite(value) ? value : range.bottom) + gain,
range.bottom,
range.top,
);
}
return state.peakHold;
}
function applyDisplayHold(state, levels, CONFIG, range) {
const holdSec = Math.max(0, Number(CONFIG.RTA_DISPLAY_HOLD_SEC) || 0);
const len = levels.length;
@@ -522,7 +577,33 @@ function applyRealtimeBarBallistics(state, levels, CONFIG, range) {
return buffer;
}
function renderSpectrum(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlayPeaks, ballisticsMode) {
export function buildRtwTickPositions(centers, ticks = RTA_X_TICKS) {
if (!isVectorLike(centers) || !centers.length) return {};
const normalizedCenters = Array.from(centers, Number)
.filter((value) => Number.isFinite(value) && value > 0);
if (!normalizedCenters.length) return {};
const positions = {};
const count = normalizedCenters.length;
for (const tick of ticks || []) {
const frequency = Number(tick);
if (!Number.isFinite(frequency) || frequency <= 0) continue;
let bestIndex = -1;
let bestRelativeError = Infinity;
for (let index = 0; index < count; index++) {
const relativeError = Math.abs(normalizedCenters[index] - frequency) / frequency;
if (relativeError < bestRelativeError) {
bestRelativeError = relativeError;
bestIndex = index;
}
}
if (bestIndex >= 0 && bestRelativeError <= 0.02) {
positions[String(tick)] = (bestIndex + 0.5) / count;
}
}
return positions;
}
function renderSpectrum(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlayPeaks, ballisticsMode, rtwLeftExtension = 0) {
const layout = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'rtw' : 'iec';
const overlay = (ballisticsMode === 'both' && isVectorLike(overlayPeaks) && overlayPeaks.length === state.mapping.length)
? overlayPeaks
@@ -530,7 +611,8 @@ function renderSpectrum(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, ra
if ((CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'line') {
renderLine(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlay);
} else if (layout === 'rtw') {
renderRtwBars(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, overlay);
const extension = Math.max(0, Number(rtwLeftExtension) || 0);
renderRtwBars(g, state, levels, plotX - extension, plotY, plotW + extension, plotH, CONFIG, range, overlay);
} else {
renderIecBars(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlay);
}
@@ -712,32 +794,6 @@ async function drawMeter(env, state, plotX, plotY, plotW, plotH) {
g.restore();
}
function createStaticLayerCanvas(width, height) {
const w = Math.max(1, width | 0);
const h = Math.max(1, height | 0);
if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(w, h);
const c = document.createElement('canvas');
c.width = w;
c.height = h;
return c;
}
function drawCachedStaticLayer(state, g, layerId, key, rect, build) {
if (!state || !rect || rect.w <= 0 || rect.h <= 0) return;
if (!state.staticLayers) state.staticLayers = new Map();
const fullKey = `${layerId}:${key}:${Math.max(0, rect.w | 0)}x${Math.max(0, rect.h | 0)}`;
let layer = state.staticLayers.get(fullKey);
if (!layer) {
const canvas = createStaticLayerCanvas(rect.w, rect.h);
const ctx = canvas.getContext('2d');
build(ctx);
layer = { canvas };
state.staticLayers.set(fullKey, layer);
}
g.drawImage(layer.canvas, rect.x, rect.y);
}
function mapLogX(freq, x0, w, logMin, logSpan) {
const clamped = Math.max(5, freq);
const frac = (Math.log10(clamped) - logMin) / (logSpan || 1);
@@ -794,11 +850,13 @@ function buildFixedRtwBands(bpoMode, freqBounds, nyq, overrideCenters) {
return bands;
}
function selectLocalRtwPacket(packet, bpoMode) {
export function selectLocalRtwPacket(packet, bpoMode) {
if (!packet || !isVectorLike(packet.centers) || !packet.centers.length) return packet;
const desiredCenters = getRtwCenters(bpoMode);
if (!desiredCenters.length) return packet;
if (packet.centers.length === desiredCenters.length) return packet;
if (packet.centers.length === desiredCenters.length) {
return packet.bpo === bpoMode ? packet : { ...packet, bpo: bpoMode };
}
const keyFor = (value) => Number(value).toFixed(1);
const indexByCenter = new Map();
@@ -843,6 +901,7 @@ function selectLocalRtwPacket(packet, bpoMode) {
return {
...packet,
bpo: bpoMode,
centers: selectedCenters,
bands_avg: pickVector(packet.bands_avg || packet.bands),
bands_peak: pickVector(packet.bands_peak),
@@ -909,16 +968,28 @@ function mapNativeFftLevels(packet, CONFIG, range, expectedLen) {
const bottom = (range && Number.isFinite(range.bottom)) ? range.bottom : FLOOR_DB;
const top = (range && Number.isFinite(range.top)) ? range.top : 9;
const gain = Number(CONFIG.RTA_DISPLAY_GAIN_FFT_DB ?? 0) || 0;
const src = isVectorLike(packet.bands_avg || packet.bands)
? (packet.bands_avg || packet.bands)
: null;
if (!src || (expectedLen && src.length !== expectedLen)) return null;
const out = new Float32Array(src.length);
for (let i = 0; i < src.length; i++) {
const v = Number(src[i]);
out[i] = clamp((Number.isFinite(v) ? v : bottom) + gain, bottom, top);
}
return out;
const ensureLen = (arr) => isVectorLike(arr) && (!expectedLen || arr.length === expectedLen) ? arr : null;
const avgRaw = ensureLen(packet.bands_avg || packet.bands);
const peakRaw = ensureLen(packet.bands_peak);
const requested = ['average', 'peak', 'both'].includes(CONFIG.RTA_BALLISTICS_MODE)
? CONFIG.RTA_BALLISTICS_MODE
: 'average';
const convert = (src) => {
if (!src) return null;
const out = new Float32Array(src.length);
for (let i = 0; i < src.length; i++) {
const value = Number(src[i]);
out[i] = clamp((Number.isFinite(value) ? value : bottom) + gain, bottom, top);
}
return out;
};
const average = convert(avgRaw);
const peak = convert(peakRaw);
if (requested === 'peak' && peak) return { primary: peak, overlay: null, mode: 'peak' };
if (requested === 'both' && average && peak) return { primary: average, overlay: peak, mode: 'both' };
if (average) return { primary: average, overlay: null, mode: 'average' };
if (peak) return { primary: peak, overlay: null, mode: 'peak' };
return null;
}
function isVectorLike(v) {
+13 -285
View File
@@ -1,300 +1,28 @@
// views/split_view.js — Split-View: zwei nebeneinander gerenderte Views (links/rechts)
import * as viewGoni from './goniometer_rtw.js';
import * as viewPhaseWheel from './phase_wheel.js';
import * as viewPanel from './panel.js';
import * as viewRealtime from './realtime.js';
import * as viewClassicNeedles from './classic_needles.js';
import * as viewPeakHistory from './peak_history.js';
import * as viewClock from './clock.js';
import * as viewWaveform from './waveform.js';
import * as viewSpectrogram from './spectrogram.js';
import { DEFAULT_TOP_INSET, FRAME_COLOR, PANEL_BG } from '../core/theme.js';
import { DEFAULT_TOP_INSET } from '../core/theme.js';
import {
CHILD_VIEWS,
computeMultiViewBaseLayout,
drawEmptyPlot,
drawMetersPanel,
readMultiViewMeters,
sanitizeChildId,
sanitizePlotId,
withClippedSubRect,
} from './multi_view_shared.js';
export const id = 'split-view';
const CONTENT_TOP = DEFAULT_TOP_INSET;
const CONTENT_BOTTOM = 0;
const CHILD_VIEWS = {
'realtime': viewRealtime,
'classic-needles': viewClassicNeedles,
'peak-history': viewPeakHistory,
'goniometer-rtw': viewGoni,
'phase-wheel': viewPhaseWheel,
'panel': viewPanel,
'clock': viewClock,
'waveform': viewWaveform,
'spectrogram': viewSpectrogram,
};
const ALLOWED_CHILD_VIEW_IDS = new Set(['none', ...Object.keys(CHILD_VIEWS)]);
const ALLOWED_SPLIT_PLOT_IDS = new Set(['none', 'phase-wheel', 'realtime', 'goniometer-rtw', 'peak-history', 'classic-needles', 'panel', 'clock', 'waveform', 'spectrogram']);
const ALLOWED_METER_IDS = new Set(['none', 'vu', 'ppm-ebu', 'ppm-din', 'tp', 'hifi-peak', 'rms', 'lufs', 'stopwatch']);
const ALLOWED_METER_POSITIONS = new Set(['left', 'center', 'right']);
const OUTER_GAP = 10;
const SIDE_INNER_GAP = 8;
const METER_GAP = 12;
const METER_W_DEFAULT = 140;
const METER_W_MIN = 90;
const MIN_PLOT_W = 240;
const METER_PAD_TOP = 15;
const METER_PAD_BOTTOM = 5;
const METER_SLOT_SHRINK = 24;
const METER_EXTRA_BOTTOM_PAD = 6;
function sanitizeChildId(val, fallback) {
return ALLOWED_CHILD_VIEW_IDS.has(val) ? val : fallback;
}
function sanitizePlotId(val, fallback) {
return ALLOWED_SPLIT_PLOT_IDS.has(val) ? val : fallback;
}
function sanitizeMeterId(val, fallback) {
const id = String(val || '');
return ALLOWED_METER_IDS.has(id) ? id : fallback;
}
function sanitizeMeterPos(val, fallback) {
const id = String(val || '');
return ALLOWED_METER_POSITIONS.has(id) ? id : fallback;
}
function clampMeterCount(val) {
const n = Number(val);
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(3, n | 0));
}
async function withClippedSubRect(g, rect, fn) {
g.save();
g.translate(rect.x, rect.y);
g.beginPath();
g.rect(0, 0, rect.w, rect.h);
g.clip();
try { return await fn(); } finally { g.restore(); }
}
function drawSubframe(g, rect) {
g.save();
g.strokeStyle = FRAME_COLOR;
g.lineWidth = 2;
g.strokeRect(rect.x + 0.5, rect.y + 0.5, Math.max(0, rect.w - 1), Math.max(0, rect.h - 1));
g.restore();
}
function createStaticLayerCanvas(width, height) {
const w = Math.max(1, width | 0);
const h = Math.max(1, height | 0);
if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(w, h);
const c = document.createElement('canvas');
c.width = w;
c.height = h;
return c;
}
function drawCachedStaticLayer(state, g, layerId, key, rect, build) {
if (!state || !rect || rect.w <= 0 || rect.h <= 0) return;
if (!state.staticLayers) state.staticLayers = new Map();
const fullKey = `${layerId}:${key}:${Math.max(0, rect.w | 0)}x${Math.max(0, rect.h | 0)}`;
let layer = state.staticLayers.get(fullKey);
if (!layer) {
const canvas = createStaticLayerCanvas(rect.w, rect.h);
const ctx = canvas.getContext('2d');
build(ctx, rect);
layer = { canvas };
state.staticLayers.set(fullKey, layer);
}
g.drawImage(layer.canvas, rect.x, rect.y);
}
function drawEmptyPlot(state, g, rect, label) {
drawCachedStaticLayer(state, g, 'empty-plot', String(label || '(leer)'), rect, (lg) => {
lg.fillStyle = PANEL_BG;
lg.fillRect(0, 0, rect.w, rect.h);
lg.fillStyle = '#9aa';
lg.textAlign = 'left';
lg.font = 'bold 14px ui-monospace, monospace';
lg.fillText(label || '(leer)', 12, 32);
lg.textAlign = 'start';
});
}
function readSplitMeters(CONFIG) {
const count = clampMeterCount(CONFIG?.SPLIT_VIEW_METER_COUNT);
const slots = [
{
id: sanitizeMeterId(CONFIG?.SPLIT_VIEW_METER_1, 'vu'),
pos: sanitizeMeterPos(CONFIG?.SPLIT_VIEW_METER_1_POS, 'right'),
},
{
id: sanitizeMeterId(CONFIG?.SPLIT_VIEW_METER_2, 'ppm-din'),
pos: sanitizeMeterPos(CONFIG?.SPLIT_VIEW_METER_2_POS, 'right'),
},
{
id: sanitizeMeterId(CONFIG?.SPLIT_VIEW_METER_3, 'lufs'),
pos: sanitizeMeterPos(CONFIG?.SPLIT_VIEW_METER_3_POS, 'right'),
},
].slice(0, count);
return slots;
}
function groupMetersByPosition(slots) {
const out = { left: [], center: [], right: [] };
for (const s of slots || []) {
if (!s) continue;
if (s.pos === 'left') out.left.push(s.id);
else if (s.pos === 'center') out.center.push(s.id);
else out.right.push(s.id);
}
return out;
return readMultiViewMeters(CONFIG, 'SPLIT_VIEW');
}
function computeSplitLayout(rect, slots) {
const contentH = Math.max(0, rect.h - CONTENT_TOP - CONTENT_BOTTOM);
const hasContent = contentH >= 140;
const BLOCK_GAP = SIDE_INNER_GAP;
let effectiveSlots = hasContent ? (Array.isArray(slots) ? slots.slice(0, 3) : []) : [];
while (true) {
const grouped = groupMetersByPosition(effectiveSlots);
const leftCount = grouped.left.length;
const centerCount = grouped.center.length;
const rightCount = grouped.right.length;
const totalSlots = leftCount + centerCount + rightCount;
const hasLeftMeters = hasContent && leftCount > 0;
const hasCenterMeters = hasContent && centerCount > 0;
const hasRightMeters = hasContent && rightCount > 0;
const interBlockGaps =
(hasLeftMeters ? BLOCK_GAP : 0) +
(hasRightMeters ? BLOCK_GAP : 0) +
(hasCenterMeters ? (2 * BLOCK_GAP) : OUTER_GAP);
const internalGaps =
Math.max(0, leftCount - 1) * METER_GAP +
Math.max(0, centerCount - 1) * METER_GAP +
Math.max(0, rightCount - 1) * METER_GAP;
const minRequired = 2 * MIN_PLOT_W + interBlockGaps + internalGaps;
const remainingForMeters = rect.w - minRequired;
let slotW = 0;
if (totalSlots > 0) {
slotW = Math.floor(remainingForMeters / totalSlots);
slotW = Math.min(METER_W_DEFAULT, slotW);
}
if (totalSlots > 0 && slotW < METER_W_MIN && effectiveSlots.length) {
effectiveSlots = effectiveSlots.slice(0, -1);
continue;
}
if (totalSlots > 0) slotW = Math.max(METER_W_MIN, Math.min(METER_W_DEFAULT, slotW));
const leftW = hasLeftMeters ? (leftCount * slotW + Math.max(0, leftCount - 1) * METER_GAP) : 0;
const centerW = hasCenterMeters ? (centerCount * slotW + Math.max(0, centerCount - 1) * METER_GAP) : 0;
const rightW = hasRightMeters ? (rightCount * slotW + Math.max(0, rightCount - 1) * METER_GAP) : 0;
const metersTotalW = leftW + centerW + rightW;
const plotAvail = Math.max(0, rect.w - interBlockGaps - metersTotalW);
const leftPlotW = Math.floor(plotAvail / 2);
const rightPlotW = plotAvail - leftPlotW;
const plotFits = leftPlotW >= MIN_PLOT_W && rightPlotW >= MIN_PLOT_W;
if (!plotFits && effectiveSlots.length) {
effectiveSlots = effectiveSlots.slice(0, -1);
continue;
}
let x = 0;
const leftMetersRect = hasLeftMeters ? { x, y: CONTENT_TOP, w: leftW, h: contentH } : null;
if (leftMetersRect) x += leftW + BLOCK_GAP;
const leftPlotRect = { x, y: 0, w: leftPlotW, h: rect.h };
x += leftPlotW;
let centerMetersRect = null;
if (hasCenterMeters) {
x += BLOCK_GAP;
centerMetersRect = { x, y: CONTENT_TOP, w: centerW, h: contentH };
x += centerW + BLOCK_GAP;
} else {
x += OUTER_GAP;
}
const rightPlotRect = { x, y: 0, w: rightPlotW, h: rect.h };
x += rightPlotW;
let rightMetersRect = null;
if (hasRightMeters) {
x += BLOCK_GAP;
rightMetersRect = { x, y: CONTENT_TOP, w: rightW, h: contentH };
}
return {
plots: { left: leftPlotRect, right: rightPlotRect },
meters: { left: leftMetersRect, center: centerMetersRect, right: rightMetersRect },
meterIds: grouped,
slotW,
effectiveSlots,
};
}
}
async function drawMetersPanel(env, state, rect, meterIds, slotW) {
const { ctx: g, meters, config: CONFIG } = env;
if (!rect || rect.w <= 0 || rect.h <= 0) return;
const ids = Array.isArray(meterIds) ? meterIds.slice(0, 3) : [];
const count = ids.length;
if (!count) return;
drawCachedStaticLayer(state, g, 'meter-panel-shell', 'bg-frame', rect, (lg) => {
lg.fillStyle = PANEL_BG;
lg.fillRect(0, 0, rect.w, rect.h);
drawSubframe(lg, { x: 0, y: 0, w: rect.w, h: rect.h });
});
const gap = METER_GAP;
const n = Math.max(1, Math.min(3, count));
const usedW = n * slotW + (n - 1) * gap;
const startX = rect.x + Math.max(0, Math.floor((rect.w - usedW) / 2));
const shrink = Math.max(0, METER_SLOT_SHRINK);
const innerHeight = Math.max(40, rect.h - METER_PAD_TOP - METER_PAD_BOTTOM - shrink - METER_EXTRA_BOTTOM_PAD);
const innerOffset = shrink / 2;
const slotY = rect.y + METER_PAD_TOP + innerOffset;
const slotH = innerHeight;
for (let i = 0; i < n; i++) {
const id = sanitizeMeterId(ids[i], 'none');
const r = { x: startX + i * (slotW + gap), y: slotY, w: slotW, h: slotH };
if (id === 'none') {
drawCachedStaticLayer(state, g, 'meter-slot-empty', `${slotW}x${slotH}`, r, (lg) => {
lg.strokeStyle = 'rgba(0,231,255,0.25)';
lg.setLineDash([6, 5]);
lg.strokeRect(0.5, 0.5, Math.max(0, r.w - 1), Math.max(0, r.h - 1));
lg.setLineDash([]);
lg.fillStyle = '#9aa';
lg.textAlign = 'center';
lg.font = '12px ui-monospace, monospace';
lg.fillText('(leer)', r.w / 2, 22);
lg.textAlign = 'start';
});
} else {
try {
g.save();
g.beginPath();
g.rect(rect.x + 1, rect.y + 1, Math.max(0, rect.w - 2), Math.max(0, rect.h - 2));
g.clip();
await meters.draw(g, r, id, CONFIG);
g.restore();
} catch (e) {
g.restore();
console.warn('Split meter draw error:', e);
}
}
}
return computeMultiViewBaseLayout(rect, slots, CONTENT_TOP, CONTENT_BOTTOM);
}
function destroyChild(child) {
+14 -7
View File
@@ -1,11 +1,9 @@
import { createCanvasSurface } from '../core/canvas_surface.js';
const MAX_STATIC_LAYERS = 24;
export function createStaticLayerCanvas(width, height) {
const w = Math.max(1, width | 0);
const h = Math.max(1, height | 0);
if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(w, h);
const c = document.createElement('canvas');
c.width = w;
c.height = h;
return c;
return createCanvasSurface(width, height)?.canvas || null;
}
export function drawCachedStaticLayer(state, g, layerId, key, rect, build) {
@@ -13,12 +11,21 @@ export function drawCachedStaticLayer(state, g, layerId, key, rect, build) {
if (!state.staticLayers) state.staticLayers = new Map();
const fullKey = `${layerId}:${key}:${Math.max(0, rect.w | 0)}x${Math.max(0, rect.h | 0)}`;
let layer = state.staticLayers.get(fullKey);
if (layer) {
// Map-Reihenfolge als kleine LRU-Liste verwenden.
state.staticLayers.delete(fullKey);
state.staticLayers.set(fullKey, layer);
}
if (!layer) {
const canvas = createStaticLayerCanvas(rect.w, rect.h);
if (!canvas) return;
const ctx = canvas.getContext('2d');
build(ctx, rect);
layer = { canvas };
state.staticLayers.set(fullKey, layer);
while (state.staticLayers.size > MAX_STATIC_LAYERS) {
state.staticLayers.delete(state.staticLayers.keys().next().value);
}
}
g.drawImage(layer.canvas, rect.x, rect.y);
}