Compare commits
17
Commits
e318647d3b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cef997d246 | ||
|
|
a4fa0ceb47 | ||
|
|
00fb59f643 | ||
|
|
22444ff5bd | ||
|
|
890a47a272 | ||
|
|
2e868bfa3a | ||
|
|
97352ecfce | ||
|
|
4379b78b8f | ||
|
|
fbcafd43f9 | ||
|
|
3a0bdbb5ad | ||
|
|
caec1edfcf | ||
|
|
c067c73024 | ||
|
|
a123539023 | ||
|
|
9b5250d42b | ||
|
|
3478bfb495 | ||
|
|
af974b92ec | ||
|
|
0347a5f946 |
@@ -117,6 +117,16 @@ Volumio-spezifisch:
|
|||||||
/home/volumio/phoenix.log
|
/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:
|
Wenn `toggle-ext` genutzt werden soll, sollte es also unter diesem Pfad liegen:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
## Verbindliches Ziel
|
## Verbindliches Ziel
|
||||||
|
|
||||||
- Referenz ist der RTW PortaMonitor 1064X/1064X-PLUS, insbesondere dessen RTA-, PPM-, Peakmeter-, Goniometer- und Korrelationsverhalten.
|
- Referenz ist der RTW PortaMonitor 1064X/1064X-PLUS, insbesondere dessen RTA-, PPM-, Peakmeter-, Goniometer- und Korrelationsverhalten.
|
||||||
|
- Gemessen werden analoge Line-Signale. Deshalb umfasst die endgültige Referenzmessung immer den vollständigen analogen Eingang einschließlich Vorstufe, Antialiasing und A/D-Wandler; reine Digitaltests sichern nur den nachfolgenden DSP ab.
|
||||||
- Der primäre RTW-nahe RTA arbeitet als IIR-Fractional-Octave-Filterbank. FFT bleibt eine optionale, getrennt gekennzeichnete Spektrumsansicht.
|
- Der primäre RTW-nahe RTA arbeitet als IIR-Fractional-Octave-Filterbank. FFT bleibt eine optionale, getrennt gekennzeichnete Spektrumsansicht.
|
||||||
- Der RTW-RTA-Modus soll 31 Bänder in 1/3-Oktaven von 20 Hz bis 20 kHz sowie ein Verhalten entsprechend IEC 225/ANSI Class 2 beziehungsweise der passenden aktuellen Nachfolgenorm bieten.
|
- Der RTW-RTA-Modus soll 31 Bänder in 1/3-Oktaven von 20 Hz bis 20 kHz sowie ein Verhalten entsprechend IEC 225/ANSI Class 2 beziehungsweise der passenden aktuellen Nachfolgenorm bieten.
|
||||||
- Vorgesehene RTA-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.
|
- 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.
|
- 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.
|
- 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**
|
- [x] **1. WebSocket auf "latest value wins" umstellen**
|
||||||
- **Soll:** Ein langsamer Client erhält immer den neuesten Messzustand; alte Zustände werden verworfen.
|
- **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.
|
- **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.
|
||||||
- **Abnahme:** Lokaler Laufzeittest liefert 68 aktuelle Messframes in 1,1 s; alte Zustände werden beim Leeren verworfen.
|
- **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**
|
- [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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **Abnahme:** Jedes der 31 Bänder besteht automatisierte Sweep- und Pegeltests innerhalb der festgelegten Toleranzen.
|
||||||
|
|
||||||
- [ ] **6. RTW-Integrationsmodi vollständig und energetisch korrekt implementieren**
|
- [ ] **6. RTW-Detektor und Reaktionszeiten vollständig und energetisch korrekt implementieren**
|
||||||
- **Soll:** Fast, Medium, Slow, Average und Peak mit dokumentiertem RTW-nahem Verhalten.
|
- **Soll:** Getrennte Auswahl von Average-/Peak-Detektor und der RMS-Reaktionszeit entsprechend der RTW-Bedienlogik.
|
||||||
- **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.
|
- **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:** Ein Regressionstest bestätigt identische Integration bei unterschiedlichen Blockgrößen.
|
- **Geprüft:** Regressionstests prüfen Blockgrößenunabhängigkeit, 10-ms-Peakintegration, Impulse-Anstieg/-Rücklauf sowie die Konfigurationsmigration.
|
||||||
- **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.
|
- **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.
|
- **Abnahme:** Sprung-, Burst- und Rauschtests zeigen für jeden Modus reproduzierbares RTW-nahes Verhalten.
|
||||||
|
|
||||||
- [ ] **7. Peak Hold und Bandspeicher wie beim PortaMonitor ergänzen**
|
- [ ] **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.
|
- **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.
|
- **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.
|
||||||
- **Falsch/unvollständig:** Ein kontinuierlich fallender Peak ist funktional nicht dasselbe wie Peak Hold.
|
- **Noch offen:** Der achtbandige RTW-Speicher ist noch nicht ergänzt.
|
||||||
- **Aufgabe:** Hold-Zeit, manuellen Hold, Reset, Rücklauf nach Hold-Ende und acht auswählbare Speicherbänder implementieren. Aktuellwert und Holdwert visuell eindeutig trennen.
|
- **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.
|
- **Abnahme:** Hold-Zeiten und Reset-Verhalten stimmen zeitlich und visuell mit der Referenz überein.
|
||||||
|
|
||||||
- [ ] **8. RTA-Anzeigeoptionen am PortaMonitor-Profil ausrichten**
|
- [ ] **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.
|
- **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.
|
- **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.
|
- **Soll:** Intersample-Peaks werden unabhängig von ihrer Lage zum ALSA-Block zuverlässig erkannt.
|
||||||
- **Ist:** Eine 4-fache Sinc-Interpolation mit kurzer Historie existiert.
|
- **Ist:** Die 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.
|
||||||
- **Falsch/kaputt:** Ungefähr die letzten acht Intervalle jedes Capture-Blocks werden nicht interpoliert und können zu niedrige dBTP-Werte liefern.
|
- **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.
|
||||||
- **Aufgabe:** Kontinuierlichen Oversampling-Filter mit vollständiger Historie verwenden und gegen ITU-Testmaterial sowie synthetische Grenzfälle prüfen.
|
|
||||||
- **Abnahme:** Gleiche Peakwerte unabhängig von Blockgrenze, Periodengröße und Samplerate.
|
- **Abnahme:** Gleiche Peakwerte unabhängig von Blockgrenze, Periodengröße und Samplerate.
|
||||||
|
|
||||||
- [x] **11. DIN- und EBU-PPM softwareseitig norm- und RTW-nah auslegen**
|
- [x] **11. DIN- und EBU-PPM softwareseitig norm- und RTW-nah auslegen**
|
||||||
- **Soll:** Richtige Skalen, Referenzpegel, Tonburst-Reaktion, Integration, Rücklauf, Peak Hold, Peak Memory und Over-Anzeige.
|
- **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.
|
- **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.
|
- **Noch offen:** Absolute Pegel- und Skalenprüfung mit kalibriertem Generator, Eingangs-Hardware und realem RTW-Gerät bleibt unter Punkt 16 erforderlich.
|
||||||
- **Abnahme:** Softwaretests bestehen; die endgültige Aussage zur Messgeräte-Konformität erfolgt erst nach der Hardwarevergleichsmessung.
|
- **Abnahme:** Softwaretests bestehen; die endgültige Aussage zur Messgeräte-Konformität erfolgt erst nach der Hardwarevergleichsmessung.
|
||||||
|
|
||||||
- [ ] **12. VU und RMS eindeutig und reproduzierbar definieren**
|
- [x] **12. VU und RMS softwareseitig eindeutig und reproduzierbar definieren**
|
||||||
- **Soll VU:** RTW-artige Moving-Coil-Ballistik mit richtigem Einschwingen, Rücklauf und Überschwingen.
|
- **Soll VU:** RTW-artige Moving-Coil-Ballistik mit richtigem Einschwingen, Rücklauf und Überschwingen.
|
||||||
- **Ist VU:** 300-ms-Rechteckmittel der gleichgerichteten Samples.
|
- **Ist VU:** Vollweggleichrichtung mit RMS-Kalibrierung und unterdämpftem Moving-Coil-Modell. Der 1-kHz-Sprung erreicht nach 300 ms etwa 99 % und überschwingt um 1 bis 1,5 %; der Rücklauf und mehrere Capture-Perioden werden automatisch geprüft.
|
||||||
- **Falsch VU:** Boxcar-Mittelung entspricht nicht der mechanischen VU-Ballistik.
|
|
||||||
- **Soll RMS:** Dokumentiertes gleitendes Messfenster mit eindeutigem dBFS-/Kalibrierbezug.
|
- **Soll RMS:** Dokumentiertes gleitendes Messfenster mit eindeutigem dBFS-/Kalibrierbezug.
|
||||||
- **Ist RMS:** RMS nur über den aktuellen ALSA-Block, bei 128 Samples etwa 2,67 ms.
|
- **Ist RMS:** True RMS wird 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.
|
||||||
- **Falsch RMS:** Wert und Unruhe hängen von der Periodengröße ab.
|
- **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.
|
||||||
- **Aufgabe:** Beide Detektoren unabhängig von der Capture-Blockgröße implementieren und separat testen.
|
- **Noch extern zu prüfen:** Pegelkalibrierung und die optische Übereinstimmung mit dem konkreten RTW-PortaMonitor am analogen Eingang.
|
||||||
- **Abnahme:** Identische Werte und Ballistiken bei verschiedenen ALSA-Perioden.
|
- **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.
|
- **Soll:** FFT ist eine korrekte Zusatzansicht, aber nicht die RTW-IIR-Referenz.
|
||||||
- **Ist:** FFT-RTA und Spektrogramm sind vorhanden.
|
- **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.
|
||||||
- **Falsch:** FFT-Bins werden innerhalb eines Bandes normiert gemittelt statt zur Bandenergie summiert. Rauschsignale und unterschiedlich breite Bänder werden dadurch falsch bewertet.
|
- **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.
|
||||||
- **Aufgabe:** Binleistungen energetisch integrieren, Fensterleistung korrekt kompensieren und FFT-Ergebnisse gegen die IIR-Referenz testen.
|
|
||||||
- **Abnahme:** Konsistente Pegel bei FFT-Größenwechseln und eindeutig getrennte Kennzeichnung im UI.
|
- **Abnahme:** Konsistente Pegel bei FFT-Größenwechseln und eindeutig getrennte Kennzeichnung im UI.
|
||||||
|
|
||||||
## Priorität 3 - Stereoanzeigen und visuelles RTW-Verhalten
|
## 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**
|
- [ ] **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.
|
- **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.
|
- **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.
|
- **Abnahme:** Geringe Reaktionslatenz ohne unnötige Allokationen, bei vergleichbarem visuellen Nachleuchten.
|
||||||
|
|
||||||
- [ ] **15. Korrelation vollständig am RTW-Verhalten prüfen**
|
- [ ] **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.
|
- **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. 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.
|
- **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.
|
- **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.
|
- **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.
|
- **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**
|
- [ ] **18. Automatisierte DSP- und Darstellungsregressionstests aufbauen**
|
||||||
- **Soll:** Keine Änderung kann unbemerkt Pegel, Frequenzgang, Ballistik, Latenz oder RTW-Darstellung verschlechtern.
|
- **Soll:** Keine Änderung kann unbemerkt Pegel, Frequenzgang, Ballistik, Latenz oder RTW-Darstellung verschlechtern.
|
||||||
- **Ist:** DIN-/EBU-PPM, Spektrogramm-Zeitbasis und Langlauf, Worker-Verhalten sowie die Binärprotokolle sind automatisiert abgesichert. Servertests prüfen außerdem das Zusammenführen der Waveform-Hüllkurve und die Trennung großer Nutzdaten vom JSON-Messstrom.
|
- **Ist:** DIN-/EBU-PPM, Spektrogramm-Zeitbasis und Langlauf, Worker-Verhalten sowie die Binärprotokolle sind automatisiert abgesichert. Servertests prüfen außerdem das Zusammenführen der Waveform-Hüllkurve und die Trennung großer Nutzdaten vom JSON-Messstrom.
|
||||||
- **Noch offen:** RTA und Korrelation besitzen nun DSP-Tests; der Goniometerweg prüft mehrere Sampleraten, Periodengrößen, Punktreduktion und Persistenzprofile. True Peak, VU/RMS, dynamische Referenzvergleiche und End-to-End-Latenz besitzen noch keine vollständige automatische Regression. Deshalb bleibt dieser Gesamtpunkt offen.
|
- **Ergänzt:** RTA und Korrelation besitzen DSP-Tests; die Korrelation prüft jetzt auch einen dynamischen Phasensprung und unterscheidet dabei 1,0 von 2,5 Sekunden. Der Goniometerweg prüft mehrere Sampleraten, Periodengrößen, Punktreduktion und Persistenzprofile. True Peak 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.
|
- **Aufgabe:** Einzeltöne aller 31 Bänder, Sweeps, Weiß-/Rosarauschen, Pegelsprünge, Tonbursts, Phasen-/Korrelationssignale und Intersample-Peaks testen. Sampleraten, Perioden und Blockgrenzen variieren. RTW-Screenshots und Messprotokolle als Referenz verwenden, soweit rechtlich möglich.
|
||||||
- **Abnahme:** Automatischer Bericht mit Erwartungswerten und Toleranzen für jede Messfunktion.
|
- **Abnahme:** Automatischer Bericht mit Erwartungswerten und Toleranzen für jede Messfunktion.
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
"panelDividersEnabled": true,
|
||||||
"ppmDinAttackMs": 10.0,
|
"ppmDinAttackMs": 10.0,
|
||||||
"ppmDinDecayDbPerS": 13.333333,
|
"ppmDinDecayDbPerS": 13.333333,
|
||||||
"ppmDinFastAttack": false,
|
|
||||||
"ppmDinLoudnessBoxes": true,
|
"ppmDinLoudnessBoxes": true,
|
||||||
"ppmEbuAttackMs": 10.0,
|
"ppmEbuAttackMs": 10.0,
|
||||||
"ppmEbuDecayDbPerS": 8.571429,
|
"ppmEbuDecayDbPerS": 8.571429,
|
||||||
|
"rmsIntegration": "fast",
|
||||||
"lufsIWindowMin": 4,
|
"lufsIWindowMin": 4,
|
||||||
"lufsINormEnabled": false,
|
"lufsINormEnabled": false,
|
||||||
"ppmDinLoudnessOffsetDb": 0.0,
|
"ppmDinLoudnessOffsetDb": 0.0,
|
||||||
|
|||||||
@@ -11,10 +11,10 @@
|
|||||||
"panelDividersEnabled": true,
|
"panelDividersEnabled": true,
|
||||||
"ppmDinAttackMs": 10.0,
|
"ppmDinAttackMs": 10.0,
|
||||||
"ppmDinDecayDbPerS": 13.333333,
|
"ppmDinDecayDbPerS": 13.333333,
|
||||||
"ppmDinFastAttack": false,
|
|
||||||
"ppmDinLoudnessBoxes": true,
|
"ppmDinLoudnessBoxes": true,
|
||||||
"ppmEbuAttackMs": 10.0,
|
"ppmEbuAttackMs": 10.0,
|
||||||
"ppmEbuDecayDbPerS": 8.571429,
|
"ppmEbuDecayDbPerS": 8.571429,
|
||||||
|
"rmsIntegration": "fast",
|
||||||
"lufsIWindowMin": 4,
|
"lufsIWindowMin": 4,
|
||||||
"lufsINormEnabled": false,
|
"lufsINormEnabled": false,
|
||||||
"ppmDinLoudnessOffsetDb": 0.0,
|
"ppmDinLoudnessOffsetDb": 0.0,
|
||||||
|
|||||||
Executable
+58
@@ -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"
|
||||||
@@ -3,7 +3,12 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
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_PARENT="$(cd "$PHOENIX_ROOT/.." && pwd -P)"
|
||||||
PHOENIX_NAME="$(basename "$PHOENIX_ROOT")"
|
PHOENIX_NAME="$(basename "$PHOENIX_ROOT")"
|
||||||
STAGE_DIR="$PHOENIX_PARENT/${PHOENIX_NAME}.update"
|
STAGE_DIR="$PHOENIX_PARENT/${PHOENIX_NAME}.update"
|
||||||
@@ -14,6 +19,10 @@ LOG_FILE="$PHOENIX_PARENT/${PHOENIX_NAME}.update.log"
|
|||||||
exec >>"$LOG_FILE" 2>&1
|
exec >>"$LOG_FILE" 2>&1
|
||||||
echo "=== $(date): restart_phoenix_services.sh START ==="
|
echo "=== $(date): restart_phoenix_services.sh START ==="
|
||||||
|
|
||||||
|
is_volumio_install() {
|
||||||
|
[ "${PHOENIX_INSTALL_PROFILE:-}" = "volumio" ] || [ "$PHOENIX_ROOT" = "/home/volumio/Phoenix" ]
|
||||||
|
}
|
||||||
|
|
||||||
restart_if_present() {
|
restart_if_present() {
|
||||||
local unit="$1"
|
local unit="$1"
|
||||||
if ! command -v systemctl >/dev/null 2>&1; then
|
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"
|
/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() {
|
health_ok() {
|
||||||
if ! command -v curl >/dev/null 2>&1; then
|
if ! command -v curl >/dev/null 2>&1; then
|
||||||
return 0
|
return 0
|
||||||
@@ -144,7 +166,56 @@ wait_for_candidate_health() {
|
|||||||
return 1
|
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() {
|
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 phoenix.service
|
||||||
restart_if_present analyzer-web.service
|
restart_if_present analyzer-web.service
|
||||||
restart_if_present analyzer-web-volumio.service
|
restart_if_present analyzer-web-volumio.service
|
||||||
@@ -154,6 +225,11 @@ start_all_services() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stop_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.service
|
||||||
stop_if_present analyzer-kiosk-volumio.service
|
stop_if_present analyzer-kiosk-volumio.service
|
||||||
stop_if_present analyzer-web.service
|
stop_if_present analyzer-web.service
|
||||||
@@ -180,6 +256,16 @@ rollback_to_backup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if [ "${PHOENIX_UPDATE_DETACHED:-0}" != "1" ]; then
|
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
|
if ! command -v systemd-run >/dev/null 2>&1; then
|
||||||
echo "systemd-run not found; cannot safely detach updater" >&2
|
echo "systemd-run not found; cannot safely detach updater" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -202,6 +288,10 @@ if [ ! -f "$STAGE_DIR/Cargo.toml" ] || [ ! -d "$STAGE_DIR/src" ] || [ ! -d "$STA
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if is_volumio_install; then
|
||||||
|
build_volumio_candidate "$STAGE_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
stop_all_services
|
stop_all_services
|
||||||
|
|
||||||
rm -rf "$BACKUP_DIR"
|
rm -rf "$BACKUP_DIR"
|
||||||
@@ -222,4 +312,11 @@ if ! wait_for_candidate_health "$PHOENIX_ROOT" "$health_attempts" "$health_delay
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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 ==="
|
echo "=== $(date): restart_phoenix_services.sh OK ==="
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -8,6 +8,10 @@ assert.ok(functionSource, 'persistence resolver must remain testable');
|
|||||||
const context = vm.createContext({ Number, String, Math });
|
const context = vm.createContext({ Number, String, Math });
|
||||||
vm.runInContext(functionSource.replace('export function', 'function'), context);
|
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: 'fast' }), 50);
|
||||||
assert.equal(context.resolveGoniometerPersistenceMs({ GONIO_PERSISTENCE_MODE: 'medium' }), 150);
|
assert.equal(context.resolveGoniometerPersistenceMs({ GONIO_PERSISTENCE_MODE: 'medium' }), 150);
|
||||||
assert.equal(context.resolveGoniometerPersistenceMs({ GONIO_PERSISTENCE_MODE: 'slow' }), 300);
|
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\(/,
|
assert.doesNotMatch(source, /utils\.correlation\(/,
|
||||||
'browser must not recalculate or smooth backend 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');
|
console.log('goniometer regression tests passed');
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -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');
|
||||||
@@ -1,14 +1,34 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import vm from 'node:vm';
|
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 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];
|
const functionSource = source.match(/export function buildRtaRuntimeConfig[\s\S]*?\n\}/)?.[0];
|
||||||
assert.ok(functionSource, 'buildRtaRuntimeConfig must remain testable');
|
assert.ok(functionSource, 'buildRtaRuntimeConfig must remain testable');
|
||||||
const context = vm.createContext({
|
const context = vm.createContext({
|
||||||
Number,
|
Number,
|
||||||
|
normalizeCorrelationResponseSeconds: correlationContext.normalizeCorrelationResponseSeconds,
|
||||||
getRtwCenters(mode) {
|
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);
|
vm.runInContext(functionSource.replace('export function', 'function'), context);
|
||||||
@@ -19,19 +39,35 @@ const forced = context.buildRtaRuntimeConfig({
|
|||||||
RTA_BPO_MODE: '1_12',
|
RTA_BPO_MODE: '1_12',
|
||||||
RTA_FREQ_RANGE: 'lf',
|
RTA_FREQ_RANGE: 'lf',
|
||||||
RTA_IIR_ORDER: 2,
|
RTA_IIR_ORDER: 2,
|
||||||
|
RTA_DETECTOR: 'peak',
|
||||||
|
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_RESPONSE_S: 2.5,
|
||||||
|
CORR_SILENCE_THRESHOLD_RMS_DBFS: -50,
|
||||||
CORR_RESET_TOKEN: 7,
|
CORR_RESET_TOKEN: 7,
|
||||||
XY_POINTS: 256,
|
XY_POINTS: 256,
|
||||||
});
|
});
|
||||||
assert.equal(forced.engine, 'iir');
|
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.freqRange, 'norm');
|
||||||
assert.equal(forced.order, 6);
|
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.correlationResponseS, 2.5);
|
||||||
|
assert.equal(forced.correlationSilenceThresholdRmsDbfs, -50);
|
||||||
assert.equal(forced.correlationResetToken, 7);
|
assert.equal(forced.correlationResetToken, 7);
|
||||||
assert.equal(forced.xyPoints, 256);
|
assert.equal(forced.xyPoints, 256);
|
||||||
|
|
||||||
|
const veryFastCorrelation = context.buildRtaRuntimeConfig({ CORR_RESPONSE_S: 0.5 });
|
||||||
|
assert.equal(veryFastCorrelation.correlationResponseS, 0.5);
|
||||||
|
|
||||||
const extension = context.buildRtaRuntimeConfig({
|
const extension = context.buildRtaRuntimeConfig({
|
||||||
RTA_BAR_LAYOUT: 'iec',
|
RTA_BAR_LAYOUT: 'iec',
|
||||||
RTA_ENGINE: 'fft',
|
RTA_ENGINE: 'fft',
|
||||||
@@ -44,7 +80,6 @@ assert.equal(extension.bpo, '1_12');
|
|||||||
assert.equal(extension.freqRange, 'lf');
|
assert.equal(extension.freqRange, 'lf');
|
||||||
assert.equal(extension.order, 8);
|
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];
|
const selectionSource = configSource.match(/function applyRtaBpoSelection[\s\S]*?\n\}/)?.[0];
|
||||||
assert.ok(selectionSource, 'RTA BPO selection policy must remain testable');
|
assert.ok(selectionSource, 'RTA BPO selection policy must remain testable');
|
||||||
const selectionContext = vm.createContext({
|
const selectionContext = vm.createContext({
|
||||||
@@ -54,9 +89,33 @@ const selectionContext = vm.createContext({
|
|||||||
vm.runInContext(selectionSource, selectionContext);
|
vm.runInContext(selectionSource, selectionContext);
|
||||||
assert.equal(selectionContext.applyRtaBpoSelection('1_6'), '1_6');
|
assert.equal(selectionContext.applyRtaBpoSelection('1_6'), '1_6');
|
||||||
assert.equal(selectionContext.CONFIG.RTA_BPO_MODE, '1_6');
|
assert.equal(selectionContext.CONFIG.RTA_BPO_MODE, '1_6');
|
||||||
assert.equal(selectionContext.CONFIG.RTA_BAR_LAYOUT, 'iec');
|
assert.equal(selectionContext.CONFIG.RTA_BAR_LAYOUT, 'rtw');
|
||||||
selectionContext.CONFIG.RTA_BAR_LAYOUT = 'rtw';
|
|
||||||
assert.equal(selectionContext.applyRtaBpoSelection('1_3'), '1_3');
|
assert.equal(selectionContext.applyRtaBpoSelection('1_3'), '1_3');
|
||||||
assert.equal(selectionContext.CONFIG.RTA_BAR_LAYOUT, 'rtw');
|
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');
|
console.log('RTA profile regression tests passed');
|
||||||
|
|||||||
@@ -19,10 +19,15 @@ const [metrics] = sockets;
|
|||||||
let count = 0;
|
let count = 0;
|
||||||
let lastSeq = 0;
|
let lastSeq = 0;
|
||||||
let invalidPayload = false;
|
let invalidPayload = false;
|
||||||
|
let maxFrameAgeMs = 0;
|
||||||
metrics.addEventListener('message', (event) => {
|
metrics.addEventListener('message', (event) => {
|
||||||
const frame = JSON.parse(String(event.data));
|
const frame = JSON.parse(String(event.data));
|
||||||
const seq = Number(frame.seq);
|
const seq = Number(frame.seq);
|
||||||
if (seq <= lastSeq || 'spectro' in frame || 'wave_env' in frame || 'xy_l' in frame || 'wave_l' in frame) {
|
const timestamp = Number(frame.timestamp_ms);
|
||||||
|
const age = Date.now() - timestamp;
|
||||||
|
maxFrameAgeMs = Math.max(maxFrameAgeMs, age);
|
||||||
|
if (seq <= lastSeq || !Number.isFinite(timestamp) || age < -100 || age > 500
|
||||||
|
|| 'spectro' in frame || 'wave_env' in frame || 'xy_l' in frame || 'wave_l' in frame) {
|
||||||
invalidPayload = true;
|
invalidPayload = true;
|
||||||
}
|
}
|
||||||
lastSeq = seq;
|
lastSeq = seq;
|
||||||
@@ -33,4 +38,4 @@ await new Promise((resolve) => setTimeout(resolve, 1100));
|
|||||||
sockets.forEach((socket) => socket.close());
|
sockets.forEach((socket) => socket.close());
|
||||||
assert.equal(invalidPayload, false, 'metrics must be ordered and contain no large visual payloads');
|
assert.equal(invalidPayload, false, 'metrics must be ordered and contain no large visual payloads');
|
||||||
assert.ok(count >= 50 && count <= 75, `expected about 60 metrics/s, received ${count}`);
|
assert.ok(count >= 50 && count <= 75, `expected about 60 metrics/s, received ${count}`);
|
||||||
console.log(`runtime websocket test passed (${count} metrics in 1.1 s; all three streams opened)`);
|
console.log(`runtime websocket test passed (${count} metrics in 1.1 s; max frame age ${maxFrameAgeMs} ms; all three streams opened)`);
|
||||||
|
|||||||
+7
-1
@@ -33,6 +33,8 @@ PHOENIX_SAMPLE_RATE="${PHOENIX_SAMPLE_RATE:-48000}"
|
|||||||
PHOENIX_PERIOD_SIZE="${PHOENIX_PERIOD_SIZE:-128}"
|
PHOENIX_PERIOD_SIZE="${PHOENIX_PERIOD_SIZE:-128}"
|
||||||
PHOENIX_BUFFER_SIZE="${PHOENIX_BUFFER_SIZE:-512}"
|
PHOENIX_BUFFER_SIZE="${PHOENIX_BUFFER_SIZE:-512}"
|
||||||
PHOENIX_GLOBAL_CONFIG_PATH="${PHOENIX_GLOBAL_CONFIG_PATH:-/home/analyzer/.config/phoenix/global-config.json}"
|
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_ANALYZER="${PHOENIX_RECORDINGS_DIR_ANALYZER:-/home/analyzer/Aufnahmen}"
|
||||||
PHOENIX_RECORDINGS_DIR_VOLUMIO="${PHOENIX_RECORDINGS_DIR_VOLUMIO:-/home/volumio/Aufnahmen}"
|
PHOENIX_RECORDINGS_DIR_VOLUMIO="${PHOENIX_RECORDINGS_DIR_VOLUMIO:-/home/volumio/Aufnahmen}"
|
||||||
PHOENIX_LR_FRAC_DELAY_ENABLED="${PHOENIX_LR_FRAC_DELAY_ENABLED:-true}"
|
PHOENIX_LR_FRAC_DELAY_ENABLED="${PHOENIX_LR_FRAC_DELAY_ENABLED:-true}"
|
||||||
@@ -83,6 +85,8 @@ else
|
|||||||
PHOENIX_PERIOD_SIZE="$PHOENIX_PERIOD_SIZE" \
|
PHOENIX_PERIOD_SIZE="$PHOENIX_PERIOD_SIZE" \
|
||||||
PHOENIX_BUFFER_SIZE="$PHOENIX_BUFFER_SIZE" \
|
PHOENIX_BUFFER_SIZE="$PHOENIX_BUFFER_SIZE" \
|
||||||
PHOENIX_GLOBAL_CONFIG_PATH="$PHOENIX_GLOBAL_CONFIG_PATH" \
|
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_ANALYZER="$PHOENIX_RECORDINGS_DIR_ANALYZER" \
|
||||||
PHOENIX_RECORDINGS_DIR_VOLUMIO="$PHOENIX_RECORDINGS_DIR_VOLUMIO" \
|
PHOENIX_RECORDINGS_DIR_VOLUMIO="$PHOENIX_RECORDINGS_DIR_VOLUMIO" \
|
||||||
PHOENIX_LR_FRAC_DELAY_ENABLED="$PHOENIX_LR_FRAC_DELAY_ENABLED" \
|
PHOENIX_LR_FRAC_DELAY_ENABLED="$PHOENIX_LR_FRAC_DELAY_ENABLED" \
|
||||||
@@ -97,6 +101,8 @@ else
|
|||||||
PHOENIX_PERIOD_SIZE="$PHOENIX_PERIOD_SIZE" \
|
PHOENIX_PERIOD_SIZE="$PHOENIX_PERIOD_SIZE" \
|
||||||
PHOENIX_BUFFER_SIZE="$PHOENIX_BUFFER_SIZE" \
|
PHOENIX_BUFFER_SIZE="$PHOENIX_BUFFER_SIZE" \
|
||||||
PHOENIX_GLOBAL_CONFIG_PATH="$PHOENIX_GLOBAL_CONFIG_PATH" \
|
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_ANALYZER="$PHOENIX_RECORDINGS_DIR_ANALYZER" \
|
||||||
PHOENIX_RECORDINGS_DIR_VOLUMIO="$PHOENIX_RECORDINGS_DIR_VOLUMIO" \
|
PHOENIX_RECORDINGS_DIR_VOLUMIO="$PHOENIX_RECORDINGS_DIR_VOLUMIO" \
|
||||||
PHOENIX_LR_FRAC_DELAY_ENABLED="$PHOENIX_LR_FRAC_DELAY_ENABLED" \
|
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 \
|
--lang=de-DE --disable-translate --disable-features=Translate,TranslateUI \
|
||||||
--memory-pressure-off \
|
--memory-pressure-off \
|
||||||
--max-active-webgl-contexts=1 \
|
--max-active-webgl-contexts=1 \
|
||||||
http://localhost/index.html \
|
'http://localhost/index.html?kiosk=1' \
|
||||||
>>"$LOG" 2>&1
|
>>"$LOG" 2>&1
|
||||||
|
|
||||||
echo "=== $(date): session.sh END ==="
|
echo "=== $(date): session.sh END ==="
|
||||||
|
|||||||
+11
-12
@@ -24,6 +24,8 @@ PHOENIX_SAMPLE_RATE="${PHOENIX_SAMPLE_RATE:-48000}"
|
|||||||
PHOENIX_PERIOD_SIZE="${PHOENIX_PERIOD_SIZE:-128}"
|
PHOENIX_PERIOD_SIZE="${PHOENIX_PERIOD_SIZE:-128}"
|
||||||
PHOENIX_BUFFER_SIZE="${PHOENIX_BUFFER_SIZE:-512}"
|
PHOENIX_BUFFER_SIZE="${PHOENIX_BUFFER_SIZE:-512}"
|
||||||
PHOENIX_GLOBAL_CONFIG_PATH="${PHOENIX_GLOBAL_CONFIG_PATH:-/home/volumio/.config/phoenix/global-config.json}"
|
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_ANALYZER="${PHOENIX_RECORDINGS_DIR_ANALYZER:-/home/analyzer/Aufnahmen}"
|
||||||
PHOENIX_RECORDINGS_DIR_VOLUMIO="${PHOENIX_RECORDINGS_DIR_VOLUMIO:-/home/volumio/Aufnahmen}"
|
PHOENIX_RECORDINGS_DIR_VOLUMIO="${PHOENIX_RECORDINGS_DIR_VOLUMIO:-/home/volumio/Aufnahmen}"
|
||||||
PHOENIX_LR_FRAC_DELAY_ENABLED="${PHOENIX_LR_FRAC_DELAY_ENABLED:-true}"
|
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}"
|
CHROME_BIN="${CHROME_BIN:-/usr/bin/chromium-browser}"
|
||||||
EXT_VK="${EXT_VK:-/data/volumiokioskextensions/VirtualKeyboard}"
|
EXT_VK="${EXT_VK:-/data/volumiokioskextensions/VirtualKeyboard}"
|
||||||
EXT_TOGGLE="${EXT_TOGGLE:-/opt/analyzer/toggle-ext}"
|
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}"
|
VOLUMIO_URL="${VOLUMIO_URL:-http://localhost:3000}"
|
||||||
|
|
||||||
mkdir -p "$(dirname "$LOG")"
|
mkdir -p "$(dirname "$LOG")"
|
||||||
@@ -96,6 +98,8 @@ else
|
|||||||
PHOENIX_PERIOD_SIZE="$PHOENIX_PERIOD_SIZE" \
|
PHOENIX_PERIOD_SIZE="$PHOENIX_PERIOD_SIZE" \
|
||||||
PHOENIX_BUFFER_SIZE="$PHOENIX_BUFFER_SIZE" \
|
PHOENIX_BUFFER_SIZE="$PHOENIX_BUFFER_SIZE" \
|
||||||
PHOENIX_GLOBAL_CONFIG_PATH="$PHOENIX_GLOBAL_CONFIG_PATH" \
|
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_ANALYZER="$PHOENIX_RECORDINGS_DIR_ANALYZER" \
|
||||||
PHOENIX_RECORDINGS_DIR_VOLUMIO="$PHOENIX_RECORDINGS_DIR_VOLUMIO" \
|
PHOENIX_RECORDINGS_DIR_VOLUMIO="$PHOENIX_RECORDINGS_DIR_VOLUMIO" \
|
||||||
PHOENIX_LR_FRAC_DELAY_ENABLED="$PHOENIX_LR_FRAC_DELAY_ENABLED" \
|
PHOENIX_LR_FRAC_DELAY_ENABLED="$PHOENIX_LR_FRAC_DELAY_ENABLED" \
|
||||||
@@ -112,6 +116,8 @@ else
|
|||||||
PHOENIX_PERIOD_SIZE="$PHOENIX_PERIOD_SIZE" \
|
PHOENIX_PERIOD_SIZE="$PHOENIX_PERIOD_SIZE" \
|
||||||
PHOENIX_BUFFER_SIZE="$PHOENIX_BUFFER_SIZE" \
|
PHOENIX_BUFFER_SIZE="$PHOENIX_BUFFER_SIZE" \
|
||||||
PHOENIX_GLOBAL_CONFIG_PATH="$PHOENIX_GLOBAL_CONFIG_PATH" \
|
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_ANALYZER="$PHOENIX_RECORDINGS_DIR_ANALYZER" \
|
||||||
PHOENIX_RECORDINGS_DIR_VOLUMIO="$PHOENIX_RECORDINGS_DIR_VOLUMIO" \
|
PHOENIX_RECORDINGS_DIR_VOLUMIO="$PHOENIX_RECORDINGS_DIR_VOLUMIO" \
|
||||||
PHOENIX_LR_FRAC_DELAY_ENABLED="$PHOENIX_LR_FRAC_DELAY_ENABLED" \
|
PHOENIX_LR_FRAC_DELAY_ENABLED="$PHOENIX_LR_FRAC_DELAY_ENABLED" \
|
||||||
@@ -159,10 +165,6 @@ else
|
|||||||
chmod 700 "$CHROME_USER_DATA_DIR"
|
chmod 700 "$CHROME_USER_DATA_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
pkill -9 -x chromium || true
|
|
||||||
pkill -9 -f "chromium.*--kiosk" || true
|
|
||||||
sleep 0.3
|
|
||||||
|
|
||||||
EXTENSIONS=()
|
EXTENSIONS=()
|
||||||
EXT_VK_RESOLVED="$(resolve_extension_dir "$EXT_VK" \
|
EXT_VK_RESOLVED="$(resolve_extension_dir "$EXT_VK" \
|
||||||
"$PHOENIX_DIR/VirtualKeyboard" \
|
"$PHOENIX_DIR/VirtualKeyboard" \
|
||||||
@@ -210,21 +212,18 @@ CHROME_CMD=(
|
|||||||
--disable-features=Translate,TranslateUI
|
--disable-features=Translate,TranslateUI
|
||||||
--memory-pressure-off
|
--memory-pressure-off
|
||||||
--max-active-webgl-contexts=1
|
--max-active-webgl-contexts=1
|
||||||
"$ANALYZER_URL"
|
|
||||||
"$VOLUMIO_URL"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if [ -n "$EXTENSION_ARG" ]; then
|
if [ -n "$EXTENSION_ARG" ]; then
|
||||||
CHROME_CMD+=(--load-extension="$EXTENSION_ARG")
|
CHROME_CMD+=(--load-extension="$EXTENSION_ARG")
|
||||||
fi
|
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 &
|
sudo -u "$KIOSK_USER" "${CHROME_CMD[@]}" >>"$LOG" 2>&1 &
|
||||||
CHROME_PID=$!
|
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"
|
wait "$CHROME_PID"
|
||||||
echo "=== $(date): session_volumio.sh END ==="
|
echo "=== $(date): session_volumio.sh END ==="
|
||||||
|
|||||||
+538
-335
File diff suppressed because it is too large
Load Diff
@@ -14,8 +14,10 @@ pub struct PhoenixConfig {
|
|||||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||||
pub buffer_size: u32,
|
pub buffer_size: u32,
|
||||||
pub global_config_path: PathBuf,
|
pub global_config_path: PathBuf,
|
||||||
|
pub rta_config_path: PathBuf,
|
||||||
pub frontend_presets_path: PathBuf,
|
pub frontend_presets_path: PathBuf,
|
||||||
pub frontend_layouts_path: PathBuf,
|
pub frontend_layouts_path: PathBuf,
|
||||||
|
pub kiosk_browser_config_path: PathBuf,
|
||||||
pub recordings_dir_analyzer: PathBuf,
|
pub recordings_dir_analyzer: PathBuf,
|
||||||
pub recordings_dir_volumio: PathBuf,
|
pub recordings_dir_volumio: PathBuf,
|
||||||
pub max_recording_upload_bytes: u64,
|
pub max_recording_upload_bytes: u64,
|
||||||
@@ -53,6 +55,11 @@ impl PhoenixConfig {
|
|||||||
.map(|v| PathBuf::from(v.trim()))
|
.map(|v| PathBuf::from(v.trim()))
|
||||||
.filter(|v| !v.as_os_str().is_empty())
|
.filter(|v| !v.as_os_str().is_empty())
|
||||||
.unwrap_or_else(default_global_config_path),
|
.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")
|
frontend_presets_path: std::env::var("PHOENIX_FRONTEND_PRESETS_PATH")
|
||||||
.ok()
|
.ok()
|
||||||
.map(|v| PathBuf::from(v.trim()))
|
.map(|v| PathBuf::from(v.trim()))
|
||||||
@@ -63,6 +70,11 @@ impl PhoenixConfig {
|
|||||||
.map(|v| PathBuf::from(v.trim()))
|
.map(|v| PathBuf::from(v.trim()))
|
||||||
.filter(|v| !v.as_os_str().is_empty())
|
.filter(|v| !v.as_os_str().is_empty())
|
||||||
.unwrap_or_else(default_frontend_layouts_path),
|
.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")
|
recordings_dir_analyzer: std::env::var("PHOENIX_RECORDINGS_DIR_ANALYZER")
|
||||||
.ok()
|
.ok()
|
||||||
.map(|v| PathBuf::from(v.trim()))
|
.map(|v| PathBuf::from(v.trim()))
|
||||||
@@ -128,6 +140,17 @@ fn default_global_config_path() -> PathBuf {
|
|||||||
PathBuf::from("phoenix-global-config.json")
|
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 {
|
fn default_frontend_presets_path() -> PathBuf {
|
||||||
if let Some(home) = std::env::var_os("HOME") {
|
if let Some(home) = std::env::var_os("HOME") {
|
||||||
let mut path = PathBuf::from(home);
|
let mut path = PathBuf::from(home);
|
||||||
@@ -150,6 +173,17 @@ fn default_frontend_layouts_path() -> PathBuf {
|
|||||||
PathBuf::from("phoenix-frontend-layouts.json")
|
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 {
|
fn default_recordings_dir_analyzer() -> PathBuf {
|
||||||
PathBuf::from("/home/analyzer/Aufnahmen")
|
PathBuf::from("/home/analyzer/Aufnahmen")
|
||||||
}
|
}
|
||||||
|
|||||||
+135
-14
@@ -9,39 +9,73 @@ pub struct CorrelationMeter {
|
|||||||
sample_rate: u32,
|
sample_rate: u32,
|
||||||
response_seconds: f32,
|
response_seconds: f32,
|
||||||
alpha: f64,
|
alpha: f64,
|
||||||
|
gate_alpha: f64,
|
||||||
|
silence_threshold_db: f32,
|
||||||
|
silence_threshold_power: f64,
|
||||||
power_l: f64,
|
power_l: f64,
|
||||||
power_r: f64,
|
power_r: f64,
|
||||||
cross_power: f64,
|
cross_power: f64,
|
||||||
|
gate_power: f64,
|
||||||
value: f32,
|
value: f32,
|
||||||
negative_peak: f32,
|
negative_peak: f32,
|
||||||
reset_token: u64,
|
reset_token: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CorrelationMeter {
|
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 {
|
let mut meter = Self {
|
||||||
sample_rate: 0,
|
sample_rate: 0,
|
||||||
response_seconds: 0.0,
|
response_seconds: 0.0,
|
||||||
alpha: 1.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_l: 0.0,
|
||||||
power_r: 0.0,
|
power_r: 0.0,
|
||||||
cross_power: 0.0,
|
cross_power: 0.0,
|
||||||
|
gate_power: 0.0,
|
||||||
value: 0.0,
|
value: 0.0,
|
||||||
negative_peak: 0.0,
|
negative_peak: 0.0,
|
||||||
reset_token,
|
reset_token,
|
||||||
};
|
};
|
||||||
meter.configure(sample_rate, response_seconds, reset_token);
|
meter.configure(
|
||||||
|
sample_rate,
|
||||||
|
response_seconds,
|
||||||
|
silence_threshold_db,
|
||||||
|
reset_token,
|
||||||
|
);
|
||||||
meter
|
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 sample_rate = sample_rate.max(8_000);
|
||||||
let response_seconds = normalize_response_seconds(response_seconds);
|
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 {
|
if self.sample_rate != sample_rate || self.response_seconds != response_seconds {
|
||||||
self.sample_rate = sample_rate;
|
self.sample_rate = sample_rate;
|
||||||
self.response_seconds = response_seconds;
|
self.response_seconds = response_seconds;
|
||||||
self.alpha =
|
self.alpha =
|
||||||
1.0 - (-1.0 / (f64::from(sample_rate) * f64::from(response_seconds))).exp();
|
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 {
|
if self.reset_token != reset_token {
|
||||||
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_l += self.alpha * (l * l - self.power_l);
|
||||||
self.power_r += self.alpha * (r * r - self.power_r);
|
self.power_r += self.alpha * (r * r - self.power_r);
|
||||||
self.cross_power += self.alpha * (l * r - self.cross_power);
|
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
|
// Below the configured mono RMS threshold the correlation indication
|
||||||
// useful phase measurement and should settle at the neutral position.
|
// settles at its neutral position.
|
||||||
const MIN_POWER: f64 = 1.0e-10;
|
const MIN_POWER: f64 = 1.0e-10;
|
||||||
let denominator = (self.power_l * self.power_r).sqrt();
|
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
|
(self.cross_power / denominator).clamp(-1.0, 1.0) as f32
|
||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
@@ -80,10 +120,22 @@ impl CorrelationMeter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn normalize_response_seconds(value: f32) -> f32 {
|
pub fn normalize_response_seconds(value: f32) -> f32 {
|
||||||
if value.is_finite() && value >= 1.75 {
|
if !value.is_finite() || value <= 0.0 {
|
||||||
2.5
|
|
||||||
} else {
|
|
||||||
1.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::PI, -1.0),
|
||||||
(std::f32::consts::FRAC_PI_2, 0.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| {
|
run_signal(&mut meter, 5, |index| {
|
||||||
let angle = phase_step * index as f32;
|
let angle = phase_step * index as f32;
|
||||||
(angle.sin(), (angle + phase).sin())
|
(angle.sin(), (angle + phase).sin())
|
||||||
@@ -127,8 +179,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn response_time_and_peak_reset_are_deterministic() {
|
fn response_time_and_peak_reset_are_deterministic() {
|
||||||
let sample_rate = 48_000;
|
let sample_rate = 48_000;
|
||||||
let mut fast = CorrelationMeter::new(sample_rate, 1.0, 0);
|
let mut fast = CorrelationMeter::new(sample_rate, 1.0, -75.0, 0);
|
||||||
let mut slow = CorrelationMeter::new(sample_rate, 2.5, 0);
|
let mut slow = CorrelationMeter::new(sample_rate, 2.5, -75.0, 0);
|
||||||
for index in 0..sample_rate as usize {
|
for index in 0..sample_rate as usize {
|
||||||
let sample = if index & 1 == 0 { 0.5 } else { -0.5 };
|
let sample = if index & 1 == 0 { 0.5 } else { -0.5 };
|
||||||
fast.process(sample, sample);
|
fast.process(sample, sample);
|
||||||
@@ -141,13 +193,82 @@ mod tests {
|
|||||||
(sample, -sample)
|
(sample, -sample)
|
||||||
});
|
});
|
||||||
assert!(fast.negative_peak() < -0.7);
|
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);
|
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]
|
#[test]
|
||||||
fn silence_and_single_channel_are_neutral() {
|
fn silence_and_single_channel_are_neutral() {
|
||||||
let mut meter = CorrelationMeter::new(48_000, 1.0, 0);
|
let mut meter = CorrelationMeter::new(48_000, 1.0, -75.0, 0);
|
||||||
run_signal(&mut meter, 2, |_| (0.0, 0.0));
|
run_signal(&mut meter, 2, |_| (0.0, 0.0));
|
||||||
assert_eq!(meter.value(), 0.0);
|
assert_eq!(meter.value(), 0.0);
|
||||||
assert_eq!(meter.negative_peak(), 0.0);
|
assert_eq!(meter.negative_peak(), 0.0);
|
||||||
|
|||||||
+12
@@ -3,10 +3,14 @@ mod config;
|
|||||||
mod correlation;
|
mod correlation;
|
||||||
mod goniometer;
|
mod goniometer;
|
||||||
mod model;
|
mod model;
|
||||||
|
mod phase_wheel;
|
||||||
mod ppm;
|
mod ppm;
|
||||||
|
mod rms;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod rta;
|
mod rta;
|
||||||
mod state;
|
mod state;
|
||||||
|
mod true_peak;
|
||||||
|
mod vu;
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
@@ -49,6 +53,14 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
"/api/v1/frontend-layouts",
|
"/api/v1/frontend-layouts",
|
||||||
get(routes::get_frontend_layouts).post(routes::set_frontend_layout),
|
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(
|
.route(
|
||||||
"/api/v1/update/download",
|
"/api/v1/update/download",
|
||||||
post(routes::download_online_update),
|
post(routes::download_online_update),
|
||||||
|
|||||||
+27
-5
@@ -19,6 +19,9 @@ pub struct ServiceStatus {
|
|||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
pub struct RtaFrame {
|
pub struct RtaFrame {
|
||||||
pub engine: String,
|
pub engine: String,
|
||||||
|
pub filterbank: String,
|
||||||
|
pub detector: String,
|
||||||
|
pub response: String,
|
||||||
pub bands_avg: Vec<f32>,
|
pub bands_avg: Vec<f32>,
|
||||||
pub bands_peak: Vec<f32>,
|
pub bands_peak: Vec<f32>,
|
||||||
pub centers: Vec<f32>,
|
pub centers: Vec<f32>,
|
||||||
@@ -51,6 +54,8 @@ pub struct WaveEnvFrame {
|
|||||||
#[serde(default, rename_all = "camelCase")]
|
#[serde(default, rename_all = "camelCase")]
|
||||||
pub struct PhoenixRtaConfig {
|
pub struct PhoenixRtaConfig {
|
||||||
pub engine: String,
|
pub engine: String,
|
||||||
|
pub filterbank: String,
|
||||||
|
pub detector: String,
|
||||||
pub fft_size: u32,
|
pub fft_size: u32,
|
||||||
pub mono_input: bool,
|
pub mono_input: bool,
|
||||||
pub lr_fractional_delay_enabled: bool,
|
pub lr_fractional_delay_enabled: bool,
|
||||||
@@ -63,16 +68,21 @@ pub struct PhoenixRtaConfig {
|
|||||||
pub tau_slow: f32,
|
pub tau_slow: f32,
|
||||||
pub integration: String,
|
pub integration: String,
|
||||||
pub layout: 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_l: f32,
|
||||||
pub input_offset_db_r: f32,
|
pub input_offset_db_r: f32,
|
||||||
pub ppm_din_attack_ms: f32,
|
pub ppm_din_attack_ms: f32,
|
||||||
pub ppm_din_decay_db_per_s: f32,
|
pub ppm_din_decay_db_per_s: f32,
|
||||||
pub ppm_din_fast_attack: bool,
|
|
||||||
pub ppm_ebu_attack_ms: f32,
|
pub ppm_ebu_attack_ms: f32,
|
||||||
pub ppm_ebu_decay_db_per_s: f32,
|
pub ppm_ebu_decay_db_per_s: f32,
|
||||||
|
pub rms_integration: String,
|
||||||
pub lufs_i_window_min: u32,
|
pub lufs_i_window_min: u32,
|
||||||
pub lufs_i_norm_enabled: bool,
|
pub lufs_i_norm_enabled: bool,
|
||||||
pub correlation_response_s: f32,
|
pub correlation_response_s: f32,
|
||||||
|
pub correlation_silence_threshold_rms_dbfs: f32,
|
||||||
pub correlation_reset_token: u64,
|
pub correlation_reset_token: u64,
|
||||||
pub xy_points: u32,
|
pub xy_points: u32,
|
||||||
}
|
}
|
||||||
@@ -81,6 +91,8 @@ impl Default for PhoenixRtaConfig {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
engine: "iir".to_string(),
|
engine: "iir".to_string(),
|
||||||
|
filterbank: "legacy".to_string(),
|
||||||
|
detector: "average".to_string(),
|
||||||
fft_size: 8192,
|
fft_size: 8192,
|
||||||
mono_input: false,
|
mono_input: false,
|
||||||
lr_fractional_delay_enabled: true,
|
lr_fractional_delay_enabled: true,
|
||||||
@@ -89,20 +101,25 @@ impl Default for PhoenixRtaConfig {
|
|||||||
freq_range: "norm".to_string(),
|
freq_range: "norm".to_string(),
|
||||||
weighting: "z".to_string(),
|
weighting: "z".to_string(),
|
||||||
order: 6,
|
order: 6,
|
||||||
tau_fast: 0.12,
|
tau_fast: 0.125,
|
||||||
tau_slow: 1.0,
|
tau_slow: 1.0,
|
||||||
integration: "fast".to_string(),
|
integration: "fast".to_string(),
|
||||||
layout: "rtw".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_l: -5.0,
|
||||||
input_offset_db_r: -5.0,
|
input_offset_db_r: -5.0,
|
||||||
ppm_din_attack_ms: 10.0,
|
ppm_din_attack_ms: 10.0,
|
||||||
ppm_din_decay_db_per_s: 20.0 / 1.5,
|
ppm_din_decay_db_per_s: 20.0 / 1.5,
|
||||||
ppm_din_fast_attack: false,
|
|
||||||
ppm_ebu_attack_ms: 10.0,
|
ppm_ebu_attack_ms: 10.0,
|
||||||
ppm_ebu_decay_db_per_s: 24.0 / 2.8,
|
ppm_ebu_decay_db_per_s: 24.0 / 2.8,
|
||||||
|
rms_integration: "fast".to_string(),
|
||||||
lufs_i_window_min: 4,
|
lufs_i_window_min: 4,
|
||||||
lufs_i_norm_enabled: false,
|
lufs_i_norm_enabled: false,
|
||||||
correlation_response_s: 1.0,
|
correlation_response_s: 1.0,
|
||||||
|
correlation_silence_threshold_rms_dbfs: -75.0,
|
||||||
correlation_reset_token: 0,
|
correlation_reset_token: 0,
|
||||||
xy_points: 1024,
|
xy_points: 1024,
|
||||||
}
|
}
|
||||||
@@ -125,9 +142,9 @@ pub struct PhoenixGlobalConfig {
|
|||||||
pub panel_dividers_enabled: bool,
|
pub panel_dividers_enabled: bool,
|
||||||
pub ppm_din_attack_ms: f32,
|
pub ppm_din_attack_ms: f32,
|
||||||
pub ppm_din_decay_db_per_s: f32,
|
pub ppm_din_decay_db_per_s: f32,
|
||||||
pub ppm_din_fast_attack: bool,
|
|
||||||
pub ppm_ebu_attack_ms: f32,
|
pub ppm_ebu_attack_ms: f32,
|
||||||
pub ppm_ebu_decay_db_per_s: f32,
|
pub ppm_ebu_decay_db_per_s: f32,
|
||||||
|
pub rms_integration: String,
|
||||||
pub lufs_i_window_min: u32,
|
pub lufs_i_window_min: u32,
|
||||||
pub lufs_i_norm_enabled: bool,
|
pub lufs_i_norm_enabled: bool,
|
||||||
pub ppm_din_loudness_boxes: bool,
|
pub ppm_din_loudness_boxes: bool,
|
||||||
@@ -194,9 +211,9 @@ impl Default for PhoenixGlobalConfig {
|
|||||||
panel_dividers_enabled: true,
|
panel_dividers_enabled: true,
|
||||||
ppm_din_attack_ms: 10.0,
|
ppm_din_attack_ms: 10.0,
|
||||||
ppm_din_decay_db_per_s: 20.0 / 1.5,
|
ppm_din_decay_db_per_s: 20.0 / 1.5,
|
||||||
ppm_din_fast_attack: false,
|
|
||||||
ppm_ebu_attack_ms: 10.0,
|
ppm_ebu_attack_ms: 10.0,
|
||||||
ppm_ebu_decay_db_per_s: 24.0 / 2.8,
|
ppm_ebu_decay_db_per_s: 24.0 / 2.8,
|
||||||
|
rms_integration: "fast".to_string(),
|
||||||
lufs_i_window_min: 4,
|
lufs_i_window_min: 4,
|
||||||
lufs_i_norm_enabled: false,
|
lufs_i_norm_enabled: false,
|
||||||
ppm_din_loudness_boxes: true,
|
ppm_din_loudness_boxes: true,
|
||||||
@@ -264,6 +281,11 @@ pub struct MeterFrame {
|
|||||||
pub period_size: u32,
|
pub period_size: u32,
|
||||||
pub correlation: f32,
|
pub correlation: f32,
|
||||||
pub correlation_negative_peak: 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_l: f32,
|
||||||
pub rms_r: f32,
|
pub rms_r: f32,
|
||||||
pub vu_l: f32,
|
pub vu_l: f32,
|
||||||
|
|||||||
@@ -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
@@ -2,9 +2,10 @@
|
|||||||
//!
|
//!
|
||||||
//! The detector is deliberately independent of ALSA block boundaries. Its two
|
//! The detector is deliberately independent of ALSA block boundaries. Its two
|
||||||
//! attack branches are calibrated against the 5 kHz tone-burst response in EBU
|
//! 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
|
//! Tech 3205-E. DIN uses the DIN 45406 / IEC 60268-10 profile (10 ms
|
||||||
//! integration, 20 dB return in 1.5 s). The optional DIN sample mode is kept
|
//! integration, 20 dB return in 1.5 s). Both standards define the normal
|
||||||
//! separate and must never be labelled as a standards-compliant DIN reading.
|
//! 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))]
|
#![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)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum PpmStandard {
|
pub enum PpmStandard {
|
||||||
Din,
|
Din,
|
||||||
DinSample,
|
|
||||||
EbuTypeIib,
|
EbuTypeIib,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PpmStandard {
|
impl PpmStandard {
|
||||||
fn return_db_per_second(self) -> f32 {
|
fn return_db_per_second(self) -> f32 {
|
||||||
match self {
|
match self {
|
||||||
Self::Din | Self::DinSample => 20.0 / 1.5,
|
Self::Din => 20.0 / 1.5,
|
||||||
Self::EbuTypeIib => 24.0 / 2.8,
|
Self::EbuTypeIib => 24.0 / 2.8,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn uses_sample_attack(self) -> bool {
|
|
||||||
matches!(self, Self::DinSample)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default)]
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
@@ -61,11 +57,7 @@ pub struct PpmDetector {
|
|||||||
impl PpmDetector {
|
impl PpmDetector {
|
||||||
pub fn new(sample_rate: u32, standard: PpmStandard) -> Self {
|
pub fn new(sample_rate: u32, standard: PpmStandard) -> Self {
|
||||||
let sr = sample_rate.max(8_000);
|
let sr = sample_rate.max(8_000);
|
||||||
let detector_rate = if standard.uses_sample_attack() {
|
let detector_rate = (sr * OVERSAMPLE as u32) as f32;
|
||||||
sr as f32
|
|
||||||
} else {
|
|
||||||
(sr * OVERSAMPLE as u32) as f32
|
|
||||||
};
|
|
||||||
let coeff = |tau_s: f32| (-1.0 / (detector_rate * tau_s)).exp();
|
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));
|
let release_coeff = 10.0f32.powf(-standard.return_db_per_second() / (20.0 * detector_rate));
|
||||||
Self {
|
Self {
|
||||||
@@ -91,31 +83,10 @@ impl PpmDetector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn process(&mut self, left: f32, right: f32) -> (f32, f32) {
|
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_l[self.raw_pos] = left;
|
||||||
self.raw_r[self.raw_pos] = right;
|
self.raw_r[self.raw_pos] = right;
|
||||||
self.raw_pos = (self.raw_pos + 1) % INTERP_TAPS;
|
self.raw_pos = (self.raw_pos + 1) % INTERP_TAPS;
|
||||||
|
|
||||||
let standard = self.standard;
|
|
||||||
let fast_coeff = self.fast_coeff;
|
let fast_coeff = self.fast_coeff;
|
||||||
let slow_coeff = self.slow_coeff;
|
let slow_coeff = self.slow_coeff;
|
||||||
let release_coeff = self.release_coeff;
|
let release_coeff = self.release_coeff;
|
||||||
@@ -131,7 +102,6 @@ impl PpmDetector {
|
|||||||
Self::process_channel(
|
Self::process_channel(
|
||||||
&mut self.left,
|
&mut self.left,
|
||||||
l.abs(),
|
l.abs(),
|
||||||
standard,
|
|
||||||
fast_coeff,
|
fast_coeff,
|
||||||
slow_coeff,
|
slow_coeff,
|
||||||
release_coeff,
|
release_coeff,
|
||||||
@@ -139,7 +109,6 @@ impl PpmDetector {
|
|||||||
Self::process_channel(
|
Self::process_channel(
|
||||||
&mut self.right,
|
&mut self.right,
|
||||||
r.abs(),
|
r.abs(),
|
||||||
standard,
|
|
||||||
fast_coeff,
|
fast_coeff,
|
||||||
slow_coeff,
|
slow_coeff,
|
||||||
release_coeff,
|
release_coeff,
|
||||||
@@ -183,18 +152,10 @@ impl PpmDetector {
|
|||||||
fn process_channel(
|
fn process_channel(
|
||||||
state: &mut PpmChannel,
|
state: &mut PpmChannel,
|
||||||
input: f32,
|
input: f32,
|
||||||
standard: PpmStandard,
|
|
||||||
fast_coeff: f32,
|
fast_coeff: f32,
|
||||||
slow_coeff: f32,
|
slow_coeff: f32,
|
||||||
release_coeff: f32,
|
release_coeff: f32,
|
||||||
) -> 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 {
|
state.fast = if input > state.fast {
|
||||||
fast_coeff * state.fast + (1.0 - fast_coeff) * input
|
fast_coeff * state.fast + (1.0 - fast_coeff) * input
|
||||||
} else {
|
} 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]
|
#[test]
|
||||||
fn ebu_return_time_is_24_db_in_2_8_seconds() {
|
fn ebu_return_time_is_24_db_in_2_8_seconds() {
|
||||||
assert_return_time(PpmStandard::EbuTypeIib, 24.0, 2.8, 0.02);
|
assert_return_time(PpmStandard::EbuTypeIib, 24.0, 2.8, 0.02);
|
||||||
|
|||||||
+213
@@ -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
@@ -7,10 +7,12 @@ use axum::{
|
|||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{
|
use std::{
|
||||||
|
collections::BTreeMap,
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
process::Stdio,
|
process::Stdio,
|
||||||
|
sync::atomic::{AtomicU64, Ordering},
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
time::{SystemTime, UNIX_EPOCH},
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
@@ -23,6 +25,9 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ONLINE_UPDATE_ZIP_URL: &str = "https://webshare.casaderoll.de/share/Phoenix.zip";
|
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 {
|
fn stable_script_command(program: &str) -> Command {
|
||||||
let mut command = Command::new(program);
|
let mut command = Command::new(program);
|
||||||
@@ -42,6 +47,39 @@ pub struct FrontendPresetSavePayload {
|
|||||||
pub config: serde_json::Value,
|
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> {
|
pub async fn health() -> Json<serde_json::Value> {
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
"ok": true,
|
"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 {
|
pub async fn download_online_update() -> Response {
|
||||||
let phoenix_root = match resolve_phoenix_root_dir() {
|
let phoenix_root = match resolve_phoenix_root_dir() {
|
||||||
Ok(path) => path,
|
Ok(path) => path,
|
||||||
@@ -814,6 +1064,32 @@ async fn read_frontend_layouts(
|
|||||||
Ok(parsed.as_object().cloned().unwrap_or_default())
|
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(
|
async fn persist_frontend_presets(
|
||||||
config: &crate::config::PhoenixConfig,
|
config: &crate::config::PhoenixConfig,
|
||||||
presets: &serde_json::Map<String, serde_json::Value>,
|
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())
|
format!("failed to create frontend preset dir {}", parent.display())
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
let json = serde_json::to_vec_pretty(&serde_json::Value::Object(presets.clone()))?;
|
persist_json_atomic(&path, &serde_json::Value::Object(presets.clone())).await
|
||||||
fs::write(&path, json)
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("failed to write frontend presets {}", path.display()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn persist_frontend_layouts(
|
async fn persist_frontend_layouts(
|
||||||
@@ -841,10 +1113,44 @@ async fn persist_frontend_layouts(
|
|||||||
format!("failed to create frontend layout dir {}", parent.display())
|
format!("failed to create frontend layout dir {}", parent.display())
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
let json = serde_json::to_vec_pretty(&serde_json::Value::Object(layouts.clone()))?;
|
persist_json_atomic(&path, &serde_json::Value::Object(layouts.clone())).await
|
||||||
fs::write(&path, json)
|
}
|
||||||
|
|
||||||
|
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
|
.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(())
|
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) {
|
async fn metrics_ws_inner(mut socket: axum::extract::ws::WebSocket, state: AppState) {
|
||||||
let mut rx = state.subscribe_metrics();
|
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 {
|
loop {
|
||||||
ticker.tick().await;
|
|
||||||
let Some(mut latest) = recv_latest_meter_frame(&mut rx).await else {
|
let Some(mut latest) = recv_latest_meter_frame(&mut rx).await else {
|
||||||
break;
|
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();
|
let mut frame = (*latest).clone();
|
||||||
|
frame.tp_l = tp_l;
|
||||||
|
frame.tp_r = tp_r;
|
||||||
strip_visual_payloads(&mut frame);
|
strip_visual_payloads(&mut frame);
|
||||||
|
|
||||||
let payload = match serde_json::to_string(&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) {
|
async fn visuals_ws_inner(mut socket: axum::extract::ws::WebSocket, state: AppState) {
|
||||||
let mut rx = state.subscribe_metrics();
|
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 {
|
loop {
|
||||||
ticker.tick().await;
|
|
||||||
let Some(mut latest) = recv_latest_meter_frame(&mut rx).await else {
|
let Some(mut latest) = recv_latest_meter_frame(&mut rx).await else {
|
||||||
return;
|
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) {
|
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 {
|
loop {
|
||||||
let mut latest = loop {
|
let mut latest = match rx.recv().await {
|
||||||
match rx.recv().await {
|
Ok(frame) => frame,
|
||||||
Ok(frame) => {
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||||
if frame.spectro.is_some() {
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
|
||||||
break frame;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match rx.try_recv() {
|
match rx.try_recv() {
|
||||||
Ok(frame) => {
|
Ok(frame) => latest = frame,
|
||||||
if frame.spectro.is_some() {
|
|
||||||
latest = frame;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue,
|
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue,
|
||||||
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => break,
|
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => break,
|
||||||
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => return,
|
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
|
if socket
|
||||||
.send(axum::extract::ws::Message::Binary(payload))
|
.send(axum::extract::ws::Message::Binary(payload))
|
||||||
.await
|
.await
|
||||||
@@ -1093,10 +1391,16 @@ async fn recv_latest_meter_frame(
|
|||||||
fn drain_latest_meter_frame(
|
fn drain_latest_meter_frame(
|
||||||
rx: &mut tokio::sync::broadcast::Receiver<Arc<MeterFrame>>,
|
rx: &mut tokio::sync::broadcast::Receiver<Arc<MeterFrame>>,
|
||||||
latest: &mut Arc<MeterFrame>,
|
latest: &mut Arc<MeterFrame>,
|
||||||
) {
|
) -> (f32, f32) {
|
||||||
|
let mut tp_l = latest.tp_l;
|
||||||
|
let mut tp_r = latest.tp_r;
|
||||||
loop {
|
loop {
|
||||||
match rx.try_recv() {
|
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::Empty) => break,
|
||||||
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(skipped)) => {
|
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(skipped)) => {
|
||||||
warn!("metrics drain skipped {} stale frames", 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,
|
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
(tp_l, tp_r)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn drain_visual_meter_frames(
|
fn drain_visual_meter_frames(
|
||||||
@@ -1282,6 +1587,10 @@ mod tests {
|
|||||||
period_size: 128,
|
period_size: 128,
|
||||||
correlation: 0.25,
|
correlation: 0.25,
|
||||||
correlation_negative_peak: -0.5,
|
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_l: -20.0,
|
||||||
rms_r: -21.0,
|
rms_r: -21.0,
|
||||||
vu_l: -20.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]
|
#[test]
|
||||||
fn wave_envelopes_merge_without_losing_columns() {
|
fn wave_envelopes_merge_without_losing_columns() {
|
||||||
let mut target = Some(WaveEnvFrame {
|
let mut target = Some(WaveEnvFrame {
|
||||||
@@ -1416,4 +1752,58 @@ mod tests {
|
|||||||
json.len()
|
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
@@ -1,8 +1,9 @@
|
|||||||
//! Realtime-analyzer filter primitives.
|
//! Realtime-analyzer filter primitives.
|
||||||
//!
|
//!
|
||||||
//! Fractional-octave bands are designed as complete Butterworth bandpasses:
|
//! Provides both the original repeated-RBJ Phoenix characteristic and complete
|
||||||
//! an analog low-pass prototype is transformed to a bandpass, pre-warped and
|
//! Butterworth fractional-octave bandpasses for controlled comparison.
|
||||||
//! mapped with the bilinear transform, then emitted as distinct SOS sections.
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
pub const RTW_THIRD_OCTAVE_CENTERS: &[f32] = &[
|
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,
|
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
|
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 {
|
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 omega = 2.0 * std::f64::consts::PI * f64::from(freq_hz) / f64::from(sample_rate.max(8_000));
|
||||||
let z1 = Complex::new(omega.cos(), -omega.sin());
|
let z1 = Complex::new(omega.cos(), -omega.sin());
|
||||||
@@ -190,6 +220,58 @@ pub fn integrate_power(
|
|||||||
previous + alpha * (block_power - previous)
|
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)]
|
#[derive(Clone, Copy)]
|
||||||
struct StereoBiquad {
|
struct StereoBiquad {
|
||||||
coeffs: BiquadCoeffs,
|
coeffs: BiquadCoeffs,
|
||||||
@@ -396,6 +478,27 @@ mod tests {
|
|||||||
assert!(((lower * upper).sqrt() - center).abs() < 0.001);
|
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(§ions, 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]
|
#[test]
|
||||||
fn neighboring_third_octave_centers_are_suppressed() {
|
fn neighboring_third_octave_centers_are_suppressed() {
|
||||||
let factor = 2.0f32.powf(1.0 / 6.0);
|
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]
|
#[test]
|
||||||
fn weighting_is_normalized_and_directionally_correct() {
|
fn weighting_is_normalized_and_directionally_correct() {
|
||||||
for mode in ["a", "c"] {
|
for mode in ["a", "c"] {
|
||||||
|
|||||||
+233
-33
@@ -17,7 +17,7 @@ use crate::{
|
|||||||
config::PhoenixConfig,
|
config::PhoenixConfig,
|
||||||
model::{
|
model::{
|
||||||
InputSource, MeterFrame, PhoenixGlobalConfig, PhoenixGlobalConfigEnvelope,
|
InputSource, MeterFrame, PhoenixGlobalConfig, PhoenixGlobalConfigEnvelope,
|
||||||
PhoenixRtaConfig, ServiceStatus,
|
PhoenixRtaConfig, ServiceStatus, SpectroFrame,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,6 +28,8 @@ pub struct AppState {
|
|||||||
seq: Arc<AtomicU64>,
|
seq: Arc<AtomicU64>,
|
||||||
actual_sample_rate: Arc<AtomicU64>,
|
actual_sample_rate: Arc<AtomicU64>,
|
||||||
metrics_tx: broadcast::Sender<Arc<MeterFrame>>,
|
metrics_tx: broadcast::Sender<Arc<MeterFrame>>,
|
||||||
|
spectro_tx: broadcast::Sender<Arc<SpectroFrame>>,
|
||||||
|
spectro_subscribers: Arc<AtomicU64>,
|
||||||
restart_token: Arc<AtomicU64>,
|
restart_token: Arc<AtomicU64>,
|
||||||
rta_config: Arc<RwLock<PhoenixRtaConfig>>,
|
rta_config: Arc<RwLock<PhoenixRtaConfig>>,
|
||||||
global_config: Arc<RwLock<PhoenixGlobalConfig>>,
|
global_config: Arc<RwLock<PhoenixGlobalConfig>>,
|
||||||
@@ -53,6 +55,7 @@ pub(crate) struct NativeWavRecorder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const NATIVE_RECORDING_EDGE_FADE_FRAMES: usize = 128;
|
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) {
|
fn apply_native_recording_edge_fades(pcm_bytes: &mut [u8], channels: u16) {
|
||||||
let ch = usize::from(channels.max(1));
|
let ch = usize::from(channels.max(1));
|
||||||
@@ -95,9 +98,10 @@ impl AppState {
|
|||||||
// WebSocket consumers always drain to the newest state. A small
|
// WebSocket consumers always drain to the newest state. A small
|
||||||
// channel bounds memory and prevents seconds of stale measurements.
|
// channel bounds memory and prevents seconds of stale measurements.
|
||||||
let (metrics_tx, _) = broadcast::channel(32);
|
let (metrics_tx, _) = broadcast::channel(32);
|
||||||
|
let (spectro_tx, _) = broadcast::channel(8);
|
||||||
let initial_global_config = load_global_config(&config);
|
let initial_global_config = load_global_config(&config);
|
||||||
let initial_rta_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;
|
let configured_sample_rate = config.sample_rate;
|
||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
@@ -105,6 +109,8 @@ impl AppState {
|
|||||||
seq: Arc::new(AtomicU64::new(0)),
|
seq: Arc::new(AtomicU64::new(0)),
|
||||||
actual_sample_rate: Arc::new(AtomicU64::new(configured_sample_rate as u64)),
|
actual_sample_rate: Arc::new(AtomicU64::new(configured_sample_rate as u64)),
|
||||||
metrics_tx,
|
metrics_tx,
|
||||||
|
spectro_tx,
|
||||||
|
spectro_subscribers: Arc::new(AtomicU64::new(0)),
|
||||||
restart_token: Arc::new(AtomicU64::new(0)),
|
restart_token: Arc::new(AtomicU64::new(0)),
|
||||||
rta_config: Arc::new(RwLock::new(initial_rta_config)),
|
rta_config: Arc::new(RwLock::new(initial_rta_config)),
|
||||||
global_config: Arc::new(RwLock::new(initial_global_config)),
|
global_config: Arc::new(RwLock::new(initial_global_config)),
|
||||||
@@ -117,6 +123,22 @@ impl AppState {
|
|||||||
self.metrics_tx.subscribe()
|
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 {
|
pub async fn input(&self) -> InputSource {
|
||||||
*self.current_input.read().await
|
*self.current_input.read().await
|
||||||
}
|
}
|
||||||
@@ -142,11 +164,15 @@ impl AppState {
|
|||||||
pub async fn set_rta_config(&self, config: PhoenixRtaConfig) -> PhoenixRtaConfig {
|
pub async fn set_rta_config(&self, config: PhoenixRtaConfig) -> PhoenixRtaConfig {
|
||||||
let normalized = normalize_rta_config(config);
|
let normalized = normalize_rta_config(config);
|
||||||
*self.rta_config.write().await = normalized.clone();
|
*self.rta_config.write().await = normalized.clone();
|
||||||
|
let _ = persist_rta_config(&self.config, &normalized).await;
|
||||||
let current = self.global_config.read().await.clone();
|
let current = self.global_config.read().await.clone();
|
||||||
let updated_global = PhoenixGlobalConfig {
|
let updated_global = PhoenixGlobalConfig {
|
||||||
fft_size: normalized.fft_size,
|
fft_size: normalized.fft_size,
|
||||||
input_source: current.input_source,
|
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_l: normalized.input_offset_db_l,
|
||||||
input_offset_db_r: normalized.input_offset_db_r,
|
input_offset_db_r: normalized.input_offset_db_r,
|
||||||
mono_input: normalized.mono_input,
|
mono_input: normalized.mono_input,
|
||||||
@@ -157,9 +183,9 @@ impl AppState {
|
|||||||
panel_dividers_enabled: current.panel_dividers_enabled,
|
panel_dividers_enabled: current.panel_dividers_enabled,
|
||||||
ppm_din_attack_ms: normalized.ppm_din_attack_ms,
|
ppm_din_attack_ms: normalized.ppm_din_attack_ms,
|
||||||
ppm_din_decay_db_per_s: normalized.ppm_din_decay_db_per_s,
|
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_attack_ms: normalized.ppm_ebu_attack_ms,
|
||||||
ppm_ebu_decay_db_per_s: normalized.ppm_ebu_decay_db_per_s,
|
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_window_min: normalized.lufs_i_window_min,
|
||||||
lufs_i_norm_enabled: normalized.lufs_i_norm_enabled,
|
lufs_i_norm_enabled: normalized.lufs_i_norm_enabled,
|
||||||
ppm_din_loudness_boxes: current.ppm_din_loudness_boxes,
|
ppm_din_loudness_boxes: current.ppm_din_loudness_boxes,
|
||||||
@@ -221,6 +247,10 @@ impl AppState {
|
|||||||
self.global_config.read().await.clone()
|
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 {
|
pub fn global_config_revision(&self) -> u64 {
|
||||||
self.global_config_rev.load(Ordering::SeqCst)
|
self.global_config_rev.load(Ordering::SeqCst)
|
||||||
}
|
}
|
||||||
@@ -239,6 +269,15 @@ impl AppState {
|
|||||||
let normalized = normalize_global_config(payload, &self.config);
|
let normalized = normalize_global_config(payload, &self.config);
|
||||||
let current_global = self.global_config.read().await.clone();
|
let current_global = self.global_config.read().await.clone();
|
||||||
if current_global == normalized {
|
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 {
|
return Ok(PhoenixGlobalConfigEnvelope {
|
||||||
revision: self.global_config_revision(),
|
revision: self.global_config_revision(),
|
||||||
config: current_global,
|
config: current_global,
|
||||||
@@ -268,6 +307,8 @@ impl AppState {
|
|||||||
seq: self.seq.clone(),
|
seq: self.seq.clone(),
|
||||||
actual_sample_rate: self.actual_sample_rate.clone(),
|
actual_sample_rate: self.actual_sample_rate.clone(),
|
||||||
metrics_tx: self.metrics_tx.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(),
|
restart_token: self.restart_token.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -321,6 +362,14 @@ fn normalize_rta_config(mut config: PhoenixRtaConfig) -> PhoenixRtaConfig {
|
|||||||
"fft" => "fft".to_string(),
|
"fft" => "fft".to_string(),
|
||||||
_ => "iir".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 {
|
config.fft_size = match config.fft_size {
|
||||||
2048 | 4096 | 8192 | 16384 => config.fft_size,
|
2048 | 4096 | 8192 | 16384 => config.fft_size,
|
||||||
n if n < 3072 => 2048,
|
n if n < 3072 => 2048,
|
||||||
@@ -358,8 +407,16 @@ fn normalize_rta_config(mut config: PhoenixRtaConfig) -> PhoenixRtaConfig {
|
|||||||
"impulse" => "impulse".to_string(),
|
"impulse" => "impulse".to_string(),
|
||||||
"medium" => "medium".to_string(),
|
"medium" => "medium".to_string(),
|
||||||
"slow" => "slow".to_string(),
|
"slow" => "slow".to_string(),
|
||||||
"average" => "average".to_string(),
|
// Compatibility with configurations saved before detector and response
|
||||||
"peak" => "peak".to_string(),
|
// became separate settings.
|
||||||
|
"peak" => {
|
||||||
|
config.detector = "peak".to_string();
|
||||||
|
"fast".to_string()
|
||||||
|
}
|
||||||
|
"average" => {
|
||||||
|
config.detector = "average".to_string();
|
||||||
|
"fast".to_string()
|
||||||
|
}
|
||||||
_ => "fast".to_string(),
|
_ => "fast".to_string(),
|
||||||
};
|
};
|
||||||
config.order = config.order.clamp(2, 8);
|
config.order = config.order.clamp(2, 8);
|
||||||
@@ -368,26 +425,45 @@ fn normalize_rta_config(mut config: PhoenixRtaConfig) -> PhoenixRtaConfig {
|
|||||||
}
|
}
|
||||||
if config.layout == "rtw" {
|
if config.layout == "rtw" {
|
||||||
config.engine = "iir".to_string();
|
config.engine = "iir".to_string();
|
||||||
config.bpo = "1_3".to_string();
|
|
||||||
config.order = 6;
|
config.order = 6;
|
||||||
config.freq_range = "norm".to_string();
|
config.freq_range = "norm".to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
let tau_fast = if config.tau_fast.is_finite() {
|
// Fast and Slow are named standard responses, not free tuning controls.
|
||||||
config.tau_fast
|
// Keep the serialized fields for old clients, but normalize their values.
|
||||||
} else {
|
config.tau_fast = 0.125;
|
||||||
0.12
|
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() {
|
let requested_hold = if config.rta_peak_hold_seconds.is_finite() {
|
||||||
config.tau_slow
|
config.rta_peak_hold_seconds
|
||||||
} else {
|
} 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() {
|
let offset_l = if config.input_offset_db_l.is_finite() {
|
||||||
config.input_offset_db_l
|
config.input_offset_db_l
|
||||||
} else {
|
} else {
|
||||||
@@ -404,13 +480,17 @@ fn normalize_rta_config(mut config: PhoenixRtaConfig) -> PhoenixRtaConfig {
|
|||||||
// compatibility with older clients without allowing silent mistuning.
|
// compatibility with older clients without allowing silent mistuning.
|
||||||
config.ppm_din_attack_ms = 10.0;
|
config.ppm_din_attack_ms = 10.0;
|
||||||
config.ppm_din_decay_db_per_s = 20.0 / 1.5;
|
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_attack_ms = 10.0;
|
||||||
config.ppm_ebu_decay_db_per_s = 24.0 / 2.8;
|
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_window_min = config.lufs_i_window_min.clamp(1, 10);
|
||||||
config.lufs_i_norm_enabled = !!config.lufs_i_norm_enabled;
|
config.lufs_i_norm_enabled = !!config.lufs_i_norm_enabled;
|
||||||
config.correlation_response_s =
|
config.correlation_response_s =
|
||||||
crate::correlation::normalize_response_seconds(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 {
|
config.xy_points = match config.xy_points {
|
||||||
128 | 256 | 512 | 1024 | 2048 => config.xy_points,
|
128 | 256 | 512 | 1024 | 2048 => config.xy_points,
|
||||||
n if n < 192 => 128,
|
n if n < 192 => 128,
|
||||||
@@ -492,9 +572,9 @@ fn normalize_global_config(
|
|||||||
config.panel_dividers_enabled = !!config.panel_dividers_enabled;
|
config.panel_dividers_enabled = !!config.panel_dividers_enabled;
|
||||||
config.ppm_din_attack_ms = 10.0;
|
config.ppm_din_attack_ms = 10.0;
|
||||||
config.ppm_din_decay_db_per_s = 20.0 / 1.5;
|
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_attack_ms = 10.0;
|
||||||
config.ppm_ebu_decay_db_per_s = 24.0 / 2.8;
|
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_window_min = config.lufs_i_window_min.clamp(1, 10);
|
||||||
config.lufs_i_norm_enabled = !!config.lufs_i_norm_enabled;
|
config.lufs_i_norm_enabled = !!config.lufs_i_norm_enabled;
|
||||||
config.ppm_din_loudness_boxes = !!config.ppm_din_loudness_boxes;
|
config.ppm_din_loudness_boxes = !!config.ppm_din_loudness_boxes;
|
||||||
@@ -663,9 +743,6 @@ fn apply_global_to_rta(
|
|||||||
) -> PhoenixRtaConfig {
|
) -> PhoenixRtaConfig {
|
||||||
config.fft_size = global.fft_size;
|
config.fft_size = global.fft_size;
|
||||||
config.bpo = global.rta_bpo_mode.clone();
|
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_l = global.input_offset_db_l;
|
||||||
config.input_offset_db_r = global.input_offset_db_r;
|
config.input_offset_db_r = global.input_offset_db_r;
|
||||||
config.mono_input = global.mono_input;
|
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.lr_fractional_delay_samples = global.lr_fractional_delay_samples;
|
||||||
config.ppm_din_attack_ms = global.ppm_din_attack_ms;
|
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_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_attack_ms = global.ppm_ebu_attack_ms;
|
||||||
config.ppm_ebu_decay_db_per_s = global.ppm_ebu_decay_db_per_s;
|
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_window_min = global.lufs_i_window_min;
|
||||||
config.lufs_i_norm_enabled = global.lufs_i_norm_enabled;
|
config.lufs_i_norm_enabled = global.lufs_i_norm_enabled;
|
||||||
config.xy_points = global.xy_points;
|
config.xy_points = global.xy_points;
|
||||||
normalize_rta_config(config)
|
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 {
|
fn load_global_config(runtime: &PhoenixConfig) -> PhoenixGlobalConfig {
|
||||||
let fallback = normalize_global_config(runtime.default_global_config(), runtime);
|
let fallback = normalize_global_config(runtime.default_global_config(), runtime);
|
||||||
let Ok(raw) = std::fs::read_to_string(&runtime.global_config_path) else {
|
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)
|
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(
|
async fn persist_global_config(
|
||||||
runtime: &PhoenixConfig,
|
runtime: &PhoenixConfig,
|
||||||
config: &PhoenixGlobalConfig,
|
config: &PhoenixGlobalConfig,
|
||||||
) -> anyhow::Result<()> {
|
) -> 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() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent)
|
fs::create_dir_all(parent)
|
||||||
.await
|
.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)?;
|
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
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -715,7 +840,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rtw_profile_is_always_iir_third_octave() {
|
fn rtw_profile_is_iir_and_retains_selected_resolution() {
|
||||||
let config = normalize_rta_config(PhoenixRtaConfig {
|
let config = normalize_rta_config(PhoenixRtaConfig {
|
||||||
engine: "fft".to_string(),
|
engine: "fft".to_string(),
|
||||||
bpo: "1_12".to_string(),
|
bpo: "1_12".to_string(),
|
||||||
@@ -725,7 +850,8 @@ mod tests {
|
|||||||
..PhoenixRtaConfig::default()
|
..PhoenixRtaConfig::default()
|
||||||
});
|
});
|
||||||
assert_eq!(config.engine, "iir");
|
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.order, 6);
|
||||||
assert_eq!(config.freq_range, "norm");
|
assert_eq!(config.freq_range, "norm");
|
||||||
}
|
}
|
||||||
@@ -734,6 +860,7 @@ mod tests {
|
|||||||
fn phoenix_profile_retains_explicit_extensions() {
|
fn phoenix_profile_retains_explicit_extensions() {
|
||||||
let config = normalize_rta_config(PhoenixRtaConfig {
|
let config = normalize_rta_config(PhoenixRtaConfig {
|
||||||
engine: "fft".to_string(),
|
engine: "fft".to_string(),
|
||||||
|
filterbank: "butterworth".to_string(),
|
||||||
bpo: "1_12".to_string(),
|
bpo: "1_12".to_string(),
|
||||||
order: 8,
|
order: 8,
|
||||||
freq_range: "lf".to_string(),
|
freq_range: "lf".to_string(),
|
||||||
@@ -742,6 +869,7 @@ mod tests {
|
|||||||
..PhoenixRtaConfig::default()
|
..PhoenixRtaConfig::default()
|
||||||
});
|
});
|
||||||
assert_eq!(config.engine, "fft");
|
assert_eq!(config.engine, "fft");
|
||||||
|
assert_eq!(config.filterbank, "butterworth");
|
||||||
assert_eq!(config.bpo, "1_12");
|
assert_eq!(config.bpo, "1_12");
|
||||||
assert_eq!(config.order, 8);
|
assert_eq!(config.order, 8);
|
||||||
assert_eq!(config.freq_range, "lf");
|
assert_eq!(config.freq_range, "lf");
|
||||||
@@ -749,12 +877,84 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
let mut global = PhoenixGlobalConfig::default();
|
||||||
global.rta_bpo_mode = "1_12".to_string();
|
global.rta_bpo_mode = "1_12".to_string();
|
||||||
let config = apply_global_to_rta(PhoenixRtaConfig::default(), &global);
|
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.bpo, "1_12");
|
||||||
assert_eq!(config.engine, "iir");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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) {
|
function activateOrCreate(url) {
|
||||||
chrome.tabs.query({}, tabs => {
|
chrome.tabs.query({ currentWindow: true }, tabs => {
|
||||||
const t = tabs.find(tt => tt.url && tt.url.startsWith(url));
|
const tab = matchingTabs(tabs, url)[0];
|
||||||
if (t) { chrome.tabs.update(t.id, {active: true}); }
|
if (tab) chrome.tabs.update(tab.id, { active: true });
|
||||||
else { chrome.tabs.create({url}); }
|
else chrome.tabs.create({ url, active: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
||||||
if (msg && msg.cmd === 'toggle') {
|
if (!msg) return;
|
||||||
if (msg.from === 'volumio') activateOrCreate('http://localhost:8088/');
|
if (msg.cmd === 'bootstrap') {
|
||||||
else activateOrCreate('http://localhost:3000/');
|
ensureDualTabs();
|
||||||
sendResponse({ok:true});
|
sendResponse({ ok: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.cmd === 'toggle') {
|
||||||
|
if (msg.from === 'volumio') activateOrCreate(ANALYZER_URL);
|
||||||
|
else activateOrCreate(VOLUMIO_URL);
|
||||||
|
sendResponse({ ok: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+40
-1
@@ -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 (_) {}
|
||||||
|
})();
|
||||||
|
|||||||
@@ -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 (Slot‑Meter): Fix für Canvas‑State/Font‑Leak, der nach Aktivierung die Skalen‑Schrift in anderen Views riesig gemacht hat.</li>
|
||||||
|
<li>Audio/Timing: Anzeige-Update-Intervall reduziert und an die effektive Render‑FPS gekoppelt (verringert Jitter/Drift, verhindert Message‑Backlogs).</li>
|
||||||
|
<li>Default: FFT_SIZE auf 4096 reduziert (weniger CPU; betrifft v.a. Spectrogram/LUFS/FFT‑RTA).</li>
|
||||||
|
<li>Performance: Keine FFT‑Berechnung 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>Quad‑View: Klick‑Verhalten wählbar (direkt wechseln oder Popup‑Ansicht).</li>
|
||||||
|
<li>Quad‑View: Popup‑Ansicht rendert exakt wie die Einzel‑View (Layout/Skalen identisch); Tap/ESC schließt.</li>
|
||||||
|
<li>Split‑View: Klick‑Verhalten wählbar (direkt wechseln oder Popup‑Ansicht).</li>
|
||||||
|
<li>Split‑View: Popup‑Ansicht rendert exakt wie die Einzel‑View; 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>Split‑View: kompaktere HUD‑Bedienung (schmalere Plot‑Dropdowns, Trennzeichen „|“).</li>
|
||||||
|
<li>Split‑View: Slot‑Zuordnung über Button + Popup (zeigt nur aktive Slots).</li>
|
||||||
|
<li>Split‑View: Popup schließt per Klick außerhalb oder ESC.</li>
|
||||||
|
<li>Split‑View: „Slot:“ wird nur angezeigt, wenn mindestens ein Slot aktiv ist.</li>
|
||||||
|
<li>Neu: Quad‑View (4 unabhängige Plots im 2×2‑Raster).</li>
|
||||||
|
<li>Quad‑View: Plot‑Auswahl über Popup; eigene Slot‑Konfiguration (unabhängig vom Split‑View).</li>
|
||||||
|
<li>Phase Wheel: Phase‑Box im Split‑View korrekt positioniert (stabil am Plot‑Panel, mit Abstand; Box kleiner).</li>
|
||||||
|
<li>Bildschirmschoner: Recorder‑UI/Modals sowie Slot‑Popup 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: Split‑View (zwei Plots nebeneinander, Links/Rechts wählbar).</li>
|
||||||
|
<li>Split‑View: optionale Meter‑Slots (0–3) 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): I‑Zeitfenster 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 7‑Segment‑Anzeige).</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 (1–10 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 & 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 & 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 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 & 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 & 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
@@ -1,7 +1,12 @@
|
|||||||
// core/audio.js — Phoenix Audio-Init, Metrics-WS, Waveform/Spectrogram buffers
|
// 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.
|
// 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 { decodePhoenixSpectroBuffer, decodePhoenixVisualsBuffer } from './binary_protocol.js';
|
||||||
import { getRtwCenters } from './rtw_centers.js';
|
import { getRtwCenters } from './rtw_centers.js';
|
||||||
|
|
||||||
@@ -9,6 +14,7 @@ import { getRtwCenters } from './rtw_centers.js';
|
|||||||
let phoenixSocket = null;
|
let phoenixSocket = null;
|
||||||
let phoenixSpectroSocket = null;
|
let phoenixSpectroSocket = null;
|
||||||
let phoenixSpectroRetryTimer = null;
|
let phoenixSpectroRetryTimer = null;
|
||||||
|
let phoenixSpectroDemanded = false;
|
||||||
let pendingSpectroBuffer = null;
|
let pendingSpectroBuffer = null;
|
||||||
let phoenixSpectroRaf = 0;
|
let phoenixSpectroRaf = 0;
|
||||||
let phoenixVisualsSocket = null;
|
let phoenixVisualsSocket = null;
|
||||||
@@ -99,6 +105,7 @@ function closePhoenixSocket() {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
phoenixSpectroSocket = null;
|
phoenixSpectroSocket = null;
|
||||||
|
phoenixSpectroDemanded = false;
|
||||||
pendingSpectroBuffer = null;
|
pendingSpectroBuffer = null;
|
||||||
if (phoenixSpectroRaf) cancelAnimationFrame(phoenixSpectroRaf);
|
if (phoenixSpectroRaf) cancelAnimationFrame(phoenixSpectroRaf);
|
||||||
phoenixSpectroRaf = 0;
|
phoenixSpectroRaf = 0;
|
||||||
@@ -253,6 +260,7 @@ function copyPhoenixSpectroBins(audioState, spectro, frameDelta = 1) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openPhoenixSpectroSocket(baseUrl, env) {
|
function openPhoenixSpectroSocket(baseUrl, env) {
|
||||||
|
if (!phoenixSpectroDemanded) return;
|
||||||
if (phoenixSpectroSocket && (
|
if (phoenixSpectroSocket && (
|
||||||
phoenixSpectroSocket.readyState === WebSocket.OPEN
|
phoenixSpectroSocket.readyState === WebSocket.OPEN
|
||||||
|| phoenixSpectroSocket.readyState === WebSocket.CONNECTING
|
|| phoenixSpectroSocket.readyState === WebSocket.CONNECTING
|
||||||
@@ -269,7 +277,7 @@ function openPhoenixSpectroSocket(baseUrl, env) {
|
|||||||
|
|
||||||
const retry = () => {
|
const retry = () => {
|
||||||
if (phoenixSpectroSocket === socket) phoenixSpectroSocket = null;
|
if (phoenixSpectroSocket === socket) phoenixSpectroSocket = null;
|
||||||
if (!phoenixSocket || phoenixSocket.readyState !== WebSocket.OPEN) return;
|
if (!phoenixSpectroDemanded || !phoenixSocket || phoenixSocket.readyState !== WebSocket.OPEN) return;
|
||||||
if (phoenixSpectroRetryTimer) clearTimeout(phoenixSpectroRetryTimer);
|
if (phoenixSpectroRetryTimer) clearTimeout(phoenixSpectroRetryTimer);
|
||||||
phoenixSpectroRetryTimer = setTimeout(() => {
|
phoenixSpectroRetryTimer = setTimeout(() => {
|
||||||
phoenixSpectroRetryTimer = null;
|
phoenixSpectroRetryTimer = null;
|
||||||
@@ -280,6 +288,31 @@ function openPhoenixSpectroSocket(baseUrl, env) {
|
|||||||
socket.onclose = retry;
|
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) {
|
function scheduleSpectroBufferPump(env) {
|
||||||
if (phoenixSpectroRaf || !pendingSpectroBuffer) return;
|
if (phoenixSpectroRaf || !pendingSpectroBuffer) return;
|
||||||
phoenixSpectroRaf = requestAnimationFrame(() => {
|
phoenixSpectroRaf = requestAnimationFrame(() => {
|
||||||
@@ -416,6 +449,11 @@ async function applyIncomingAudioPacket(env, packet, CONFIG, sampleTs = performa
|
|||||||
if (typeof d.correlationNegativePeak === 'number') {
|
if (typeof d.correlationNegativePeak === 'number') {
|
||||||
env.audio.correlationNegativePeak = d.correlationNegativePeak;
|
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) {
|
if (d.waveL && env.audio.pushWaveSamples) {
|
||||||
const channelCount = d.waveChannels || (d.waveR ? 2 : 1);
|
const channelCount = d.waveChannels || (d.waveR ? 2 : 1);
|
||||||
env.audio.sampleRate = d.sampleRate || env.audio.sampleRate || 48000;
|
env.audio.sampleRate = d.sampleRate || env.audio.sampleRate || 48000;
|
||||||
@@ -474,6 +512,10 @@ function buildPhoenixMeterPacket(frame) {
|
|||||||
const ppmBoxR = Number(frame?.ppm_box_r);
|
const ppmBoxR = Number(frame?.ppm_box_r);
|
||||||
const correlation = Number(frame?.correlation);
|
const correlation = Number(frame?.correlation);
|
||||||
const correlationNegativePeak = Number(frame?.correlation_negative_peak);
|
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 xyL = Array.isArray(frame?.xy_l) ? frame.xy_l : null;
|
||||||
const xyR = Array.isArray(frame?.xy_r) ? frame.xy_r : 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;
|
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)
|
correlationNegativePeak: Number.isFinite(correlationNegativePeak)
|
||||||
? Math.max(-1, Math.min(1, correlationNegativePeak))
|
? Math.max(-1, Math.min(1, correlationNegativePeak))
|
||||||
: 0,
|
: 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,
|
rmsL: Number.isFinite(rmsL) ? rmsL : -120,
|
||||||
rmsR: Number.isFinite(rmsR) ? rmsR : -120,
|
rmsR: Number.isFinite(rmsR) ? rmsR : -120,
|
||||||
tpL: Number.isFinite(tpL) ? tpL : -120,
|
tpL: Number.isFinite(tpL) ? tpL : -120,
|
||||||
@@ -516,6 +562,9 @@ function buildPhoenixMeterPacket(frame) {
|
|||||||
xyR,
|
xyR,
|
||||||
rta: rta ? {
|
rta: rta ? {
|
||||||
engine: String(rta?.engine || 'iir'),
|
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_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_peak: Array.isArray(rta?.bands_peak) ? rta.bands_peak : [],
|
||||||
bands: Array.isArray(rta?.bands) ? rta.bands : (Array.isArray(rta?.bands_avg) ? rta.bands_avg : []),
|
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 bpoMode = CONFIG.RTA_BPO_MODE || '1_3';
|
||||||
const layout = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'rtw' : 'iec';
|
const layout = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'rtw' : 'iec';
|
||||||
const rtwProfile = layout === 'rtw';
|
const rtwProfile = layout === 'rtw';
|
||||||
const runtimeBpoMode = rtwProfile ? '1_3' : bpoMode;
|
const runtimeBpoMode = bpoMode;
|
||||||
return {
|
return {
|
||||||
engine: rtwProfile ? 'iir' : (CONFIG.RTA_ENGINE || 'iir'),
|
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,
|
fftSize: CONFIG.FFT_SIZE || 4096,
|
||||||
monoInput: !!CONFIG.MONO_INPUT,
|
monoInput: !!CONFIG.MONO_INPUT,
|
||||||
lrFractionalDelayEnabled: !!CONFIG.LR_FRACTIONAL_DELAY_ENABLED,
|
lrFractionalDelayEnabled: !!CONFIG.LR_FRACTIONAL_DELAY_ENABLED,
|
||||||
@@ -879,20 +930,28 @@ export function buildRtaRuntimeConfig(CONFIG = {}) {
|
|||||||
freqRange: rtwProfile ? 'norm' : (CONFIG.RTA_FREQ_RANGE || 'norm'),
|
freqRange: rtwProfile ? 'norm' : (CONFIG.RTA_FREQ_RANGE || 'norm'),
|
||||||
weighting: CONFIG.RTA_WEIGHTING || 'z',
|
weighting: CONFIG.RTA_WEIGHTING || 'z',
|
||||||
order: rtwProfile ? 6 : (CONFIG.RTA_IIR_ORDER || 6),
|
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,
|
tauSlow: CONFIG.RTA_IIR_TAU_SLOW || 1.0,
|
||||||
integration: CONFIG.RTA_INTEGRATION || 'fast',
|
integration: CONFIG.RTA_INTEGRATION || 'fast',
|
||||||
layout,
|
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,
|
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,
|
inputOffsetDbR: Number.isFinite(CONFIG.INPUT_OFFSET_DB_R) ? CONFIG.INPUT_OFFSET_DB_R : -5,
|
||||||
ppmDinAttackMs: 10,
|
ppmDinAttackMs: 10,
|
||||||
ppmDinDecayDbPerS: 20 / 1.5,
|
ppmDinDecayDbPerS: 20 / 1.5,
|
||||||
ppmDinFastAttack: !!CONFIG.PPM_DIN_FAST_ATTACK,
|
|
||||||
ppmEbuAttackMs: 10,
|
ppmEbuAttackMs: 10,
|
||||||
ppmEbuDecayDbPerS: 24 / 2.8,
|
ppmEbuDecayDbPerS: 24 / 2.8,
|
||||||
lufsIWindowMin: Number.isFinite(CONFIG.LUFS_I_WINDOW_MIN) ? CONFIG.LUFS_I_WINDOW_MIN : 4,
|
lufsIWindowMin: Number.isFinite(CONFIG.LUFS_I_WINDOW_MIN) ? CONFIG.LUFS_I_WINDOW_MIN : 4,
|
||||||
lufsINormEnabled: !!CONFIG.LUFS_I_NORM_ENABLED,
|
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)),
|
correlationResetToken: Math.max(0, Math.floor(Number(CONFIG.CORR_RESET_TOKEN) || 0)),
|
||||||
xyPoints: [128, 256, 512, 1024, 2048].includes(Number(CONFIG.XY_POINTS))
|
xyPoints: [128, 256, 512, 1024, 2048].includes(Number(CONFIG.XY_POINTS))
|
||||||
? Number(CONFIG.XY_POINTS)
|
? Number(CONFIG.XY_POINTS)
|
||||||
@@ -1038,6 +1097,7 @@ async function initPhoenixAudio(env) {
|
|||||||
? cfg
|
? cfg
|
||||||
: ((typeof env?.getProcessingProfile === 'function') ? env.getProcessingProfile() : null);
|
: ((typeof env?.getProcessingProfile === 'function') ? env.getProcessingProfile() : null);
|
||||||
const profile = rawProfile || {};
|
const profile = rawProfile || {};
|
||||||
|
setPhoenixSpectroDemand(baseUrl, env, profile.needSpectro);
|
||||||
if (!profile.needXy) {
|
if (!profile.needXy) {
|
||||||
env.audio.xyL = null;
|
env.audio.xyL = null;
|
||||||
env.audio.xyR = null;
|
env.audio.xyR = null;
|
||||||
@@ -1052,6 +1112,9 @@ async function initPhoenixAudio(env) {
|
|||||||
resetWaveformStateFallback(env.audio.waveformFallback);
|
resetWaveformStateFallback(env.audio.waveformFallback);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
env.audio.updateProcessingConfig(
|
||||||
|
(typeof env?.getProcessingProfile === 'function') ? env.getProcessingProfile() : null,
|
||||||
|
);
|
||||||
|
|
||||||
const pushPhoenixGlobalConfig = async () => {
|
const pushPhoenixGlobalConfig = async () => {
|
||||||
const response = await requestPhoenixGlobalConfigUpdate(baseUrl, buildPhoenixGlobalConfigPayload());
|
const response = await requestPhoenixGlobalConfigUpdate(baseUrl, buildPhoenixGlobalConfigPayload());
|
||||||
@@ -1100,7 +1163,7 @@ async function initPhoenixAudio(env) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
openPhoenixSpectroSocket(baseUrl, env);
|
if (phoenixSpectroDemanded) openPhoenixSpectroSocket(baseUrl, env);
|
||||||
openPhoenixVisualsSocket(baseUrl, env);
|
openPhoenixVisualsSocket(baseUrl, env);
|
||||||
finish(true);
|
finish(true);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -1,5 +1,13 @@
|
|||||||
// core/config.js — zentrale CONFIG + Presets + Persistenz
|
// 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) {
|
function isLoopbackHost(host) {
|
||||||
const h = String(host || '').trim().toLowerCase();
|
const h = String(host || '').trim().toLowerCase();
|
||||||
return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '[::1]';
|
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_HOLD_MS: 800,
|
||||||
REALTIME_BAR_DECAY_DB_PER_S: 20,
|
REALTIME_BAR_DECAY_DB_PER_S: 20,
|
||||||
RTA_ENGINE: 'iir', // 'fft' | 'iir'
|
RTA_ENGINE: 'iir', // 'fft' | 'iir'
|
||||||
|
RTA_IIR_FILTERBANK: 'legacy', // 'legacy' | 'butterworth'
|
||||||
RTA_FREQ_RANGE: 'norm', // 'norm' | 'lf'
|
RTA_FREQ_RANGE: 'norm', // 'norm' | 'lf'
|
||||||
RTA_BPO_MODE: '1_3',
|
RTA_BPO_MODE: '1_3',
|
||||||
RTA_WEIGHTING: 'z', // 'z' | 'a' | 'c'
|
RTA_WEIGHTING: 'z', // 'z' | 'a' | 'c'
|
||||||
@@ -58,14 +67,16 @@ const CONFIG = {
|
|||||||
RTA_BAR_LAYOUT: 'rtw', // 'iec' | 'rtw'
|
RTA_BAR_LAYOUT: 'rtw', // 'iec' | 'rtw'
|
||||||
RTA_BAR_BASE_COLOR: '#ffe066',
|
RTA_BAR_BASE_COLOR: '#ffe066',
|
||||||
HEADER_TEXT_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_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_HOLD_SEC: 2.5,
|
||||||
RTA_PEAK_DECAY_DB_PER_S: 20,
|
RTA_PEAK_DECAY_DB_PER_S: 20,
|
||||||
|
RTA_PEAK_RESET_TOKEN: 0,
|
||||||
RTA_DISPLAY_HOLD_SEC: 0,
|
RTA_DISPLAY_HOLD_SEC: 0,
|
||||||
RTA_IIR_ORDER: 6,
|
RTA_IIR_ORDER: 6,
|
||||||
RTA_IIR_TAU_FAST: 0.12,
|
RTA_IIR_TAU_FAST: 0.125,
|
||||||
RTA_IIR_TAU_SLOW: 1.0,
|
RTA_IIR_TAU_SLOW: 1.0,
|
||||||
SPECTRO_GAMMA: 0.4,
|
SPECTRO_GAMMA: 0.4,
|
||||||
SPECTRO_SCROLL_MODE: 1, // 0.5=Langsam, 1=Normal, 2=Licht, 4=Lächerlich, 6=Wahnsinnig
|
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,
|
VU_REF_IS_PLUS4: true,
|
||||||
// 0 VU soll +4 dBu entsprechen → DIGITAL_REF + 4 dB.
|
// 0 VU soll +4 dBu entsprechen → DIGITAL_REF + 4 dB.
|
||||||
VU_DBFS_REF: -14.0,
|
VU_DBFS_REF: -14.0,
|
||||||
VU_OFFSET_DB: 1.92,
|
VU_OFFSET_DB: 0.0,
|
||||||
VU_HEADER_SHOW_VALUE: false,
|
VU_HEADER_SHOW_VALUE: false,
|
||||||
TP_OFFSET_DB: 0.0,
|
TP_OFFSET_DB: 0.0,
|
||||||
TP_HEADER_SHOW_VALUE: false,
|
TP_HEADER_SHOW_VALUE: false,
|
||||||
@@ -116,8 +127,9 @@ const CONFIG = {
|
|||||||
PPM_RED_BAR_ONLY: true,
|
PPM_RED_BAR_ONLY: true,
|
||||||
TP_RED_BAR_ONLY: true,
|
TP_RED_BAR_ONLY: true,
|
||||||
RMS_RED_BAR_ONLY: true,
|
RMS_RED_BAR_ONLY: true,
|
||||||
RMS_TC_MODE: 'fast', // 'impulse' (35 ms), 'fast' (125 ms), 'slow' (1000 ms), 'none'
|
// True-RMS integration is performed sample-continuously on linear power in
|
||||||
RMS_TC_MS: null, // optional override in ms (falls gesetzt, überschreibt den Modus)
|
// the backend. "window" is a rectangular sliding 300-ms measurement.
|
||||||
|
RMS_TC_MODE: 'fast', // 'fast', 'slow', 'window'
|
||||||
VU_COLOR_NORMAL: '#ffe066',
|
VU_COLOR_NORMAL: '#ffe066',
|
||||||
VU_COLOR_WARN: '#ff3b3b',
|
VU_COLOR_WARN: '#ff3b3b',
|
||||||
PPM_DIN_COLOR_NORMAL: '#ffe066',
|
PPM_DIN_COLOR_NORMAL: '#ffe066',
|
||||||
@@ -158,9 +170,6 @@ const CONFIG = {
|
|||||||
PPM_DIN_RED_START: 0,
|
PPM_DIN_RED_START: 0,
|
||||||
// Fixed RTW/DIN profile: 10 ms integration and 20 dB return in 1.5 s.
|
// Fixed RTW/DIN profile: 10 ms integration and 20 dB return in 1.5 s.
|
||||||
PPM_DIN_ATTACK_MS: 10,
|
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,
|
PPM_DIN_DECAY_DB_PER_S: 20 / 1.5,
|
||||||
// Hold time for DIN PPM: verlängert auf 1000 ms (1 s), damit der Peak‑Hold
|
// Hold time for DIN PPM: verlängert auf 1000 ms (1 s), damit der Peak‑Hold
|
||||||
// auch bei kurzen Transienten deutlich sichtbar bleibt. Gemäß
|
// auch bei kurzen Transienten deutlich sichtbar bleibt. Gemäß
|
||||||
@@ -188,6 +197,7 @@ const CONFIG = {
|
|||||||
CORR_ZERO_ON_SILENCE: false,
|
CORR_ZERO_ON_SILENCE: false,
|
||||||
CORR_SILENCE_THRESHOLD_RMS_DBFS: -75,
|
CORR_SILENCE_THRESHOLD_RMS_DBFS: -75,
|
||||||
CORR_RESPONSE_S: 1.0,
|
CORR_RESPONSE_S: 1.0,
|
||||||
|
CORR_NEGATIVE_MARKER_MODE: 'memory',
|
||||||
CORR_RESET_TOKEN: 0,
|
CORR_RESET_TOKEN: 0,
|
||||||
XY_POINTS: 1024,
|
XY_POINTS: 1024,
|
||||||
XY_STYLE: 'lines',
|
XY_STYLE: 'lines',
|
||||||
@@ -285,11 +295,6 @@ const CONFIG_DEFAULTS = JSON.parse(JSON.stringify(CONFIG));
|
|||||||
function applyRtaBpoSelection(value) {
|
function applyRtaBpoSelection(value) {
|
||||||
const mode = ['1_3', '1_6', '1_12'].includes(String(value)) ? String(value) : '1_3';
|
const mode = ['1_3', '1_6', '1_12'].includes(String(value)) ? String(value) : '1_3';
|
||||||
CONFIG.RTA_BPO_MODE = mode;
|
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;
|
return mode;
|
||||||
}
|
}
|
||||||
const SOFTWARE_PRESET_IDS = Object.freeze(['default', 'claus', 'michael']);
|
const SOFTWARE_PRESET_IDS = Object.freeze(['default', 'claus', 'michael']);
|
||||||
@@ -590,9 +595,9 @@ const PHOENIX_GLOBAL_OPTION_KEYS = Object.freeze([
|
|||||||
'LR_FRACTIONAL_DELAY_SAMPLES',
|
'LR_FRACTIONAL_DELAY_SAMPLES',
|
||||||
'PPM_DIN_ATTACK_MS',
|
'PPM_DIN_ATTACK_MS',
|
||||||
'PPM_DIN_DECAY_DB_PER_S',
|
'PPM_DIN_DECAY_DB_PER_S',
|
||||||
'PPM_DIN_FAST_ATTACK',
|
|
||||||
'PPM_EBU_ATTACK_MS',
|
'PPM_EBU_ATTACK_MS',
|
||||||
'PPM_EBU_DECAY_DB_PER_S',
|
'PPM_EBU_DECAY_DB_PER_S',
|
||||||
|
'RMS_TC_MODE',
|
||||||
'LUFS_I_WINDOW_MIN',
|
'LUFS_I_WINDOW_MIN',
|
||||||
'LUFS_I_NORM_ENABLED',
|
'LUFS_I_NORM_ENABLED',
|
||||||
'PPM_DIN_LOUDNESS_BOXES',
|
'PPM_DIN_LOUDNESS_BOXES',
|
||||||
@@ -680,11 +685,12 @@ const BROADCAST_STANDARDS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const STORAGE_KEY = 'analyzer_config';
|
const STORAGE_KEY = 'analyzer_config';
|
||||||
const VU_OFFSET_ALIGNMENT_DB = 1.92;
|
const VU_OFFSET_DEFAULT_DB = 0.0;
|
||||||
const VU_OFFSET_ALIGNMENT_MIGRATION_KEY = 'migrate_vu_offset_alignment_plus2_v1';
|
const VU_OFFSET_ZERO_MIGRATION_KEY = 'migrate_vu_offset_default_zero_v2';
|
||||||
const INPUT_OFFSET_DB_MIN = -60;
|
const INPUT_OFFSET_DB_MIN = -60;
|
||||||
const INPUT_OFFSET_DB_MAX = 20;
|
const INPUT_OFFSET_DB_MAX = 20;
|
||||||
const LR_DELAY_ZERO_MIGRATION_KEY = 'migrate_lr_delay_zero_to_0_05_v1';
|
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;
|
let configLoadedOnce = false;
|
||||||
|
|
||||||
function normalizeSoftwarePresetId(value) {
|
function normalizeSoftwarePresetId(value) {
|
||||||
@@ -776,9 +782,16 @@ function loadConfig(opts = {}) {
|
|||||||
let saved = null;
|
let saved = null;
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
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);
|
saved = JSON.parse(raw);
|
||||||
for (const k of Object.keys(saved)) if (k in CONFIG) CONFIG[k] = saved[k];
|
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_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_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;
|
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_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 (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 === 'classic') CONFIG.RTA_BAR_LAYOUT = 'rtw';
|
||||||
if (CONFIG.RTA_BAR_LAYOUT === 'rtw' && CONFIG.RTA_BPO_MODE !== '1_3') {
|
CONFIG.RTA_IIR_FILTERBANK = CONFIG.RTA_IIR_FILTERBANK === 'butterworth'
|
||||||
CONFIG.RTA_BAR_LAYOUT = 'iec';
|
? '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') {
|
if (CONFIG.RTA_BAR_LAYOUT === 'rtw') {
|
||||||
CONFIG.RTA_ENGINE = 'iir';
|
CONFIG.RTA_ENGINE = 'iir';
|
||||||
CONFIG.RTA_BPO_MODE = '1_3';
|
|
||||||
CONFIG.RTA_IIR_ORDER = 6;
|
CONFIG.RTA_IIR_ORDER = 6;
|
||||||
CONFIG.RTA_FREQ_RANGE = 'norm';
|
CONFIG.RTA_FREQ_RANGE = 'norm';
|
||||||
const hold = Number(CONFIG.RTA_PEAK_HOLD_SEC);
|
const hold = Number(CONFIG.RTA_PEAK_HOLD_SEC);
|
||||||
CONFIG.RTA_PEAK_HOLD_SEC = hold >= 3.25 ? 4.0 : 2.5;
|
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_TOP)) CONFIG.DBFS_TOP = CONFIG_DEFAULTS.DBFS_TOP;
|
||||||
if (!Number.isFinite(CONFIG.DBFS_BOTTOM)) CONFIG.DBFS_BOTTOM = CONFIG_DEFAULTS.DBFS_BOTTOM;
|
if (!Number.isFinite(CONFIG.DBFS_BOTTOM)) CONFIG.DBFS_BOTTOM = CONFIG_DEFAULTS.DBFS_BOTTOM;
|
||||||
// Clamp to sensible bounds while keeping the user-selected display range.
|
// 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;
|
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));
|
CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB = Math.max(-7, Math.min(7, CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB));
|
||||||
try {
|
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 savedVuOffset = Number(saved?.VU_OFFSET_DB);
|
||||||
const oldDefaultOffset = Number(CONFIG_DEFAULTS?.VU_OFFSET_DB);
|
const looksLikeOldAutomaticOffset = !Number.isFinite(savedVuOffset)
|
||||||
const looksLikeOldDefault = !Number.isFinite(savedVuOffset)
|
|| Math.abs(savedVuOffset - 1.92) < 0.02
|
||||||
|| Math.abs(savedVuOffset - oldDefaultOffset) < 0.02
|
|
||||||
|| Math.abs(savedVuOffset - (-0.08)) < 0.02
|
|| Math.abs(savedVuOffset - (-0.08)) < 0.02
|
||||||
|| Math.abs(savedVuOffset - (-0.05)) < 0.02;
|
|| Math.abs(savedVuOffset - (-0.05)) < 0.02;
|
||||||
if (needsVuMigration && looksLikeOldDefault) {
|
if (needsVuMigration && looksLikeOldAutomaticOffset) {
|
||||||
CONFIG.VU_OFFSET_DB = VU_OFFSET_ALIGNMENT_DB;
|
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 (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
if (!Number.isFinite(CONFIG.LUFS_I_WINDOW_MIN)) CONFIG.LUFS_I_WINDOW_MIN = CONFIG_DEFAULTS.LUFS_I_WINDOW_MIN ?? 4;
|
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.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.LUFS_I_NORM_ENABLED = !!CONFIG.LUFS_I_NORM_ENABLED;
|
||||||
CONFIG.STOPWATCH_DISPLAY_STYLE = (CONFIG.STOPWATCH_DISPLAY_STYLE === 'seven') ? 'seven' : 'mono';
|
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;
|
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.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.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,
|
// Migrate stale profiles: detector ballistics are standards profiles,
|
||||||
// not user-adjustable display preferences.
|
// not user-adjustable display preferences.
|
||||||
CONFIG.PPM_DIN_ATTACK_MS = 10;
|
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;
|
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.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));
|
CONFIG.CORR_RESET_TOKEN = Math.max(0, Math.floor(Number(CONFIG.CORR_RESET_TOKEN) || 0));
|
||||||
const persistenceMode = String(CONFIG.GONIO_PERSISTENCE_MODE || 'fast').toLowerCase();
|
const persistenceMode = String(CONFIG.GONIO_PERSISTENCE_MODE || 'fast').toLowerCase();
|
||||||
CONFIG.GONIO_PERSISTENCE_MODE = ['fast', 'medium', 'slow', 'custom'].includes(persistenceMode)
|
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';
|
return (mode === '1_3' || mode === '1_6' || mode === '1_12') ? mode : '1_6';
|
||||||
};
|
};
|
||||||
const phaseAmplitudeMode = normalizePhaseAmplitudeMode(CONFIG.PHASE_AMPLITUDE_MODE);
|
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 {
|
return {
|
||||||
fftSize,
|
fftSize,
|
||||||
rtaBpoMode: normalizeRtaBpoMode(CONFIG.RTA_BPO_MODE),
|
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),
|
lrFractionalDelaySamples: Number.isFinite(CONFIG.LR_FRACTIONAL_DELAY_SAMPLES) ? CONFIG.LR_FRACTIONAL_DELAY_SAMPLES : (CONFIG_DEFAULTS.LR_FRACTIONAL_DELAY_SAMPLES ?? 0.996),
|
||||||
ppmDinAttackMs: 10,
|
ppmDinAttackMs: 10,
|
||||||
ppmDinDecayDbPerS: 20 / 1.5,
|
ppmDinDecayDbPerS: 20 / 1.5,
|
||||||
ppmDinFastAttack: !!CONFIG.PPM_DIN_FAST_ATTACK,
|
|
||||||
ppmEbuAttackMs: 10,
|
ppmEbuAttackMs: 10,
|
||||||
ppmEbuDecayDbPerS: 24 / 2.8,
|
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),
|
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,
|
lufsINormEnabled: !!CONFIG.LUFS_I_NORM_ENABLED,
|
||||||
ppmDinLoudnessBoxes: !!CONFIG.PPM_DIN_LOUDNESS_BOXES,
|
ppmDinLoudnessBoxes: !!CONFIG.PPM_DIN_LOUDNESS_BOXES,
|
||||||
@@ -1126,7 +1167,6 @@ function applyPhoenixGlobalConfig(payload = {}) {
|
|||||||
}
|
}
|
||||||
if (CONFIG.RTA_BAR_LAYOUT === 'rtw') {
|
if (CONFIG.RTA_BAR_LAYOUT === 'rtw') {
|
||||||
CONFIG.RTA_ENGINE = 'iir';
|
CONFIG.RTA_ENGINE = 'iir';
|
||||||
CONFIG.RTA_BPO_MODE = '1_3';
|
|
||||||
CONFIG.RTA_IIR_ORDER = 6;
|
CONFIG.RTA_IIR_ORDER = 6;
|
||||||
CONFIG.RTA_FREQ_RANGE = 'norm';
|
CONFIG.RTA_FREQ_RANGE = 'norm';
|
||||||
}
|
}
|
||||||
@@ -1135,9 +1175,12 @@ function applyPhoenixGlobalConfig(payload = {}) {
|
|||||||
if ('spectroScrollMode' in payload) CONFIG.SPECTRO_SCROLL_MODE = normalizeSpectroScroll(payload.spectroScrollMode);
|
if ('spectroScrollMode' in payload) CONFIG.SPECTRO_SCROLL_MODE = normalizeSpectroScroll(payload.spectroScrollMode);
|
||||||
CONFIG.PPM_DIN_ATTACK_MS = 10;
|
CONFIG.PPM_DIN_ATTACK_MS = 10;
|
||||||
CONFIG.PPM_DIN_DECAY_DB_PER_S = 20 / 1.5;
|
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_ATTACK_MS = 10;
|
||||||
CONFIG.PPM_EBU_DECAY_DB_PER_S = 24 / 2.8;
|
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);
|
const lufsWin = Number(payload.lufsIWindowMin);
|
||||||
if (Number.isFinite(lufsWin)) CONFIG.LUFS_I_WINDOW_MIN = Math.max(1, Math.min(10, Math.round(lufsWin)));
|
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;
|
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;
|
CONFIG.VU_RED_START = p.VU_RED_START;
|
||||||
|
|
||||||
// Offsets zurücksetzen (wie bisher)
|
// 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.TP_OFFSET_DB = 0;
|
||||||
CONFIG.RMS_OFFSET_DB = 0;
|
CONFIG.RMS_OFFSET_DB = 0;
|
||||||
|
|
||||||
|
|||||||
+11
-17
@@ -1,5 +1,6 @@
|
|||||||
// core/registry.js — Lazy Loader + Safe Runner für Meter
|
// core/registry.js — Lazy Loader + Safe Runner für Meter
|
||||||
// Lädt Meter-Module on-demand (oder via registerMeter) und kapselt Fehler.
|
// Lädt Meter-Module on-demand (oder via registerMeter) und kapselt Fehler.
|
||||||
|
import { createCanvasSurface } from './canvas_surface.js';
|
||||||
|
|
||||||
const cache = {
|
const cache = {
|
||||||
meters: new Map(), // id -> module
|
meters: new Map(), // id -> module
|
||||||
@@ -7,26 +8,12 @@ const cache = {
|
|||||||
|
|
||||||
const meterRenderCache = new Map(); // key -> surface
|
const meterRenderCache = new Map(); // key -> surface
|
||||||
const METER_CACHE_MARGIN = 32;
|
const METER_CACHE_MARGIN = 32;
|
||||||
|
const METER_RENDER_CACHE_LIMIT = 48;
|
||||||
let activeFrameStamp = 0;
|
let activeFrameStamp = 0;
|
||||||
|
|
||||||
const CAN_USE_OFFSCREEN = typeof OffscreenCanvas === 'function';
|
|
||||||
|
|
||||||
function createCacheSurface(width, height) {
|
function createCacheSurface(width, height) {
|
||||||
const w = Math.max(1, Math.ceil(width));
|
const surface = createCanvasSurface(width, height);
|
||||||
const h = Math.max(1, Math.ceil(height));
|
return surface ? { ...surface, stamp: -1 } : null;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cacheKeyForMeter(id, rect) {
|
function cacheKeyForMeter(id, rect) {
|
||||||
@@ -40,10 +27,17 @@ function cacheKeyForMeter(id, rect) {
|
|||||||
function ensureCacheSurface(id, rect) {
|
function ensureCacheSurface(id, rect) {
|
||||||
const key = cacheKeyForMeter(id, rect);
|
const key = cacheKeyForMeter(id, rect);
|
||||||
let surface = meterRenderCache.get(key);
|
let surface = meterRenderCache.get(key);
|
||||||
|
if (surface) {
|
||||||
|
meterRenderCache.delete(key);
|
||||||
|
meterRenderCache.set(key, surface);
|
||||||
|
}
|
||||||
if (!surface) {
|
if (!surface) {
|
||||||
surface = createCacheSurface(rect.w + METER_CACHE_MARGIN * 2, rect.h + METER_CACHE_MARGIN * 2);
|
surface = createCacheSurface(rect.w + METER_CACHE_MARGIN * 2, rect.h + METER_CACHE_MARGIN * 2);
|
||||||
if (!surface) return null;
|
if (!surface) return null;
|
||||||
meterRenderCache.set(key, surface);
|
meterRenderCache.set(key, surface);
|
||||||
|
while (meterRenderCache.size > METER_RENDER_CACHE_LIMIT) {
|
||||||
|
meterRenderCache.delete(meterRenderCache.keys().next().value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!surface.canvas || !surface.ctx) return null;
|
if (!surface.canvas || !surface.ctx) return null;
|
||||||
const w = Math.max(1, Math.ceil(rect.w + METER_CACHE_MARGIN * 2));
|
const w = Math.max(1, Math.ceil(rect.w + METER_CACHE_MARGIN * 2));
|
||||||
|
|||||||
+20
-10
@@ -138,22 +138,18 @@ export function buildBandBinMapping(bands, nyq, binCount) {
|
|||||||
const bins = [];
|
const bins = [];
|
||||||
const start = Math.max(0, Math.floor((band.fLo / nyq) * binCount));
|
const start = Math.max(0, Math.floor((band.fLo / nyq) * binCount));
|
||||||
const end = Math.min(binCount - 1, Math.ceil((band.fHi / nyq) * binCount));
|
const end = Math.min(binCount - 1, Math.ceil((band.fHi / nyq) * binCount));
|
||||||
let weightSum = 0;
|
|
||||||
for (let i = start; i <= end; i++) {
|
for (let i = start; i <= end; i++) {
|
||||||
const binStart = i * binWidth;
|
const binStart = i * binWidth;
|
||||||
const binEnd = binStart + binWidth;
|
const binEnd = binStart + binWidth;
|
||||||
const overlap = Math.max(0, Math.min(binEnd, band.fHi) - Math.max(binStart, band.fLo));
|
const overlap = Math.max(0, Math.min(binEnd, band.fHi) - Math.max(binStart, band.fLo));
|
||||||
if (overlap > 0) {
|
if (overlap > 0) {
|
||||||
bins.push({ index: i, weight: overlap });
|
bins.push({ index: i, weight: Math.min(1, overlap / binWidth) });
|
||||||
weightSum += overlap;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!bins.length) {
|
if (!bins.length) {
|
||||||
const idx = Math.max(0, Math.min(binCount - 1, Math.round((band.center / nyq) * binCount)));
|
const idx = Math.max(0, Math.min(binCount - 1, Math.round((band.center / nyq) * binCount)));
|
||||||
bins.push({ index: idx, weight: 1 });
|
bins.push({ index: idx, weight: 1 });
|
||||||
weightSum = 1;
|
|
||||||
}
|
}
|
||||||
bins.forEach((b) => { b.weight /= weightSum || 1; });
|
|
||||||
result.push({
|
result.push({
|
||||||
center: band.center,
|
center: band.center,
|
||||||
fLo: band.fLo,
|
fLo: band.fLo,
|
||||||
@@ -254,7 +250,9 @@ export function drawDbfsGridRect(ctx, x0, y0, w, h, CONFIG, nyq, opts = {}) {
|
|||||||
colorMinor = 'rgba(30,42,53,.55)',
|
colorMinor = 'rgba(30,42,53,.55)',
|
||||||
labelColor = '#8fd3d4',
|
labelColor = '#8fd3d4',
|
||||||
freqTicks = null,
|
freqTicks = null,
|
||||||
|
freqTickPositions = null,
|
||||||
freqMin = 20,
|
freqMin = 20,
|
||||||
|
xLeftExtension = 0,
|
||||||
refDb = null,
|
refDb = null,
|
||||||
refStyle = 'rgba(0,231,255,0.35)',
|
refStyle = 'rgba(0,231,255,0.35)',
|
||||||
refDash = [4, 3],
|
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 _g = Number(CONFIG && CONFIG.AXIS_GUTTER_LEFT);
|
||||||
const gutterL = Number.isFinite(_g) ? Math.max(8, _g) : 14;
|
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.save();
|
||||||
ctx.strokeStyle = '#00e7ff'; ctx.lineWidth = 2; ctx.strokeRect(x0 - gutterL, y0, w + gutterL, h);
|
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 y = yFromDbfs(v, y0, y0 + h, CONFIG);
|
||||||
const labelY = Math.min(y0 + h - 4, Math.max(y0 + 12, y + 4));
|
const labelY = Math.min(y0 + h - 4, Math.max(y0 + 12, y + 4));
|
||||||
ctx.strokeStyle = colorMajor; ctx.lineWidth = 1.2;
|
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);
|
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.strokeStyle = refStyle;
|
||||||
ctx.setLineDash(Array.isArray(refDash) ? refDash : [4, 3]);
|
ctx.setLineDash(Array.isArray(refDash) ? refDash : [4, 3]);
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(x0, yRef);
|
ctx.moveTo(contentX, yRef);
|
||||||
ctx.lineTo(x0 + w, yRef);
|
ctx.lineTo(contentX + contentW, yRef);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
}
|
}
|
||||||
@@ -303,9 +307,15 @@ export function drawDbfsGridRect(ctx, x0, y0, w, h, CONFIG, nyq, opts = {}) {
|
|||||||
const logMin = Math.log10(minFreq);
|
const logMin = Math.log10(minFreq);
|
||||||
const logSpan = Math.max(1e-6, Math.log10(nyq) - logMin);
|
const logSpan = Math.max(1e-6, Math.log10(nyq) - logMin);
|
||||||
for (const f of ticks) {
|
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
|
// 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.strokeStyle = '#131b24';
|
||||||
ctx.beginPath(); ctx.moveTo(x, y0); ctx.lineTo(x, y0 + h); ctx.stroke();
|
ctx.beginPath(); ctx.moveTo(x, y0); ctx.lineTo(x, y0 + h); ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|||||||
+155
-728
File diff suppressed because it is too large
Load Diff
+42
-5
@@ -51,6 +51,7 @@ import * as stopwatch from './meters/stopwatch.js';
|
|||||||
|
|
||||||
// Options UI
|
// Options UI
|
||||||
import { setupOptions } from './ui/options.js';
|
import { setupOptions } from './ui/options.js';
|
||||||
|
import { setupKioskBrowserBackup } from './ui/kiosk_browser_backup.js';
|
||||||
|
|
||||||
// --- Canvas & Context ---------------------------------------------------------
|
// --- Canvas & Context ---------------------------------------------------------
|
||||||
const C = document.getElementById('cv');
|
const C = document.getElementById('cv');
|
||||||
@@ -716,6 +717,7 @@ function buildProcessingProfile(viewId = style) {
|
|||||||
const profile = {
|
const profile = {
|
||||||
needXy: false,
|
needXy: false,
|
||||||
needRta: false,
|
needRta: false,
|
||||||
|
needSpectro: false,
|
||||||
needVu: false,
|
needVu: false,
|
||||||
needPpmDin: false,
|
needPpmDin: false,
|
||||||
needPpmEbu: false,
|
needPpmEbu: false,
|
||||||
@@ -728,6 +730,8 @@ function buildProcessingProfile(viewId = style) {
|
|||||||
for (const plotId of plotIds) {
|
for (const plotId of plotIds) {
|
||||||
if (plotId === 'realtime') {
|
if (plotId === 'realtime') {
|
||||||
profile.needRta = true;
|
profile.needRta = true;
|
||||||
|
} else if (plotId === 'spectrogram') {
|
||||||
|
profile.needSpectro = true;
|
||||||
} else if (plotId === 'goniometer-rtw') {
|
} else if (plotId === 'goniometer-rtw') {
|
||||||
profile.needXy = true;
|
profile.needXy = true;
|
||||||
profile.needRms = true;
|
profile.needRms = true;
|
||||||
@@ -792,6 +796,7 @@ function setSlotForView(viewId, idx, value){
|
|||||||
try { saveConfig?.(); } catch (_) {}
|
try { saveConfig?.(); } catch (_) {}
|
||||||
try { env?.audio?.updatePhoenixGlobalConfig?.(); } catch (_) {}
|
try { env?.audio?.updatePhoenixGlobalConfig?.(); } catch (_) {}
|
||||||
}
|
}
|
||||||
|
requestRender('processing-profile-change');
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetAllSlotsToDefaults() {
|
function resetAllSlotsToDefaults() {
|
||||||
@@ -804,6 +809,7 @@ function resetAllSlotsToDefaults() {
|
|||||||
saveSlotState(slotsState);
|
saveSlotState(slotsState);
|
||||||
saveSlotCustomizedState({});
|
saveSlotCustomizedState({});
|
||||||
refreshSlotEditors();
|
refreshSlotEditors();
|
||||||
|
requestRender('processing-profile-change');
|
||||||
}
|
}
|
||||||
function initSlotSelect(sel, idx){
|
function initSlotSelect(sel, idx){
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
@@ -1007,11 +1013,13 @@ initSplitPlotSelect(splitLeftSel, (val) => {
|
|||||||
const clean = sanitizeSplitPlotId(val) ?? 'none';
|
const clean = sanitizeSplitPlotId(val) ?? 'none';
|
||||||
CONFIG.SPLIT_VIEW_LEFT = clean;
|
CONFIG.SPLIT_VIEW_LEFT = clean;
|
||||||
saveConfig();
|
saveConfig();
|
||||||
|
requestRender('processing-profile-change');
|
||||||
});
|
});
|
||||||
initSplitPlotSelect(splitRightSel, (val) => {
|
initSplitPlotSelect(splitRightSel, (val) => {
|
||||||
const clean = sanitizeSplitPlotId(val) ?? 'none';
|
const clean = sanitizeSplitPlotId(val) ?? 'none';
|
||||||
CONFIG.SPLIT_VIEW_RIGHT = clean;
|
CONFIG.SPLIT_VIEW_RIGHT = clean;
|
||||||
saveConfig();
|
saveConfig();
|
||||||
|
requestRender('processing-profile-change');
|
||||||
});
|
});
|
||||||
|
|
||||||
function clampMeterCount3(v) {
|
function clampMeterCount3(v) {
|
||||||
@@ -1053,6 +1061,7 @@ function writeMeterToConfig(prefix, idx, val) {
|
|||||||
const key = getMeterKey(prefix, idx);
|
const key = getMeterKey(prefix, idx);
|
||||||
CONFIG[key] = sanitizeSlotId(val) ?? 'vu';
|
CONFIG[key] = sanitizeSlotId(val) ?? 'vu';
|
||||||
saveConfig();
|
saveConfig();
|
||||||
|
requestRender('processing-profile-change');
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSplitMeterPopupOpen(open) {
|
function setSplitMeterPopupOpen(open) {
|
||||||
@@ -1098,10 +1107,10 @@ function syncQuadPlotPopup() {
|
|||||||
if (quadPlotSelBR) quadPlotSelBR.value = sanitizeSplitPlotId(CONFIG.QUAD_VIEW_BR) ?? 'none';
|
if (quadPlotSelBR) quadPlotSelBR.value = sanitizeSplitPlotId(CONFIG.QUAD_VIEW_BR) ?? 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
initSplitPlotSelect(quadPlotSelTL, (val) => { CONFIG.QUAD_VIEW_TL = sanitizeSplitPlotId(val) ?? 'phase-wheel'; 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(); });
|
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(); });
|
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(); });
|
initSplitPlotSelect(quadPlotSelBR, (val) => { CONFIG.QUAD_VIEW_BR = sanitizeSplitPlotId(val) ?? 'none'; saveConfig(); requestRender('processing-profile-change'); });
|
||||||
|
|
||||||
if (splitMeterBtn) {
|
if (splitMeterBtn) {
|
||||||
splitMeterBtn.addEventListener('click', () => {
|
splitMeterBtn.addEventListener('click', () => {
|
||||||
@@ -1692,6 +1701,11 @@ const audioState = {
|
|||||||
xyL: null,
|
xyL: null,
|
||||||
xyR: null,
|
xyR: null,
|
||||||
xySeq: 0,
|
xySeq: 0,
|
||||||
|
phaseAngleRad: null,
|
||||||
|
phaseCoherence: 0,
|
||||||
|
phaseLevel: 0,
|
||||||
|
phasePeak: 0,
|
||||||
|
phaseSeq: 0,
|
||||||
rtaData: null,
|
rtaData: null,
|
||||||
rmsDb: { L: -120, R: -120, mono: -120 },
|
rmsDb: { L: -120, R: -120, mono: -120 },
|
||||||
updateRtaConfig: null,
|
updateRtaConfig: null,
|
||||||
@@ -1857,10 +1871,12 @@ function handleSplitTapUp(x, y) {
|
|||||||
if (env?.splitPopup?.open) {
|
if (env?.splitPopup?.open) {
|
||||||
env.splitPopup.open = false;
|
env.splitPopup.open = false;
|
||||||
env.splitPopup.viewId = null;
|
env.splitPopup.viewId = null;
|
||||||
|
requestRender('processing-profile-change');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
env.splitPopup.open = true;
|
env.splitPopup.open = true;
|
||||||
env.splitPopup.viewId = target;
|
env.splitPopup.viewId = target;
|
||||||
|
requestRender('processing-profile-change');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
env.switchView?.(target);
|
env.switchView?.(target);
|
||||||
@@ -1877,6 +1893,7 @@ function handleQuadTapUp(x, y) {
|
|||||||
// Popup ist offen: Tap innerhalb/außerhalb schließt
|
// Popup ist offen: Tap innerhalb/außerhalb schließt
|
||||||
env.quadPopup.open = false;
|
env.quadPopup.open = false;
|
||||||
env.quadPopup.viewId = null;
|
env.quadPopup.viewId = null;
|
||||||
|
requestRender('processing-profile-change');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isInAnyMeterHitRect(x, y)) return;
|
if (isInAnyMeterHitRect(x, y)) return;
|
||||||
@@ -1889,6 +1906,7 @@ function handleQuadTapUp(x, y) {
|
|||||||
if (env?.quadPopup) {
|
if (env?.quadPopup) {
|
||||||
env.quadPopup.open = true;
|
env.quadPopup.open = true;
|
||||||
env.quadPopup.viewId = target;
|
env.quadPopup.viewId = target;
|
||||||
|
requestRender('processing-profile-change');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1912,6 +1930,7 @@ C.addEventListener('pointerdown', (e) => {
|
|||||||
if (!env?.splitPopup?.open) return;
|
if (!env?.splitPopup?.open) return;
|
||||||
env.splitPopup.open = false;
|
env.splitPopup.open = false;
|
||||||
env.splitPopup.viewId = null;
|
env.splitPopup.viewId = null;
|
||||||
|
requestRender('processing-profile-change');
|
||||||
try { e.preventDefault?.(); } catch (_) {}
|
try { e.preventDefault?.(); } catch (_) {}
|
||||||
try { e.stopPropagation?.(); } catch (_) {}
|
try { e.stopPropagation?.(); } catch (_) {}
|
||||||
}, { capture: true, passive: false });
|
}, { capture: true, passive: false });
|
||||||
@@ -1968,6 +1987,7 @@ C.addEventListener('pointerdown', (e) => {
|
|||||||
if (pointInRect(x, y, box)) return;
|
if (pointInRect(x, y, box)) return;
|
||||||
env.quadPopup.open = false;
|
env.quadPopup.open = false;
|
||||||
env.quadPopup.viewId = null;
|
env.quadPopup.viewId = null;
|
||||||
|
requestRender('processing-profile-change');
|
||||||
try { e.preventDefault?.(); } catch (_) {}
|
try { e.preventDefault?.(); } catch (_) {}
|
||||||
try { e.stopPropagation?.(); } catch (_) {}
|
try { e.stopPropagation?.(); } catch (_) {}
|
||||||
}, { capture: true, passive: false });
|
}, { capture: true, passive: false });
|
||||||
@@ -2051,6 +2071,9 @@ if (peakHistScrollSel) {
|
|||||||
}
|
}
|
||||||
if (resetPeakBtn) {
|
if (resetPeakBtn) {
|
||||||
resetPeakBtn.addEventListener('click', () => {
|
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 {
|
try {
|
||||||
if (typeof window.resetRealTimeAnalyzerPeakHold === 'function') {
|
if (typeof window.resetRealTimeAnalyzerPeakHold === 'function') {
|
||||||
window.resetRealTimeAnalyzerPeakHold();
|
window.resetRealTimeAnalyzerPeakHold();
|
||||||
@@ -2612,9 +2635,14 @@ let audioRecoverInFlight = false;
|
|||||||
let lastAudioRecoverAt = 0;
|
let lastAudioRecoverAt = 0;
|
||||||
let renderDirty = true;
|
let renderDirty = true;
|
||||||
let lastRenderedAudioSeq = 0;
|
let lastRenderedAudioSeq = 0;
|
||||||
|
let processingProfileDirty = true;
|
||||||
|
let appliedProcessingProfileView = '';
|
||||||
|
|
||||||
|
const DATA_ONLY_RENDER_REASONS = new Set(['audio', 'spectro', 'visuals']);
|
||||||
|
|
||||||
function requestRender(reason = 'ui') {
|
function requestRender(reason = 'ui') {
|
||||||
renderDirty = true;
|
renderDirty = true;
|
||||||
|
if (!DATA_ONLY_RENDER_REASONS.has(reason)) processingProfileDirty = true;
|
||||||
env.__lastRenderReason = reason;
|
env.__lastRenderReason = reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2647,7 +2675,15 @@ async function loop(now){
|
|||||||
const elapsed = now - lastFrameTime;
|
const elapsed = now - lastFrameTime;
|
||||||
const audioOk = env.audio.alive && !audioLost(env);
|
const audioOk = env.audio.alive && !audioLost(env);
|
||||||
const renderStyle = getRenderableStyle();
|
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 targetMs = audioOk ? (1000 / desiredFpsForStyle(renderStyle)) : (1000 / 20);
|
||||||
const target = targetMs;
|
const target = targetMs;
|
||||||
const currentAudioSeq = Number.isFinite(env.audio?.xySeq) ? env.audio.xySeq : 0;
|
const currentAudioSeq = Number.isFinite(env.audio?.xySeq) ? env.audio.xySeq : 0;
|
||||||
@@ -2699,6 +2735,7 @@ async function loop(now){
|
|||||||
(async function boot(){
|
(async function boot(){
|
||||||
try{
|
try{
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
|
if (!await setupKioskBrowserBackup()) return;
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
const layoutParam = (() => {
|
const layoutParam = (() => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+12
-67
@@ -15,32 +15,25 @@ import { drawCachedStaticLayer } from './static_layer.js';
|
|||||||
import { METER_HEADER_FONT } from './scale_helpers.js';
|
import { METER_HEADER_FONT } from './scale_helpers.js';
|
||||||
import { HEADER_BG, LABEL_COLOR, OK_COLOR, WARN_COLOR } from '../core/theme.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 const id = 'rms';
|
||||||
|
|
||||||
export function initShared(CONFIG = {}) {
|
export function initShared(CONFIG = {}) {
|
||||||
const offset = CONFIG.RMS_OFFSET_DB || 0;
|
const initVal = -60;
|
||||||
const initVal = -60 + offset;
|
|
||||||
return {
|
return {
|
||||||
values: { L: initVal, R: initVal }, // intern immer dBFS RMS aus der Messkette
|
values: { L: initVal, R: initVal }, // unveränderte dBFS-RMS-Werte aus dem Backend
|
||||||
offset,
|
offset: 0,
|
||||||
lastValidL: initVal,
|
lastValidL: initVal,
|
||||||
lastValidR: initVal,
|
lastValidR: initVal,
|
||||||
_smooth: null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function update(packet, shared) {
|
export function update(packet, shared) {
|
||||||
const offset = shared.offset || 0;
|
const floor = -60;
|
||||||
const floor = -60 + offset;
|
|
||||||
if (!Number.isFinite(shared.lastValidL)) shared.lastValidL = floor;
|
if (!Number.isFinite(shared.lastValidL)) shared.lastValidL = floor;
|
||||||
if (!Number.isFinite(shared.lastValidR)) shared.lastValidR = floor;
|
if (!Number.isFinite(shared.lastValidR)) shared.lastValidR = floor;
|
||||||
|
|
||||||
if (Number.isFinite(packet?.rmsL)) {
|
if (Number.isFinite(packet?.rmsL)) {
|
||||||
const vL = packet.rmsL + offset;
|
const vL = packet.rmsL;
|
||||||
shared.values.L = vL;
|
shared.values.L = vL;
|
||||||
shared.lastValidL = vL;
|
shared.lastValidL = vL;
|
||||||
} else {
|
} else {
|
||||||
@@ -48,7 +41,7 @@ export function update(packet, shared) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Number.isFinite(packet?.rmsR)) {
|
if (Number.isFinite(packet?.rmsR)) {
|
||||||
const vR = packet.rmsR + offset;
|
const vR = packet.rmsR;
|
||||||
shared.values.R = vR;
|
shared.values.R = vR;
|
||||||
shared.lastValidR = vR;
|
shared.lastValidR = vR;
|
||||||
} else {
|
} else {
|
||||||
@@ -65,6 +58,7 @@ export function draw(g, rect, CONFIG = {}, shared) {
|
|||||||
|
|
||||||
// Anzeige-Umschaltung & Skala
|
// Anzeige-Umschaltung & Skala
|
||||||
const isDBU = (MODE === 'dbu');
|
const isDBU = (MODE === 'dbu');
|
||||||
|
const offset = Number.isFinite(CONFIG.RMS_OFFSET_DB) ? CONFIG.RMS_OFFSET_DB : 0;
|
||||||
const LOG_MIN = -60;
|
const LOG_MIN = -60;
|
||||||
const LOG_TOP = isDBU ? +24 : 0;
|
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.fillRect(rect.x, rect.y - 24, rect.w, 24);
|
||||||
g.restore();
|
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
|
// Werte in Anzeigeeinheit clampen
|
||||||
const rawDispL = toDisplay(shared._smooth?.L ?? shared.values.L);
|
const rawDispL = toDisplay(shared.values.L + offset);
|
||||||
const rawDispR = toDisplay(shared._smooth?.R ?? shared.values.R);
|
const rawDispR = toDisplay(shared.values.R + offset);
|
||||||
const smooth = smoothHeader(shared, rawDispL, rawDispR);
|
|
||||||
const vL = clamp(rawDispL, LOG_MIN, LOG_TOP);
|
const vL = clamp(rawDispL, LOG_MIN, LOG_TOP);
|
||||||
const vR = clamp(rawDispR, LOG_MIN, LOG_TOP);
|
const vR = clamp(rawDispR, LOG_MIN, LOG_TOP);
|
||||||
g.save();
|
g.save();
|
||||||
@@ -167,11 +135,11 @@ export function draw(g, rect, CONFIG = {}, shared) {
|
|||||||
const isRedL = rawDispL > RED_START;
|
const isRedL = rawDispL > RED_START;
|
||||||
const isRedR = rawDispR > RED_START;
|
const isRedR = rawDispR > RED_START;
|
||||||
g.fillStyle = isRedL ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
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.fillStyle = CONFIG.HEADER_TEXT_COLOR || MID_COLOR;
|
||||||
g.fillText('|', centerX, yText);
|
g.fillText('|', centerX, yText);
|
||||||
g.fillStyle = isRedR ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
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 {
|
} else {
|
||||||
const title = isDBU ? 'RMS (dBu)' : 'RMS (dBFS RMS)';
|
const title = isDBU ? 'RMS (dBu)' : 'RMS (dBFS RMS)';
|
||||||
g.fillText(title, centerX, rect.y - 12);
|
g.fillText(title, centerX, rect.y - 12);
|
||||||
@@ -284,7 +252,7 @@ function drawRmsStaticOverlay(g, shared, rect, CONFIG, geom) {
|
|||||||
bottomValue: LOG_MIN,
|
bottomValue: LOG_MIN,
|
||||||
highlightTick: null,
|
highlightTick: null,
|
||||||
});
|
});
|
||||||
const unitLabel = 'dBFS (RMS)';
|
const unitLabel = isDBU ? 'dBu (RMS)' : 'dBFS (RMS)';
|
||||||
cg.fillStyle = LABEL_COLOR;
|
cg.fillStyle = LABEL_COLOR;
|
||||||
cg.textAlign = 'center';
|
cg.textAlign = 'center';
|
||||||
const baseY = rect.y + rect.h + 16;
|
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 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) {
|
function drawRedStripeLR(g, leftX, barW, rightX, centerX, mapY, yWarn, yTop) {
|
||||||
const innerRight = leftX + barW;
|
const innerRight = leftX + barW;
|
||||||
const gapL = Math.abs(centerX - innerRight);
|
const gapL = Math.abs(centerX - innerRight);
|
||||||
@@ -491,13 +446,3 @@ function getRmsAlignmentHighlight(CONFIG) {
|
|||||||
color: WARN_COLOR,
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,22 +1,4 @@
|
|||||||
const CAN_USE_OFFSCREEN = typeof OffscreenCanvas === 'function';
|
import { createCanvasSurface } from '../core/canvas_surface.js';
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function drawCachedStaticLayer(targetCtx, shared, layerId, key, x, y, width, height, build) {
|
export function drawCachedStaticLayer(targetCtx, shared, layerId, key, x, y, width, height, build) {
|
||||||
if (!targetCtx || !shared || typeof build !== 'function') return false;
|
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;
|
|| layer.height !== h;
|
||||||
|
|
||||||
if (needsRebuild) {
|
if (needsRebuild) {
|
||||||
layer = createSurface(w, h);
|
layer = createCanvasSurface(w, h);
|
||||||
if (!layer) return false;
|
if (!layer) return false;
|
||||||
layer.key = key;
|
layer.key = key;
|
||||||
layer.ctx.save();
|
layer.ctx.save();
|
||||||
|
|||||||
+34
-34
@@ -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 { createPeakHoldState, stepPeakHold } from '../core/utils.js';
|
||||||
import { drawHairlineGrid, METER_HEADER_FONT } from './scale_helpers.js';
|
import { drawHairlineGrid, METER_HEADER_FONT } from './scale_helpers.js';
|
||||||
import { drawCachedStaticLayer } from './static_layer.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';
|
import { HEADER_BG, LABEL_COLOR, MID_COLOR, WARN_COLOR } from '../core/theme.js';
|
||||||
|
|
||||||
export const id = 'tp';
|
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) {
|
export function initShared(CONFIG) {
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
return {
|
return {
|
||||||
@@ -42,22 +29,22 @@ export function update(packet, shared) {
|
|||||||
shared.values.R = R + (shared.offset || 0);
|
shared.values.R = R + (shared.offset || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOG_MIN = TP_PERCENT_SCALE[0].db;
|
const LOG_MIN = TRUE_PEAK_SCALE[0].db;
|
||||||
const LOG_TOP = TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].db;
|
const LOG_TOP = TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].db;
|
||||||
|
|
||||||
function interpolateFrac(db) {
|
function interpolateFrac(db) {
|
||||||
if (db <= TP_PERCENT_SCALE[0].db) return TP_PERCENT_SCALE[0].frac;
|
if (db <= TRUE_PEAK_SCALE[0].db) return TRUE_PEAK_SCALE[0].frac;
|
||||||
if (db >= TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].db) return TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].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 < TP_PERCENT_SCALE.length; i++) {
|
for (let i = 1; i < TRUE_PEAK_SCALE.length; i++) {
|
||||||
const prev = TP_PERCENT_SCALE[i - 1];
|
const prev = TRUE_PEAK_SCALE[i - 1];
|
||||||
const curr = TP_PERCENT_SCALE[i];
|
const curr = TRUE_PEAK_SCALE[i];
|
||||||
if (db <= curr.db) {
|
if (db <= curr.db) {
|
||||||
const span = curr.db - prev.db || 1;
|
const span = curr.db - prev.db || 1;
|
||||||
const t = (db - prev.db) / span;
|
const t = (db - prev.db) / span;
|
||||||
return prev.frac + t * (curr.frac - prev.frac);
|
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) {
|
export function draw(g, rect, CONFIG, shared) {
|
||||||
@@ -122,13 +109,30 @@ export function draw(g, rect, CONFIG, shared) {
|
|||||||
g.font = prevFont;
|
g.font = prevFont;
|
||||||
g.restore();
|
g.restore();
|
||||||
const yFloor = mapY(LOG_MIN);
|
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 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 drawBar = (x0, val) => {
|
||||||
const yVal = mapY(val);
|
const yVal = mapY(val);
|
||||||
const colNorm = CONFIG.TP_COLOR_NORMAL;
|
if (val > redStart) {
|
||||||
g.fillStyle = colNorm;
|
if (redOnly) {
|
||||||
g.fillRect(x0 + 1, yVal, innerW, Math.max(0, yFloor - yVal));
|
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
|
// Glanzkante
|
||||||
g.globalAlpha = .12;
|
g.globalAlpha = .12;
|
||||||
g.fillStyle = '#fff';
|
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) {
|
function drawOverlayTicksLR(g, leftX, rightX, widthPx, mapY, alignmentTick = null) {
|
||||||
if (!g) return;
|
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();
|
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 y = Math.round(mapY(point.db)) + 0.5;
|
||||||
const isHighlight = highlightValue !== null && approx(point.db, highlightValue);
|
const isHighlight = highlightValue !== null && approx(point.db, highlightValue);
|
||||||
if (isHighlight) highlightMatched = true;
|
if (isHighlight) highlightMatched = true;
|
||||||
drawMajor(y, isHighlight);
|
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 y = Math.round(mapY(db)) + 0.5;
|
||||||
const isHighlight = highlightValue !== null && approx(db, highlightValue);
|
const isHighlight = highlightValue !== null && approx(db, highlightValue);
|
||||||
if (isHighlight) highlightMatched = true;
|
if (isHighlight) highlightMatched = true;
|
||||||
@@ -289,11 +290,10 @@ function drawScale(g, colRect, centerX, mapY, CONFIG) {
|
|||||||
g.textAlign = 'center';
|
g.textAlign = 'center';
|
||||||
g.textBaseline = 'alphabetic';
|
g.textBaseline = 'alphabetic';
|
||||||
|
|
||||||
for (const point of TP_PERCENT_SCALE) {
|
for (const point of TRUE_PEAK_SCALE) {
|
||||||
const y = mapY(point.db);
|
const y = mapY(point.db);
|
||||||
if (y < colRect.y || y > colRect.y + colRect.h) continue;
|
if (y < colRect.y || y > colRect.y + colRect.h) continue;
|
||||||
const value = Math.abs(point.db);
|
const dbLabel = point.db > 0 ? `+${point.db}` : String(point.db);
|
||||||
const dbLabel = Number.isInteger(value) ? String(value) : value.toFixed(1);
|
|
||||||
g.fillText(dbLabel, centerX, y + 5);
|
g.fillText(dbLabel, centerX, y + 5);
|
||||||
}
|
}
|
||||||
g.font = prevFont;
|
g.font = prevFont;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]);
|
||||||
@@ -415,3 +415,15 @@ canvas.waveform-layer{
|
|||||||
|
|
||||||
#optionsPanelNew summary{font-size:17px;padding:8px 4px;}
|
#optionsPanelNew summary{font-size:17px;padding:8px 4px;}
|
||||||
#optionsPanelNew .opt{font-size:16px;}
|
#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; }
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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 = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
@@ -1,13 +1,15 @@
|
|||||||
// ui/options.js — bindet das Options-Panel an CONFIG (lesen/schreiben), Presets, Export/Import
|
// 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'.
|
// 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 { 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
|
// DOM helper
|
||||||
const E = (id) => document.getElementById(id);
|
const E = (id) => document.getElementById(id);
|
||||||
const PPM_LOUD_WARN_KEY = 'ppm_din_loudness_warn_ack_v1';
|
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() {
|
function getPpmDinVisibleBaseOffset() {
|
||||||
const baseMode = (CONFIG.PPM_DIN_MODE === 'al_minus6') ? -6 : -9;
|
const baseMode = (CONFIG.PPM_DIN_MODE === 'al_minus6') ? -6 : -9;
|
||||||
@@ -81,113 +83,26 @@ function showPpmLoudnessWarning(onConfirm) {
|
|||||||
wrap.style.display = 'flex';
|
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) {
|
export function setupOptions(env) {
|
||||||
// Initial laden
|
// Initial laden
|
||||||
loadConfig();
|
loadConfig();
|
||||||
enforcePhaseAmplitudeConstraints({ persist: true });
|
enforcePhaseAmplitudeConstraints({ persist: true });
|
||||||
syncUI();
|
syncUI();
|
||||||
wireHandlers(env);
|
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);
|
bindAlignmentToggle(env);
|
||||||
bindVuRefToggle(env);
|
bindVuRefToggle(env);
|
||||||
// Sichtbarkeit nach View steuern übernimmt main.js — hier nur Export der Sync-Funktion
|
// Sichtbarkeit nach View steuern übernimmt main.js — hier nur Export der Sync-Funktion
|
||||||
return { syncUI };
|
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() {
|
function syncSoftwarePresetControls() {
|
||||||
const presetId = String(E('opt_softwarePreset')?.value || CONFIG.SOFTWARE_PRESET || 'default').toLowerCase();
|
const presetId = String(E('opt_softwarePreset')?.value || CONFIG.SOFTWARE_PRESET || 'default').toLowerCase();
|
||||||
const isDefault = presetId === 'default';
|
const isDefault = presetId === 'default';
|
||||||
@@ -237,7 +152,6 @@ function syncUI() {
|
|||||||
['opt_ppmDinColWarn', CONFIG.PPM_DIN_COLOR_WARN],
|
['opt_ppmDinColWarn', CONFIG.PPM_DIN_COLOR_WARN],
|
||||||
['opt_ppmDinHeaderValue', CONFIG.PPM_DIN_HEADER_SHOW_VALUE, null, null, 'checkbox'],
|
['opt_ppmDinHeaderValue', CONFIG.PPM_DIN_HEADER_SHOW_VALUE, null, null, 'checkbox'],
|
||||||
['opt_ppmDinLoudnessBox', CONFIG.PPM_DIN_LOUDNESS_BOXES, 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_ppmDinLoudOff', CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB ?? 0, 'val_ppmDinLoudOff', (v)=>`${Number(v).toFixed(1)} dB`],
|
||||||
['opt_ppmEbuColNorm', CONFIG.PPM_EBU_COLOR_NORMAL],
|
['opt_ppmEbuColNorm', CONFIG.PPM_EBU_COLOR_NORMAL],
|
||||||
['opt_ppmEbuColWarn', CONFIG.PPM_EBU_COLOR_WARN],
|
['opt_ppmEbuColWarn', CONFIG.PPM_EBU_COLOR_WARN],
|
||||||
@@ -270,7 +184,8 @@ function syncUI() {
|
|||||||
['opt_rmsColNorm', CONFIG.RMS_COLOR_NORMAL],
|
['opt_rmsColNorm', CONFIG.RMS_COLOR_NORMAL],
|
||||||
['opt_rmsColWarn', CONFIG.RMS_COLOR_WARN],
|
['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_xyPoints', String(CONFIG.XY_POINTS)],
|
||||||
['opt_xyStyle', CONFIG.XY_STYLE],
|
['opt_xyStyle', CONFIG.XY_STYLE],
|
||||||
['opt_xySilenceGate', CONFIG.XY_SILENCE_GATE_ENABLED, null, null, 'checkbox'],
|
['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_barThin', CONFIG.METER_BAR_THIN, 'val_barThin', (v)=>Number(v).toFixed(2)],
|
||||||
['opt_headerTextColor', CONFIG.HEADER_TEXT_COLOR || '#ffe066'],
|
['opt_headerTextColor', CONFIG.HEADER_TEXT_COLOR || '#ffe066'],
|
||||||
['opt_rtaEngine', CONFIG.RTA_ENGINE || 'iir'],
|
['opt_rtaEngine', CONFIG.RTA_ENGINE || 'iir'],
|
||||||
|
['opt_rtaFilterbank', CONFIG.RTA_IIR_FILTERBANK || 'legacy'],
|
||||||
['opt_rtaBpo', CONFIG.RTA_BPO_MODE || '1_6'],
|
['opt_rtaBpo', CONFIG.RTA_BPO_MODE || '1_6'],
|
||||||
['opt_rtaOrder', CONFIG.RTA_IIR_ORDER ?? 4],
|
['opt_rtaOrder', CONFIG.RTA_IIR_ORDER ?? 4],
|
||||||
['opt_rtaDisplayGainFft', CONFIG.RTA_DISPLAY_GAIN_FFT_DB ?? 12, 'val_rtaDisplayGainFft', (v)=>`${v} dB`],
|
['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_rtaLayout', CONFIG.RTA_BAR_LAYOUT || 'iec'],
|
||||||
['opt_rtaBarBaseColor', CONFIG.RTA_BAR_BASE_COLOR || '#ffe066'],
|
['opt_rtaBarBaseColor', CONFIG.RTA_BAR_BASE_COLOR || '#ffe066'],
|
||||||
['opt_rtaWeighting', CONFIG.RTA_WEIGHTING || 'z'],
|
['opt_rtaWeighting', CONFIG.RTA_WEIGHTING || 'z'],
|
||||||
|
['opt_rtaDetector', CONFIG.RTA_DETECTOR || 'average'],
|
||||||
['opt_rtaIntegration', CONFIG.RTA_INTEGRATION || 'fast'],
|
['opt_rtaIntegration', CONFIG.RTA_INTEGRATION || 'fast'],
|
||||||
['opt_rtaBallistics', CONFIG.RTA_BALLISTICS_MODE || 'average'],
|
['opt_rtaBallistics', CONFIG.RTA_BALLISTICS_MODE || 'average'],
|
||||||
['opt_rtaHoldMode', CONFIG.RTA_PEAK_HOLD_MODE || 'auto'],
|
['opt_rtaHoldMode', CONFIG.RTA_PEAK_HOLD_MODE || 'auto'],
|
||||||
@@ -302,7 +219,7 @@ function syncUI() {
|
|||||||
['opt_waveColorL', CONFIG.WAVEFORM_COLOR_LEFT || '#00e7ff'],
|
['opt_waveColorL', CONFIG.WAVEFORM_COLOR_LEFT || '#00e7ff'],
|
||||||
['opt_waveColorR', CONFIG.WAVEFORM_COLOR_RIGHT || '#ff6b81'],
|
['opt_waveColorR', CONFIG.WAVEFORM_COLOR_RIGHT || '#ff6b81'],
|
||||||
['opt_waveColorDiff', CONFIG.WAVEFORM_COLOR_DIFF || '#00e7ff'],
|
['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_rtaTauSlow', CONFIG.RTA_IIR_TAU_SLOW ?? 1.0],
|
||||||
['opt_rtRenderMode', CONFIG.REALTIME_RENDER_STYLE || 'bars'],
|
['opt_rtRenderMode', CONFIG.REALTIME_RENDER_STYLE || 'bars'],
|
||||||
['opt_rtHold', CONFIG.REALTIME_BAR_HOLD_MS],
|
['opt_rtHold', CONFIG.REALTIME_BAR_HOLD_MS],
|
||||||
@@ -417,6 +334,12 @@ function syncUI() {
|
|||||||
|
|
||||||
const iWin = E('opt_lufsIWindowMin');
|
const iWin = E('opt_lufsIWindowMin');
|
||||||
if (iWin) iWin.disabled = !!CONFIG.LUFS_I_NORM_ENABLED;
|
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');
|
const mp3Wrap = E('opt_recMp3BitrateWrap');
|
||||||
if (mp3Wrap) mp3Wrap.style.display = (String(CONFIG.RECORD_OUTPUT_FORMAT || 'wav') === 'mp3') ? '' : 'none';
|
if (mp3Wrap) mp3Wrap.style.display = (String(CONFIG.RECORD_OUTPUT_FORMAT || 'wav') === 'mp3') ? '' : 'none';
|
||||||
const mp3Sel = E('opt_recMp3Bitrate');
|
const mp3Sel = E('opt_recMp3Bitrate');
|
||||||
@@ -534,8 +457,8 @@ function wireHandlers(env) {
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
const notifyRta = /^opt_rta/i.test(id)
|
const notifyRta = /^opt_rta/i.test(id)
|
||||||
|| id === 'opt_lufsIWindowMin'
|
|| id === 'opt_lufsIWindowMin'
|
||||||
|| id === 'opt_lufsINorm';
|
|| id === 'opt_lufsINorm'
|
||||||
const notifyPpm = (id === 'opt_ppmDinFastAttack');
|
|| id === 'opt_xySilenceThr';
|
||||||
const inputType = String(el.type || '').toLowerCase();
|
const inputType = String(el.type || '').toLowerCase();
|
||||||
const commitOnInput = isCheckbox || inputType === 'range' || inputType === 'color';
|
const commitOnInput = isCheckbox || inputType === 'range' || inputType === 'color';
|
||||||
|
|
||||||
@@ -549,9 +472,6 @@ function wireHandlers(env) {
|
|||||||
if (notifyRta) {
|
if (notifyRta) {
|
||||||
try { env?.notifyRtaConfig?.(); } catch (_) {}
|
try { env?.notifyRtaConfig?.(); } catch (_) {}
|
||||||
}
|
}
|
||||||
if (notifyPpm) {
|
|
||||||
try { env?.notifyPpmConfig?.(); } catch (_) {}
|
|
||||||
}
|
|
||||||
try { env?.syncRealtimeGain?.(); } catch (_) {}
|
try { env?.syncRealtimeGain?.(); } catch (_) {}
|
||||||
try { env?.syncWaveformWindow?.(); } catch (_) {}
|
try { env?.syncWaveformWindow?.(); } catch (_) {}
|
||||||
try { env?.syncWaveformMode?.(); } catch (_) {}
|
try { env?.syncWaveformMode?.(); } catch (_) {}
|
||||||
@@ -780,29 +700,6 @@ function wireHandlers(env) {
|
|||||||
CONFIG.PPM_DIN_HEADER_SHOW_VALUE = !!v;
|
CONFIG.PPM_DIN_HEADER_SHOW_VALUE = !!v;
|
||||||
return CONFIG.PPM_DIN_HEADER_SHOW_VALUE;
|
return CONFIG.PPM_DIN_HEADER_SHOW_VALUE;
|
||||||
}, null, null, true);
|
}, 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 => {
|
h('opt_ppmDinLoudnessBox', v => {
|
||||||
const desired = !!v;
|
const desired = !!v;
|
||||||
const cb = E('opt_ppmDinLoudnessBox');
|
const cb = E('opt_ppmDinLoudnessBox');
|
||||||
@@ -866,9 +763,9 @@ function wireHandlers(env) {
|
|||||||
}, null, null, true);
|
}, null, null, true);
|
||||||
h('opt_rmsTc', v => {
|
h('opt_rmsTc', v => {
|
||||||
const mode = String(v).toLowerCase();
|
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_MODE = mode;
|
||||||
CONFIG.RMS_TC_MS = null;
|
notifyPhoenixGlobalConfig();
|
||||||
}
|
}
|
||||||
return CONFIG.RMS_TC_MODE;
|
return CONFIG.RMS_TC_MODE;
|
||||||
});
|
});
|
||||||
@@ -917,10 +814,17 @@ function wireHandlers(env) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
h('opt_corrResponse', v => {
|
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 (_) {}
|
try { env?.notifyRtaConfig?.(); } catch (_) {}
|
||||||
return String(CONFIG.CORR_RESPONSE_S);
|
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');
|
const corrResetPeak = E('opt_corrResetPeak');
|
||||||
if (corrResetPeak) corrResetPeak.onclick = () => {
|
if (corrResetPeak) corrResetPeak.onclick = () => {
|
||||||
CONFIG.CORR_RESET_TOKEN = Math.max(0, Math.floor(Number(CONFIG.CORR_RESET_TOKEN) || 0)) + 1;
|
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');
|
CONFIG.RTA_ENGINE = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'iir' : ((v === 'iir') ? 'iir' : 'fft');
|
||||||
return CONFIG.RTA_ENGINE;
|
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 => {
|
h('opt_rtaBpo', v => {
|
||||||
applyRtaBpoSelection(v);
|
applyRtaBpoSelection(v);
|
||||||
const layoutControl = E('opt_rtaLayout');
|
const layoutControl = E('opt_rtaLayout');
|
||||||
@@ -1104,12 +1012,10 @@ function wireHandlers(env) {
|
|||||||
CONFIG.RTA_BAR_LAYOUT = val;
|
CONFIG.RTA_BAR_LAYOUT = val;
|
||||||
if (val === 'rtw') {
|
if (val === 'rtw') {
|
||||||
CONFIG.RTA_ENGINE = 'iir';
|
CONFIG.RTA_ENGINE = 'iir';
|
||||||
CONFIG.RTA_BPO_MODE = '1_3';
|
|
||||||
CONFIG.RTA_IIR_ORDER = 6;
|
CONFIG.RTA_IIR_ORDER = 6;
|
||||||
CONFIG.RTA_FREQ_RANGE = 'norm';
|
CONFIG.RTA_FREQ_RANGE = 'norm';
|
||||||
const forced = {
|
const forced = {
|
||||||
opt_rtaEngine: 'iir',
|
opt_rtaEngine: 'iir',
|
||||||
opt_rtaBpo: '1_3',
|
|
||||||
opt_rtaOrder: '6',
|
opt_rtaOrder: '6',
|
||||||
opt_rtaFreq: 'norm',
|
opt_rtaFreq: 'norm',
|
||||||
};
|
};
|
||||||
@@ -1130,8 +1036,14 @@ function wireHandlers(env) {
|
|||||||
const allowed = new Set(['z','a','c']);
|
const allowed = new Set(['z','a','c']);
|
||||||
CONFIG.RTA_WEIGHTING = allowed.has(v) ? v : 'z';
|
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 => {
|
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';
|
CONFIG.RTA_INTEGRATION = allowed.has(v) ? v : 'fast';
|
||||||
return CONFIG.RTA_INTEGRATION;
|
return CONFIG.RTA_INTEGRATION;
|
||||||
});
|
});
|
||||||
@@ -1141,9 +1053,10 @@ function wireHandlers(env) {
|
|||||||
return val;
|
return val;
|
||||||
});
|
});
|
||||||
h('opt_rtaHoldMode', v => {
|
h('opt_rtaHoldMode', v => {
|
||||||
const allowed = new Set(['off','auto','manual']);
|
const allowed = new Set(['off','auto','fall','manual']);
|
||||||
CONFIG.RTA_PEAK_HOLD_MODE = allowed.has(v) ? v : 'auto';
|
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 => {
|
h('opt_rtaHoldTime', v => {
|
||||||
const requested = clamp(+v, 0, 30);
|
const requested = clamp(+v, 0, 30);
|
||||||
CONFIG.RTA_PEAK_HOLD_SEC = CONFIG.RTA_BAR_LAYOUT === 'rtw'
|
CONFIG.RTA_PEAK_HOLD_SEC = CONFIG.RTA_BAR_LAYOUT === 'rtw'
|
||||||
@@ -1151,7 +1064,6 @@ function wireHandlers(env) {
|
|||||||
: requested;
|
: requested;
|
||||||
return CONFIG.RTA_PEAK_HOLD_SEC;
|
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_rtaDisplayHold', v => { CONFIG.RTA_DISPLAY_HOLD_SEC = clamp(+v, 0, 5); });
|
||||||
h('opt_spectroGamma', v => {
|
h('opt_spectroGamma', v => {
|
||||||
const num = clamp(parseFloat(v), 0.3, 1.2);
|
const num = clamp(parseFloat(v), 0.3, 1.2);
|
||||||
@@ -1209,8 +1121,8 @@ function wireHandlers(env) {
|
|||||||
CONFIG.WAVEFORM_COLOR_DIFF = v;
|
CONFIG.WAVEFORM_COLOR_DIFF = v;
|
||||||
notifyPhoenixGlobalConfig();
|
notifyPhoenixGlobalConfig();
|
||||||
});
|
});
|
||||||
h('opt_rtaTauFast', v => { CONFIG.RTA_IIR_TAU_FAST = clamp(+v, 0.01, 1.0); });
|
h('opt_rtaTauFast', () => { CONFIG.RTA_IIR_TAU_FAST = 0.125; return 0.125; });
|
||||||
h('opt_rtaTauSlow', v => { CONFIG.RTA_IIR_TAU_SLOW = clamp(+v, 0.1, 5.0); });
|
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_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_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));
|
h('opt_rtDecay', v => CONFIG.REALTIME_BAR_DECAY_DB_PER_S = clamp(+v, 1, 60));
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
// Nutzt die bestehenden VU-Werte (inkl. Offsets/Kalibrierung) und zeichnet
|
// Nutzt die bestehenden VU-Werte (inkl. Offsets/Kalibrierung) und zeichnet
|
||||||
// zwei kompakte, horizontale Nadelinstrumente (L/R) mit Slot-Auswahl.
|
// zwei kompakte, horizontale Nadelinstrumente (L/R) mit Slot-Auswahl.
|
||||||
import { drawCachedStaticLayer } from './static_layer.js';
|
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';
|
export const id = 'classic-needles';
|
||||||
|
|
||||||
@@ -47,22 +48,6 @@ const DIN_SCALE = [
|
|||||||
{ db: +5, pos: 1.0000 },
|
{ 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_MAJOR_TICKS = [-12, -8, -4, 0, +4, +8, +12];
|
||||||
const PPM_EBU_MINOR_TICKS = [-10, -6, -2, +2, +6, +9, +10];
|
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 raw = readMeterDisplayLR(meterId, meterState, CONFIG, scale, audio);
|
||||||
const targets = { L: scale.valueToNorm(raw.L), R: scale.valueToNorm(raw.R) };
|
const targets = { L: scale.valueToNorm(raw.L), R: scale.valueToNorm(raw.R) };
|
||||||
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
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)
|
// Box-Abmessungen an den Real-Time-Analyzer anlehnen (gleiche Offsets)
|
||||||
const BOX_LEFT = 0;
|
const BOX_LEFT = 0;
|
||||||
const BOX_TOP = Number.isFinite(env?.topInset) ? Number(env.topInset) : 70;
|
const BOX_TOP = Number.isFinite(env?.topInset) ? Number(env.topInset) : 70;
|
||||||
@@ -425,24 +422,24 @@ function getNeedleScaleDescriptor(meterId, CONFIG) {
|
|||||||
|
|
||||||
if (meterId === 'tp') {
|
if (meterId === 'tp') {
|
||||||
const bottom = -60;
|
const bottom = -60;
|
||||||
const top = 0;
|
const top = 6;
|
||||||
const redStart = Number.isFinite(CONFIG?.TP_RED_START) ? CONFIG.TP_RED_START : -1;
|
const redStart = Number.isFinite(CONFIG?.TP_RED_START) ? CONFIG.TP_RED_START : -1;
|
||||||
const warnColor = CONFIG?.TP_COLOR_WARN || '#ff3b3b';
|
const warnColor = CONFIG?.TP_COLOR_WARN || '#ff3b3b';
|
||||||
const normalColor = CONFIG?.TP_COLOR_NORMAL || '#ffe066';
|
const normalColor = CONFIG?.TP_COLOR_NORMAL || '#ffe066';
|
||||||
const mapRaw = (db) => {
|
const mapRaw = (db) => {
|
||||||
const clamped = clamp(db, bottom, top);
|
const clamped = clamp(db, bottom, top);
|
||||||
if (clamped <= TP_PERCENT_SCALE[0].db) return TP_PERCENT_SCALE[0].frac;
|
if (clamped <= TRUE_PEAK_SCALE[0].db) return TRUE_PEAK_SCALE[0].frac;
|
||||||
if (clamped >= TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].db) return TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].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 < TP_PERCENT_SCALE.length; i++) {
|
for (let i = 1; i < TRUE_PEAK_SCALE.length; i++) {
|
||||||
const prev = TP_PERCENT_SCALE[i - 1];
|
const prev = TRUE_PEAK_SCALE[i - 1];
|
||||||
const curr = TP_PERCENT_SCALE[i];
|
const curr = TRUE_PEAK_SCALE[i];
|
||||||
if (clamped <= curr.db) {
|
if (clamped <= curr.db) {
|
||||||
const span = curr.db - prev.db || 1;
|
const span = curr.db - prev.db || 1;
|
||||||
const t = (clamped - prev.db) / span;
|
const t = (clamped - prev.db) / span;
|
||||||
return prev.frac + t * (curr.frac - prev.frac);
|
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));
|
const valueToNorm = (v) => clamp01(mapRaw(v));
|
||||||
return {
|
return {
|
||||||
@@ -454,11 +451,10 @@ function getNeedleScaleDescriptor(meterId, CONFIG) {
|
|||||||
warnColor,
|
warnColor,
|
||||||
normalColor,
|
normalColor,
|
||||||
unitLabel: 'dBTP',
|
unitLabel: 'dBTP',
|
||||||
majorTicks: TP_PERCENT_SCALE.map((p) => p.db),
|
majorTicks: TRUE_PEAK_SCALE.map((p) => p.db),
|
||||||
minorTicks: TP_EXTRA_MINOR_TICKS,
|
minorTicks: TRUE_PEAK_MINOR_TICKS,
|
||||||
formatMajor: (db) => {
|
formatMajor: (db) => {
|
||||||
const v = Math.abs(db);
|
return db > 0 ? `+${db}` : String(db);
|
||||||
return Number.isInteger(v) ? String(v) : v.toFixed(1);
|
|
||||||
},
|
},
|
||||||
valueToNorm,
|
valueToNorm,
|
||||||
};
|
};
|
||||||
@@ -633,7 +629,7 @@ function readMeterDisplayLR(meterId, shared, CONFIG, scale, audio) {
|
|||||||
const isDBU = (mode === 'dbu');
|
const isDBU = (mode === 'dbu');
|
||||||
if (isDBU) {
|
if (isDBU) {
|
||||||
const refDbfs = Number.isFinite(CONFIG?.RMS_REF_DBFS_FOR_REF_DBU) ? CONFIG.RMS_REF_DBFS_FOR_REF_DBU : -18;
|
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;
|
rawL = (rawL - refDbfs) + refDbu;
|
||||||
rawR = (rawR - refDbfs) + refDbu;
|
rawR = (rawR - refDbfs) + refDbu;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 BASE_GONIO_SCALE = (BASE_TARGET / ALIGN_PEAK) * Math.pow(10, -BASE_HEADROOM_DB / 20) * BASE_ZOOM;
|
||||||
const AGC_TARGET_DB = linearToDb(ALIGN_PEAK);
|
const AGC_TARGET_DB = linearToDb(ALIGN_PEAK);
|
||||||
const CORR_SILENCE_THRESHOLD_DEFAULT = -75;
|
const CORR_SILENCE_THRESHOLD_DEFAULT = -75;
|
||||||
|
const CORR_HOLD_MS = 4000;
|
||||||
const MAX_TRAIL_FRAMES = 48;
|
const MAX_TRAIL_FRAMES = 48;
|
||||||
|
|
||||||
function computePanelSlotWidth(canvasWidth, slotCount = METER_SLOTS, slotGap = 12) {
|
function computePanelSlotWidth(canvasWidth, slotCount = METER_SLOTS, slotGap = 12) {
|
||||||
@@ -48,6 +49,10 @@ export function init() {
|
|||||||
staticLayerCanvas: null,
|
staticLayerCanvas: null,
|
||||||
staticLayerCtx: null,
|
staticLayerCtx: null,
|
||||||
staticLayerKey: '',
|
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
|
// Correlation is measured continuously in the audio DSP. Do not add a
|
||||||
// second, frame-rate-dependent browser integration here.
|
// second, frame-rate-dependent browser integration here.
|
||||||
const corrVisual = Number.isFinite(audio?.correlation) ? clamp1(audio.correlation) : 0;
|
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)
|
? clamp1(audio.correlationNegativePeak)
|
||||||
: 0;
|
: 0;
|
||||||
|
const corrNegativeMarker = resolveCorrelationNegativeMarker(
|
||||||
|
state,
|
||||||
|
CONFIG,
|
||||||
|
corrVisual,
|
||||||
|
corrMemoryPeak,
|
||||||
|
frameNow,
|
||||||
|
);
|
||||||
drawCorrelationBar(
|
drawCorrelationBar(
|
||||||
g,
|
g,
|
||||||
layout.plot.x + layout.plot.w / 2,
|
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)),
|
Math.round(Math.min(layout.scope.w * 0.75, layout.plot.w - 50)),
|
||||||
18,
|
18,
|
||||||
corrVisual,
|
corrVisual,
|
||||||
corrNegativePeak,
|
corrNegativeMarker,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (slots.length) {
|
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) {
|
function drawStaticLayer(g, state, rect, layout, CONFIG, slotCount) {
|
||||||
const layer = ensureStaticLayer(state, rect, layout, CONFIG, slotCount);
|
const layer = ensureStaticLayer(state, rect, layout, CONFIG, slotCount);
|
||||||
if (!layer) {
|
if (!layer) {
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,7 @@
|
|||||||
// views/peak_history.js - Meter-Verlauf (rechter Slot) mit RTA-Layout
|
// 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 { 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 PLOT = { left: 43, top: 70, right: 0, bottom: 18 };
|
||||||
const METER_WIDTH = 140;
|
const METER_WIDTH = 140;
|
||||||
@@ -47,24 +48,10 @@ const DIN_SCALE = [
|
|||||||
{ db: +5, pos: 1.0000 },
|
{ 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 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_DIN_TICKS = [-50, -40, -30, -20, -10, -5, 0, 5];
|
||||||
const PPM_EBU_TICKS = [-12, -8, -4, 0, 4, 8, 12];
|
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_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 RMS_DBU_TICKS = [24, 20, 10, 0, -10, -20, -30, -40, -50, -60];
|
||||||
const LUFS_TICKS = [-50, -40, -30, -23, -18, -10, -5];
|
const LUFS_TICKS = [-50, -40, -30, -23, -18, -10, -5];
|
||||||
@@ -91,7 +78,6 @@ export function init() {
|
|||||||
meterLabel: '',
|
meterLabel: '',
|
||||||
neutralNorm: 0,
|
neutralNorm: 0,
|
||||||
neutralValue: 0,
|
neutralValue: 0,
|
||||||
rmsSmooth: null,
|
|
||||||
staticLayer: null,
|
staticLayer: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -605,7 +591,6 @@ async function drawMeter(env, plotX, plotY, plotW, plotH, meterRects = null) {
|
|||||||
function resolveMeterInfo(env, state, meterId) {
|
function resolveMeterInfo(env, state, meterId) {
|
||||||
const cfg = env.config || {};
|
const cfg = env.config || {};
|
||||||
const shared = env.meters?.getState?.(meterId) || null;
|
const shared = env.meters?.getState?.(meterId) || null;
|
||||||
if (meterId !== 'rms') state.rmsSmooth = null;
|
|
||||||
|
|
||||||
switch (meterId) {
|
switch (meterId) {
|
||||||
case 'ppm-din':
|
case 'ppm-din':
|
||||||
@@ -615,7 +600,7 @@ function resolveMeterInfo(env, state, meterId) {
|
|||||||
case 'tp':
|
case 'tp':
|
||||||
return sampleTp(cfg, shared);
|
return sampleTp(cfg, shared);
|
||||||
case 'rms':
|
case 'rms':
|
||||||
return sampleRms(cfg, shared, state);
|
return sampleRms(cfg, shared);
|
||||||
case 'lufs':
|
case 'lufs':
|
||||||
return sampleLufs(cfg, shared);
|
return sampleLufs(cfg, shared);
|
||||||
case 'vu':
|
case 'vu':
|
||||||
@@ -715,8 +700,8 @@ function samplePpmEbu(cfg, shared) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sampleTp(cfg, shared) {
|
function sampleTp(cfg, shared) {
|
||||||
const bottom = TP_PERCENT_SCALE[0].db;
|
const bottom = TRUE_PEAK_SCALE[0].db;
|
||||||
const top = TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].db;
|
const top = TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].db;
|
||||||
const effOff = Number(cfg?.TP_OFFSET_DB) || 0;
|
const effOff = Number(cfg?.TP_OFFSET_DB) || 0;
|
||||||
const offCorr = effOff - (shared?.offset || 0);
|
const offCorr = effOff - (shared?.offset || 0);
|
||||||
const warnVal = Number.isFinite(cfg?.TP_RED_START) ? cfg.TP_RED_START : -1;
|
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 mode = String(cfg?.RMS_MODE || 'dbfs').toLowerCase();
|
||||||
const isDBU = mode === 'dbu';
|
const isDBU = mode === 'dbu';
|
||||||
const bottom = -60;
|
const bottom = -60;
|
||||||
const top = isDBU ? 24 : 0;
|
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 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 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)
|
const warnVal = Number.isFinite(cfg?.RMS_RED_START)
|
||||||
? cfg.RMS_RED_START
|
? cfg.RMS_RED_START
|
||||||
: (isDBU ? 20 : 0);
|
: (isDBU ? 20 : 0);
|
||||||
const vL = Number.isFinite(shared?.values?.L) ? shared.values.L : bottom;
|
const vL = Number.isFinite(shared?.values?.L) ? shared.values.L : bottom;
|
||||||
const vR = Number.isFinite(shared?.values?.R) ? shared.values.R : bottom;
|
const vR = Number.isFinite(shared?.values?.R) ? shared.values.R : bottom;
|
||||||
const base = Math.max(vL, vR);
|
const base = Math.max(vL, vR) + offset;
|
||||||
const smoothed = applyRmsSmoothing(state, base, cfg);
|
const display = isDBU ? (base - refDbfs + refDbu) : base;
|
||||||
const display = isDBU ? (smoothed - refDbfs + refDbu) : smoothed;
|
|
||||||
const val = clamp(display, bottom, top);
|
const val = clamp(display, bottom, top);
|
||||||
const toNorm = (db) => {
|
const toNorm = (db) => {
|
||||||
const c = clamp(db, bottom, top);
|
const c = clamp(db, bottom, top);
|
||||||
@@ -778,43 +763,12 @@ function sampleRms(cfg, shared, state) {
|
|||||||
toNorm,
|
toNorm,
|
||||||
range: { bottom, top },
|
range: { bottom, top },
|
||||||
ticks: isDBU ? RMS_DBU_TICKS : RMS_DBFS_TICKS,
|
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,
|
hasValue: !!shared,
|
||||||
warnValue: warnVal,
|
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) {
|
function sampleLufs(cfg, shared) {
|
||||||
const bottom = -50;
|
const bottom = -50;
|
||||||
const top = -5;
|
const top = -5;
|
||||||
@@ -888,7 +842,7 @@ function interpolateScale(table, db) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function interpolateTp(db) {
|
function interpolateTp(db) {
|
||||||
return interpolateScale(TP_PERCENT_SCALE, db);
|
return interpolateScale(TRUE_PEAK_SCALE, db);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deflectionFrac(db) {
|
function deflectionFrac(db) {
|
||||||
|
|||||||
+121
-273
@@ -13,33 +13,24 @@ const METER_SLOTS = 3;
|
|||||||
const METER_PAD_TOP = 30;
|
const METER_PAD_TOP = 30;
|
||||||
const METER_PAD_BOTTOM = 20;
|
const METER_PAD_BOTTOM = 20;
|
||||||
const METER_EXTRA_BOTTOM_PAD = 6;
|
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_DBFS = [0, -8, -16, -24, -32, -40];
|
||||||
const RING_PPM_DIN = [+5, 0, -10, -20, -30, -40, -50];
|
const RING_PPM_DIN = [+5, 0, -10, -20, -30, -40, -50];
|
||||||
const PHASE_GAIN_MIN_DB = -35;
|
const PHASE_GAIN_MIN_DB = -35;
|
||||||
const PHASE_GAIN_MAX_DB = 35;
|
const PHASE_GAIN_MAX_DB = 35;
|
||||||
const PHASE_ALIGN_TARGET = Math.pow(10, -15 / 20);
|
const PHASE_ALIGN_TARGET = Math.pow(10, -15 / 20);
|
||||||
const PHASE_AGC_TARGET_DB = linearToDb(PHASE_ALIGN_TARGET);
|
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_ATTACK_S = 0.02;
|
||||||
const PHASE_AGC_RELEASE_DB_PER_S = 12;
|
const PHASE_AGC_RELEASE_DB_PER_S = 12;
|
||||||
const PHASE_LEVEL_THRESHOLD_DB = -60;
|
const PHASE_LEVEL_THRESHOLD_DB = -60;
|
||||||
const PHASE_LEVEL_THRESHOLD = dbToLinear(PHASE_LEVEL_THRESHOLD_DB);
|
const PHASE_LEVEL_THRESHOLD = dbToLinear(PHASE_LEVEL_THRESHOLD_DB);
|
||||||
const PHASE_IDLE_DECAY = 0.85;
|
const PHASE_IDLE_TAU_S = 0.103;
|
||||||
const PHASE_PHASE_SMOOTH_ALPHA = 0.08;
|
const PHASE_SMOOTH_TAU_S = 0.2;
|
||||||
const PHASE_RADIUS_SMOOTH_ALPHA = 0.18;
|
const PHASE_RADIUS_TAU_S = 0.085;
|
||||||
const PHASE_BANDPASS_LOW_HZ = 300;
|
|
||||||
const PHASE_BANDPASS_HIGH_HZ = 5000;
|
|
||||||
const PHASE_TRAIL_FADE_MS = 2000;
|
const PHASE_TRAIL_FADE_MS = 2000;
|
||||||
const PHASE_TRAIL_MIN_STEP_MS = 1000 / 45;
|
const PHASE_TRAIL_MIN_STEP_MS = 1000 / 45;
|
||||||
const PHASE_TRAIL_MIN_DIST_PX = 1.5;
|
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 = [
|
const PHASE_SECTORS = [
|
||||||
{ startDeg: -30, endDeg: 30, color: 'rgba(72,210,150,0.3)' },
|
{ startDeg: -30, endDeg: 30, color: 'rgba(72,210,150,0.3)' },
|
||||||
{ startDeg: 30, endDeg: 60, color: 'rgba(255,214,120,0.25)' },
|
{ 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)' },
|
{ 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() {
|
export function init() {
|
||||||
return {
|
return {
|
||||||
traceBuffer: new Float32Array(0),
|
phaseDisplaySeq: -1,
|
||||||
ampBuffer: new Float32Array(0),
|
phaseDisplayLastTs: 0,
|
||||||
filteredL: new Float32Array(0),
|
phaseConfidence: 0,
|
||||||
filteredR: new Float32Array(0),
|
|
||||||
currentPhase: null,
|
currentPhase: null,
|
||||||
currentRadius: 0,
|
currentRadius: 0,
|
||||||
smoothPhase: null,
|
smoothPhase: null,
|
||||||
@@ -74,9 +51,7 @@ export function init() {
|
|||||||
phaseAgcEnv: 1e-3,
|
phaseAgcEnv: 1e-3,
|
||||||
phaseAgcGainDb: 0,
|
phaseAgcGainDb: 0,
|
||||||
phaseAgcLastTs: 0,
|
phaseAgcLastTs: 0,
|
||||||
bandpass: createBandpassState(),
|
phaseAgcSeq: -1,
|
||||||
bufferGrowthCount: 0,
|
|
||||||
maxBufferSize: 0,
|
|
||||||
phaseTrail: [],
|
phaseTrail: [],
|
||||||
staticLayerCanvas: null,
|
staticLayerCanvas: null,
|
||||||
staticLayerKey: '',
|
staticLayerKey: '',
|
||||||
@@ -99,13 +74,13 @@ export async function render(env, state) {
|
|||||||
const now = getNow();
|
const now = getNow();
|
||||||
|
|
||||||
drawStaticLayer(g, state, rect, layout, CONFIG, slots.length);
|
drawStaticLayer(g, state, rect, layout, CONFIG, slots.length);
|
||||||
const xyData = extractXYData(audio);
|
const phaseData = extractPhaseData(audio);
|
||||||
const gainCtrl = resolvePhaseGain(state, xyData, CONFIG);
|
if (phaseData.ready) {
|
||||||
if (xyData.ready) {
|
const gainCtrl = resolvePhaseGain(state, phaseData, CONFIG);
|
||||||
const trace = buildWheelTrace(state, xyData, layout.wheel, gainCtrl.gain, CONFIG, audio);
|
const updated = updatePhasePointer(state, phaseData, gainCtrl.gain, CONFIG, audio, now);
|
||||||
renderWheel(g, trace, layout.wheel, state, CONFIG, now);
|
renderWheel(g, updated, layout.wheel, state, CONFIG, now);
|
||||||
} else {
|
} else {
|
||||||
decayPhasePointer(state);
|
updatePhasePointer(state, phaseData, 1, CONFIG, audio, now);
|
||||||
trimPhaseTrail(state, CONFIG, now);
|
trimPhaseTrail(state, CONFIG, now);
|
||||||
drawPhaseTrail(g, layout.wheel, state, CONFIG, now);
|
drawPhaseTrail(g, layout.wheel, state, CONFIG, now);
|
||||||
drawPhasePointer(g, layout.wheel, state);
|
drawPhasePointer(g, layout.wheel, state);
|
||||||
@@ -319,85 +294,54 @@ function radToDeg(rad) {
|
|||||||
return (rad * 180) / Math.PI;
|
return (rad * 180) / Math.PI;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractXYData(audio) {
|
function extractPhaseData(audio) {
|
||||||
const xyL = audio?.xyL;
|
const angle = Number(audio?.phaseAngleRad);
|
||||||
const xyR = audio?.xyR;
|
const level = Number(audio?.phaseLevel);
|
||||||
const isVec = (v) => v && (Array.isArray(v) || ArrayBuffer.isView(v));
|
const peak = Number(audio?.phasePeak);
|
||||||
const ready = !!(audio?.alive && isVec(xyL) && isVec(xyR) && xyL.length && xyR.length);
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready: !!audio?.alive,
|
||||||
xyL,
|
angle: Number.isFinite(angle) ? angle : null,
|
||||||
xyR,
|
coherence: clamp01(Number(audio?.phaseCoherence) || 0),
|
||||||
length: ready ? Math.min(xyL.length, xyR.length) : 0,
|
level: Number.isFinite(level) ? Math.max(0, level) : 0,
|
||||||
sampleRate: audio?.sampleRate || 48000,
|
peak: Number.isFinite(peak) ? Math.max(0, peak) : 0,
|
||||||
|
seq: Number(audio?.phaseSeq) || 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildWheelTrace(state, xyData, wheel, gain = 1, CONFIG, audio) {
|
function updatePhasePointer(state, phaseData, gain = 1, CONFIG, audio, nowMs) {
|
||||||
if (!xyData.ready || !xyData.length) {
|
const seq = Number(phaseData?.seq) || 0;
|
||||||
decayPhasePointer(state);
|
if (seq > 0 && state.phaseDisplaySeq === seq) return false;
|
||||||
return null;
|
const now = Number.isFinite(nowMs) ? nowMs : getNow();
|
||||||
}
|
const previousTs = Number.isFinite(state.phaseDisplayLastTs) && state.phaseDisplayLastTs > 0
|
||||||
const filtered = preparePhaseFilteredBuffers(state, xyData);
|
? 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 amplitudeMode = getPhaseAmplitudeMode(CONFIG);
|
||||||
const ringDbValues = getRingDbValues(CONFIG);
|
const ringDbValues = getRingDbValues(CONFIG);
|
||||||
const ppmRadiusNorm = amplitudeMode === 'ppm-din'
|
const ppmRadiusNorm = amplitudeMode === 'ppm-din'
|
||||||
? computePpmDinRadiusNorm(audio, CONFIG, ringDbValues)
|
? computePpmDinRadiusNorm(audio, CONFIG, ringDbValues)
|
||||||
: 0;
|
: 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;
|
const gainLinear = Number.isFinite(gain) ? gain : 1;
|
||||||
|
const targetRadius = amplitudeMode === 'ppm-din'
|
||||||
for (let i = 0; i < xyData.length; i += step) {
|
? ppmRadiusNorm
|
||||||
const lRe = clamp1(filtered.L[i]);
|
: linearToRadiusNorm(Math.min(1, phaseData.level * gainLinear), ringDbValues);
|
||||||
const rRe = clamp1(filtered.R[i]);
|
if (Number.isFinite(phaseData.angle) && phaseData.level >= PHASE_LEVEL_THRESHOLD) {
|
||||||
const lIm = hilbertAt(filtered.L, i);
|
const targetPhase = wrapAngle(phaseData.angle - Math.PI / 2);
|
||||||
const rIm = hilbertAt(filtered.R, i);
|
const prevPhase = Number.isFinite(state.smoothPhase) ? state.smoothPhase : targetPhase;
|
||||||
const phaseL = Math.atan2(lIm, lRe);
|
const prevRadius = Number.isFinite(state.smoothRadius) ? state.smoothRadius : targetRadius;
|
||||||
const phaseR = Math.atan2(rIm, rRe);
|
state.currentPhase = targetPhase;
|
||||||
let phaseDiff = phaseL - phaseR;
|
state.currentRadius = targetRadius;
|
||||||
if (!Number.isFinite(phaseDiff)) continue;
|
state.phaseConfidence = phaseData.coherence;
|
||||||
phaseDiff = wrapAngle(phaseDiff);
|
state.smoothPhase = smoothAngle(prevPhase, targetPhase, smoothingAlpha(dt, PHASE_SMOOTH_TAU_S));
|
||||||
const angle = phaseDiff - Math.PI / 2;
|
state.smoothRadius = lerp(prevRadius, targetRadius, smoothingAlpha(dt, PHASE_RADIUS_TAU_S));
|
||||||
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);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
decayPhasePointer(state);
|
state.phaseConfidence = 0;
|
||||||
|
decayPhasePointer(state, dt);
|
||||||
}
|
}
|
||||||
return { count: ampIdx, radius };
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderWheel(g, trace, wheel, state, CONFIG, timestamp) {
|
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);
|
const radiusNorm = Math.max(0, Number(state?.smoothRadius) || 0);
|
||||||
if (radiusNorm > 0.002 && Number.isFinite(state?.smoothPhase)) {
|
if (radiusNorm > 0.002 && Number.isFinite(state?.smoothPhase)) {
|
||||||
const length = radiusNorm * wheel.radius;
|
const phase = wrapAngle(state.smoothPhase);
|
||||||
const x = wheel.cx + Math.cos(state.smoothPhase) * length;
|
const point = createPhaseTrailPoint(wheel, phase, radiusNorm, now);
|
||||||
const y = wheel.cy + Math.sin(state.smoothPhase) * length;
|
|
||||||
const color = trailColorForAngle(state.smoothPhase);
|
|
||||||
const last = state.phaseTrail.length ? state.phaseTrail[state.phaseTrail.length - 1] : null;
|
const last = state.phaseTrail.length ? state.phaseTrail[state.phaseTrail.length - 1] : null;
|
||||||
if (!last) {
|
if (!last) {
|
||||||
state.phaseTrail.push({ x, y, t: now, color });
|
state.phaseTrail.push(point);
|
||||||
} else {
|
} else {
|
||||||
const dt = now - last.t;
|
const dt = now - last.t;
|
||||||
const dx = x - last.x;
|
const dx = point.x - last.x;
|
||||||
const dy = y - last.y;
|
const dy = point.y - last.y;
|
||||||
const dist2 = dx * dx + dy * dy;
|
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) {
|
if (dt >= PHASE_TRAIL_MIN_STEP_MS || dist2 >= PHASE_TRAIL_MIN_DIST_PX * PHASE_TRAIL_MIN_DIST_PX || last.color !== point.color) {
|
||||||
state.phaseTrail.push({ x, y, t: now, color });
|
appendPhaseTrailSegment(state.phaseTrail, wheel, last, phase, radiusNorm, now);
|
||||||
} else {
|
} else {
|
||||||
last.x = x;
|
last.x = point.x;
|
||||||
last.y = y;
|
last.y = point.y;
|
||||||
last.t = now;
|
last.t = now;
|
||||||
last.color = color;
|
last.color = point.color;
|
||||||
|
last.phase = phase;
|
||||||
|
last.radiusNorm = radiusNorm;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
trimPhaseTrail(state, CONFIG, now);
|
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) {
|
function trimPhaseTrail(state, CONFIG, now) {
|
||||||
if (!state.phaseTrail) state.phaseTrail = [];
|
if (!state.phaseTrail) state.phaseTrail = [];
|
||||||
if (!CONFIG?.PHASE_TRAIL_ENABLED) {
|
if (!CONFIG?.PHASE_TRAIL_ENABLED) {
|
||||||
@@ -615,6 +592,9 @@ function trimPhaseTrail(state, CONFIG, now) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
state.phaseTrail.length = write;
|
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) {
|
function drawPhaseTrail(g, wheel, state, CONFIG, now) {
|
||||||
@@ -650,11 +630,6 @@ function trailColorForAngle(angle) {
|
|||||||
return '#ff7a78';
|
return '#ff7a78';
|
||||||
}
|
}
|
||||||
|
|
||||||
function clamp1(value) {
|
|
||||||
if (!Number.isFinite(value)) return 0;
|
|
||||||
return Math.max(-1, Math.min(1, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function ringFraction(index, ringCount) {
|
function ringFraction(index, ringCount) {
|
||||||
const n = Math.max(1, ringCount | 0);
|
const n = Math.max(1, ringCount | 0);
|
||||||
const idx = Math.max(0, Math.min(n - 1, index | 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);
|
return dbToRadiusNorm(db, ringDbValues);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBandpassState() {
|
function decayPhasePointer(state, dt = 1 / 60) {
|
||||||
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) {
|
|
||||||
const prevPhase = Number.isFinite(state.currentPhase) ? state.currentPhase : 0;
|
const prevPhase = Number.isFinite(state.currentPhase) ? state.currentPhase : 0;
|
||||||
const prevRadius = Number.isFinite(state.currentRadius) ? state.currentRadius : 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.currentPhase = prevPhase;
|
||||||
state.currentRadius = decayedRadius;
|
state.currentRadius = decayedRadius;
|
||||||
const prevSmoothPhase = Number.isFinite(state.smoothPhase) ? state.smoothPhase : prevPhase;
|
const prevSmoothPhase = Number.isFinite(state.smoothPhase) ? state.smoothPhase : prevPhase;
|
||||||
const prevSmoothRadius = Number.isFinite(state.smoothRadius) ? state.smoothRadius : decayedRadius;
|
const prevSmoothRadius = Number.isFinite(state.smoothRadius) ? state.smoothRadius : decayedRadius;
|
||||||
state.smoothPhase = smoothAngle(prevSmoothPhase, prevPhase, PHASE_PHASE_SMOOTH_ALPHA * 0.5);
|
state.smoothPhase = prevSmoothPhase;
|
||||||
state.smoothRadius = lerp(prevSmoothRadius, decayedRadius, PHASE_RADIUS_SMOOTH_ALPHA);
|
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 gainDb = clampPhaseGain(CONFIG?.PHASE_DISPLAY_GAIN_DB ?? 0);
|
||||||
const allowAgc = CONFIG?.PHASE_AGC_ENABLED && getPhaseAmplitudeMode(CONFIG) !== 'ppm-din';
|
const allowAgc = CONFIG?.PHASE_AGC_ENABLED && getPhaseAmplitudeMode(CONFIG) !== 'ppm-din';
|
||||||
if (allowAgc && xyData?.ready) {
|
if (allowAgc && phaseData?.ready) {
|
||||||
const auto = computePhaseAgcGain(state, xyData);
|
const seq = Number(phaseData.seq) || 0;
|
||||||
return { gainDb: auto.gainDb, gain: auto.gain * PHASE_AGC_BASE_GAIN };
|
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.phaseAgcGainDb = gainDb;
|
||||||
|
state.phaseAgcSeq = Number(phaseData?.seq) || 0;
|
||||||
return { gainDb, gain: dbToLinear(gainDb) };
|
return { gainDb, gain: dbToLinear(gainDb) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -857,51 +751,22 @@ function lerp(a, b, t) {
|
|||||||
return a + (b - a) * clampedT;
|
return a + (b - a) * clampedT;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hilbertAt(buffer, idx) {
|
export function smoothingAlpha(dt, tau) {
|
||||||
if (!buffer || idx < 0 || idx >= buffer.length) return 0;
|
const seconds = Number.isFinite(dt) ? Math.max(0, dt) : 0;
|
||||||
|
const timeConstant = Number.isFinite(tau) ? Math.max(1e-6, tau) : 1e-6;
|
||||||
let acc = 0;
|
return 1 - Math.exp(-seconds / timeConstant);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildHilbertKernel(size = 33) {
|
function clamp01(value) {
|
||||||
const taps = size % 2 === 0 ? size + 1 : size;
|
return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;
|
||||||
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 computePhaseAgcGain(state, xyData) {
|
function computePhaseAgcGain(state, phaseData) {
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
const dt = state.phaseAgcLastTs ? Math.max(0, (now - state.phaseAgcLastTs) / 1000) : 0;
|
const dt = state.phaseAgcLastTs ? Math.max(0, (now - state.phaseAgcLastTs) / 1000) : 0;
|
||||||
state.phaseAgcLastTs = now;
|
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;
|
let env = Number.isFinite(state.phaseAgcEnv) && state.phaseAgcEnv > 0 ? state.phaseAgcEnv : 1e-3;
|
||||||
|
|
||||||
if (peak >= env) {
|
if (peak >= env) {
|
||||||
@@ -925,23 +790,6 @@ function computePhaseAgcGain(state, xyData) {
|
|||||||
return { gainDb, gain: dbToLinear(gainDb) };
|
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) {
|
function clampPhaseGain(db) {
|
||||||
let val = Number(db);
|
let val = Number(db);
|
||||||
if (!Number.isFinite(val)) val = 0;
|
if (!Number.isFinite(val)) val = 0;
|
||||||
|
|||||||
+13
-284
@@ -1,246 +1,29 @@
|
|||||||
// views/quad_view.js — Quad-View: vier Plots (2x2) + optionales Meter-Panel (wie Split-View)
|
// views/quad_view.js — Quad-View: vier Plots (2x2) + optionales Meter-Panel (wie Split-View)
|
||||||
|
|
||||||
import * as viewGoni from './goniometer_rtw.js';
|
import { DEFAULT_TOP_INSET } from '../core/theme.js';
|
||||||
import * as viewPhaseWheel from './phase_wheel.js';
|
import {
|
||||||
import * as viewPanel from './panel.js';
|
CHILD_VIEWS,
|
||||||
import * as viewRealtime from './realtime.js';
|
computeMultiViewBaseLayout,
|
||||||
import * as viewClassicNeedles from './classic_needles.js';
|
drawEmptyPlot,
|
||||||
import * as viewPeakHistory from './peak_history.js';
|
drawMetersPanel,
|
||||||
import * as viewClock from './clock.js';
|
readMultiViewMeters,
|
||||||
import * as viewWaveform from './waveform.js';
|
sanitizeChildId,
|
||||||
import * as viewSpectrogram from './spectrogram.js';
|
sanitizePlotId,
|
||||||
import { DEFAULT_TOP_INSET, FRAME_COLOR, PANEL_BG } from '../core/theme.js';
|
withClippedSubRect,
|
||||||
|
} from './multi_view_shared.js';
|
||||||
|
|
||||||
export const id = 'quad-view';
|
export const id = 'quad-view';
|
||||||
|
|
||||||
const CONTENT_TOP = DEFAULT_TOP_INSET;
|
const CONTENT_TOP = DEFAULT_TOP_INSET;
|
||||||
const CONTENT_BOTTOM = 0;
|
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 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) {
|
function readQuadMeters(CONFIG) {
|
||||||
const count = clampMeterCount(CONFIG?.QUAD_VIEW_METER_COUNT);
|
return readMultiViewMeters(CONFIG, 'QUAD_VIEW');
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeBaseLayout(rect, slots) {
|
function computeBaseLayout(rect, slots) {
|
||||||
const contentH = Math.max(0, rect.h - CONTENT_TOP - CONTENT_BOTTOM);
|
return computeMultiViewBaseLayout(rect, slots, 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitRectToRows(rect) {
|
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);
|
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) {
|
function destroyChild(child) {
|
||||||
if (!child || child.id === 'none') return;
|
if (!child || child.id === 'none') return;
|
||||||
const mod = CHILD_VIEWS[child.id];
|
const mod = CHILD_VIEWS[child.id];
|
||||||
|
|||||||
+132
-61
@@ -1,5 +1,6 @@
|
|||||||
import { getRtwCenters, resolveRtwBpoValue } from '../core/rtw_centers.js';
|
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 { 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
|
// 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({
|
const PEAK_HOLD_MAP = new Map(Object.entries({
|
||||||
off: 0,
|
off: 0,
|
||||||
auto: null,
|
auto: null,
|
||||||
|
fall: null,
|
||||||
'1s': 1,
|
'1s': 1,
|
||||||
'2s': 2,
|
'2s': 2,
|
||||||
'4s': 4,
|
'4s': 4,
|
||||||
@@ -31,10 +33,9 @@ const PEAK_HOLD_MAP = new Map(Object.entries({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const INTEGRATION_TAU = {
|
const INTEGRATION_TAU = {
|
||||||
impulse: 0.035,
|
|
||||||
fast: 0.125,
|
fast: 0.125,
|
||||||
|
medium: 0.5,
|
||||||
slow: 1.0,
|
slow: 1.0,
|
||||||
peak: 0.01,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -79,7 +80,7 @@ export async function render(env, state) {
|
|||||||
const useNativeFftEngine = !useIirEngine && rtaData && rtaData.engine === 'fft';
|
const useNativeFftEngine = !useIirEngine && rtaData && rtaData.engine === 'fft';
|
||||||
const nativeRtaPacket = (useIirEngine || useNativeFftEngine) ? rtaData : null;
|
const nativeRtaPacket = (useIirEngine || useNativeFftEngine) ? rtaData : null;
|
||||||
const displayRtaPacket = (nativeRtaPacket && CONFIG.RTA_BAR_LAYOUT === 'rtw')
|
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;
|
: nativeRtaPacket;
|
||||||
|
|
||||||
const topInset = Number.isFinite(env?.topInset) ? Number(env.topInset) : DEFAULT_TOP_INSET;
|
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)
|
const gutterL = Number.isFinite(CONFIG?.AXIS_GUTTER_LEFT)
|
||||||
? Math.max(8, Number(CONFIG.AXIS_GUTTER_LEFT))
|
? Math.max(8, Number(CONFIG.AXIS_GUTTER_LEFT))
|
||||||
: 14;
|
: 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 plotPadLeft = gutterL + STATIC_LABEL_PAD_LEFT;
|
||||||
const plotPadBottom = STATIC_LABEL_PAD_BOTTOM;
|
const plotPadBottom = STATIC_LABEL_PAD_BOTTOM;
|
||||||
|
|
||||||
@@ -133,6 +151,8 @@ export async function render(env, state) {
|
|||||||
refDb ?? 'none',
|
refDb ?? 'none',
|
||||||
hide20HzTick ? 1 : 0,
|
hide20HzTick ? 1 : 0,
|
||||||
plotPadLeft,
|
plotPadLeft,
|
||||||
|
gridLeftExtension,
|
||||||
|
tickPositionKey,
|
||||||
plotPadBottom,
|
plotPadBottom,
|
||||||
].join('|'),
|
].join('|'),
|
||||||
{ x: plotX - plotPadLeft, y: plotY, w: plotW + plotPadLeft, h: plotH + plotPadBottom },
|
{ x: plotX - plotPadLeft, y: plotY, w: plotW + plotPadLeft, h: plotH + plotPadBottom },
|
||||||
@@ -147,7 +167,9 @@ export async function render(env, state) {
|
|||||||
freqBounds.max,
|
freqBounds.max,
|
||||||
{
|
{
|
||||||
freqTicks,
|
freqTicks,
|
||||||
|
freqTickPositions,
|
||||||
freqMin: freqBounds.min,
|
freqMin: freqBounds.min,
|
||||||
|
xLeftExtension: gridLeftExtension,
|
||||||
refDb,
|
refDb,
|
||||||
refStyle: 'rgba(0,231,255,0.35)',
|
refStyle: 'rgba(0,231,255,0.35)',
|
||||||
refDash: [3, 4],
|
refDash: [3, 4],
|
||||||
@@ -181,7 +203,7 @@ export async function render(env, state) {
|
|||||||
// Native IIR values already contain the selected power-domain
|
// Native IIR values already contain the selected power-domain
|
||||||
// integration. A second browser attack/hold stage would falsify it.
|
// integration. A second browser attack/hold stage would falsify it.
|
||||||
const display = displayBase;
|
const display = displayBase;
|
||||||
applyPeakHold(state, integrated, CONFIG, range);
|
syncNativePeakHold(state, displayRtaPacket, CONFIG, range, state.mapping.length, 'iir');
|
||||||
state.displayLevels = display;
|
state.displayLevels = display;
|
||||||
state.currentRange = range;
|
state.currentRange = range;
|
||||||
if (typeof window !== 'undefined') window.__RTA_STATE__ = state;
|
if (typeof window !== 'undefined') window.__RTA_STATE__ = state;
|
||||||
@@ -197,20 +219,25 @@ export async function render(env, state) {
|
|||||||
range,
|
range,
|
||||||
freqBounds,
|
freqBounds,
|
||||||
ballisticsData?.overlay || null,
|
ballisticsData?.overlay || null,
|
||||||
ballisticsData?.mode || CONFIG.RTA_BALLISTICS_MODE || 'average'
|
ballisticsData?.mode || CONFIG.RTA_BALLISTICS_MODE || 'average',
|
||||||
|
gridLeftExtension,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (useNativeFftEngine) {
|
} 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) {
|
if (!nativeLevels || !nativeLevels.length) {
|
||||||
drawWaiting(g, plotX, plotY);
|
drawWaiting(g, plotX, plotY);
|
||||||
} else {
|
} 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 displayBase = applyDisplayHold(state, integrated, CONFIG, range);
|
||||||
const display = (CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'bars'
|
const display = (CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'bars'
|
||||||
? applyRealtimeBarBallistics(state, displayBase, CONFIG, range)
|
? applyRealtimeBarBallistics(state, displayBase, CONFIG, range)
|
||||||
: displayBase;
|
: displayBase;
|
||||||
applyPeakHold(state, integrated, CONFIG, range);
|
syncNativePeakHold(state, displayRtaPacket, CONFIG, range, state.mapping.length, 'fft');
|
||||||
state.displayLevels = display;
|
state.displayLevels = display;
|
||||||
state.currentRange = range;
|
state.currentRange = range;
|
||||||
if (typeof window !== 'undefined') window.__RTA_STATE__ = state;
|
if (typeof window !== 'undefined') window.__RTA_STATE__ = state;
|
||||||
@@ -225,8 +252,9 @@ export async function render(env, state) {
|
|||||||
CONFIG,
|
CONFIG,
|
||||||
range,
|
range,
|
||||||
freqBounds,
|
freqBounds,
|
||||||
null,
|
ballisticsData?.overlay || null,
|
||||||
CONFIG.RTA_BALLISTICS_MODE || 'average'
|
ballisticsData?.mode || CONFIG.RTA_BALLISTICS_MODE || 'average',
|
||||||
|
gridLeftExtension,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (!analyser || !buf) {
|
} else if (!analyser || !buf) {
|
||||||
@@ -255,7 +283,8 @@ export async function render(env, state) {
|
|||||||
range,
|
range,
|
||||||
freqBounds,
|
freqBounds,
|
||||||
null,
|
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_BPO_MODE,
|
||||||
CONFIG.RTA_WEIGHTING,
|
CONFIG.RTA_WEIGHTING,
|
||||||
CONFIG.RTA_BAR_LAYOUT,
|
CONFIG.RTA_BAR_LAYOUT,
|
||||||
|
CONFIG.RTA_DETECTOR,
|
||||||
CONFIG.RTA_INTEGRATION,
|
CONFIG.RTA_INTEGRATION,
|
||||||
CONFIG.RTA_PEAK_HOLD_MODE,
|
CONFIG.RTA_PEAK_HOLD_MODE,
|
||||||
CONFIG.RTA_PEAK_HOLD_SEC,
|
CONFIG.RTA_PEAK_HOLD_SEC,
|
||||||
CONFIG.RTA_PEAK_DECAY_DB_PER_S,
|
CONFIG.RTA_PEAK_DECAY_DB_PER_S,
|
||||||
|
CONFIG.RTA_PEAK_RESET_TOKEN,
|
||||||
CONFIG.RTA_DISPLAY_HOLD_SEC,
|
CONFIG.RTA_DISPLAY_HOLD_SEC,
|
||||||
CONFIG.REALTIME_RENDER_STYLE,
|
CONFIG.REALTIME_RENDER_STYLE,
|
||||||
CONFIG.REALTIME_BAR_HOLD_MS,
|
CONFIG.REALTIME_BAR_HOLD_MS,
|
||||||
@@ -383,15 +414,14 @@ function computeBandLevels(state, utils, buf, CONFIG, range) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyIntegration(state, levels, 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
|
? CONFIG.RTA_INTEGRATION
|
||||||
: 'fast';
|
: 'fast';
|
||||||
const tau = INTEGRATION_TAU[mode] || 0.125;
|
const peakDetector = CONFIG.RTA_DETECTOR === 'peak';
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
const last = state.lastIntegrationTs || now;
|
const last = state.lastIntegrationTs || now;
|
||||||
const dt = Math.max(1 / 240, (now - last) / 1000);
|
const dt = Math.max(1 / 240, (now - last) / 1000);
|
||||||
state.lastIntegrationTs = now;
|
state.lastIntegrationTs = now;
|
||||||
const alpha = (tau > 0 && dt > 0) ? 1 - Math.exp(-dt / tau) : 1;
|
|
||||||
|
|
||||||
if (!state.ewma || state.ewma.length !== levels.length) {
|
if (!state.ewma || state.ewma.length !== levels.length) {
|
||||||
state.ewma = new Float32Array(levels.length);
|
state.ewma = new Float32Array(levels.length);
|
||||||
@@ -400,19 +430,23 @@ function applyIntegration(state, levels, CONFIG, range) {
|
|||||||
const buffer = state.ewma;
|
const buffer = state.ewma;
|
||||||
for (let i = 0; i < levels.length; i++) {
|
for (let i = 0; i < levels.length; i++) {
|
||||||
const prev = buffer[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;
|
buffer[i] = prev + (levels[i] - prev) * alpha;
|
||||||
}
|
}
|
||||||
return buffer;
|
return buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyPeakHold(state, levels, CONFIG, range) {
|
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 len = levels.length;
|
||||||
const mapped = PEAK_HOLD_MAP.get(mode);
|
const mapped = PEAK_HOLD_MAP.get(mode);
|
||||||
const holdSec = Number.isFinite(mapped)
|
const holdSec = Number.isFinite(mapped)
|
||||||
? Math.max(0, mapped)
|
? Math.max(0, mapped)
|
||||||
: Math.max(0, Number(CONFIG.RTA_PEAK_HOLD_SEC) || 0);
|
: 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 now = performance.now();
|
||||||
const dt = Math.max(0, (now - (state.holdSampleTs || now)) / 1000);
|
const dt = Math.max(0, (now - (state.holdSampleTs || now)) / 1000);
|
||||||
state.holdSampleTs = now;
|
state.holdSampleTs = now;
|
||||||
@@ -437,11 +471,10 @@ function applyPeakHold(state, levels, CONFIG, range) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (mapped === Infinity || mode === 'manual') continue;
|
if (mapped === Infinity || mode === 'manual') continue;
|
||||||
let allowDecay = mode === 'off';
|
if (mode === 'auto' && (now - (state.lastPeakTime[i] || 0)) >= holdSec * 1000) {
|
||||||
if (mode === 'auto') {
|
buffer[i] = current;
|
||||||
allowDecay = (now - (state.lastPeakTime[i] || 0)) >= holdSec * 1000;
|
state.lastPeakTime[i] = now;
|
||||||
}
|
} else if (mode === 'fall' && (now - (state.lastPeakTime[i] || 0)) >= holdSec * 1000) {
|
||||||
if (allowDecay && decayRate > 0) {
|
|
||||||
buffer[i] = Math.max(current, buffer[i] - decayRate * dt);
|
buffer[i] = Math.max(current, buffer[i] - decayRate * dt);
|
||||||
}
|
}
|
||||||
buffer[i] = clamp(buffer[i], range.bottom, range.top);
|
buffer[i] = clamp(buffer[i], range.bottom, range.top);
|
||||||
@@ -449,6 +482,28 @@ function applyPeakHold(state, levels, CONFIG, range) {
|
|||||||
return buffer;
|
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) {
|
function applyDisplayHold(state, levels, CONFIG, range) {
|
||||||
const holdSec = Math.max(0, Number(CONFIG.RTA_DISPLAY_HOLD_SEC) || 0);
|
const holdSec = Math.max(0, Number(CONFIG.RTA_DISPLAY_HOLD_SEC) || 0);
|
||||||
const len = levels.length;
|
const len = levels.length;
|
||||||
@@ -522,7 +577,33 @@ function applyRealtimeBarBallistics(state, levels, CONFIG, range) {
|
|||||||
return buffer;
|
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 layout = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'rtw' : 'iec';
|
||||||
const overlay = (ballisticsMode === 'both' && isVectorLike(overlayPeaks) && overlayPeaks.length === state.mapping.length)
|
const overlay = (ballisticsMode === 'both' && isVectorLike(overlayPeaks) && overlayPeaks.length === state.mapping.length)
|
||||||
? overlayPeaks
|
? overlayPeaks
|
||||||
@@ -530,7 +611,8 @@ function renderSpectrum(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, ra
|
|||||||
if ((CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'line') {
|
if ((CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'line') {
|
||||||
renderLine(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlay);
|
renderLine(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlay);
|
||||||
} else if (layout === 'rtw') {
|
} 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 {
|
} else {
|
||||||
renderIecBars(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlay);
|
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();
|
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) {
|
function mapLogX(freq, x0, w, logMin, logSpan) {
|
||||||
const clamped = Math.max(5, freq);
|
const clamped = Math.max(5, freq);
|
||||||
const frac = (Math.log10(clamped) - logMin) / (logSpan || 1);
|
const frac = (Math.log10(clamped) - logMin) / (logSpan || 1);
|
||||||
@@ -794,11 +850,13 @@ function buildFixedRtwBands(bpoMode, freqBounds, nyq, overrideCenters) {
|
|||||||
return bands;
|
return bands;
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectLocalRtwPacket(packet, bpoMode) {
|
export function selectLocalRtwPacket(packet, bpoMode) {
|
||||||
if (!packet || !isVectorLike(packet.centers) || !packet.centers.length) return packet;
|
if (!packet || !isVectorLike(packet.centers) || !packet.centers.length) return packet;
|
||||||
const desiredCenters = getRtwCenters(bpoMode);
|
const desiredCenters = getRtwCenters(bpoMode);
|
||||||
if (!desiredCenters.length) return packet;
|
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 keyFor = (value) => Number(value).toFixed(1);
|
||||||
const indexByCenter = new Map();
|
const indexByCenter = new Map();
|
||||||
@@ -843,6 +901,7 @@ function selectLocalRtwPacket(packet, bpoMode) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...packet,
|
...packet,
|
||||||
|
bpo: bpoMode,
|
||||||
centers: selectedCenters,
|
centers: selectedCenters,
|
||||||
bands_avg: pickVector(packet.bands_avg || packet.bands),
|
bands_avg: pickVector(packet.bands_avg || packet.bands),
|
||||||
bands_peak: pickVector(packet.bands_peak),
|
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 bottom = (range && Number.isFinite(range.bottom)) ? range.bottom : FLOOR_DB;
|
||||||
const top = (range && Number.isFinite(range.top)) ? range.top : 9;
|
const top = (range && Number.isFinite(range.top)) ? range.top : 9;
|
||||||
const gain = Number(CONFIG.RTA_DISPLAY_GAIN_FFT_DB ?? 0) || 0;
|
const gain = Number(CONFIG.RTA_DISPLAY_GAIN_FFT_DB ?? 0) || 0;
|
||||||
const src = isVectorLike(packet.bands_avg || packet.bands)
|
const ensureLen = (arr) => isVectorLike(arr) && (!expectedLen || arr.length === expectedLen) ? arr : null;
|
||||||
? (packet.bands_avg || packet.bands)
|
const avgRaw = ensureLen(packet.bands_avg || packet.bands);
|
||||||
: null;
|
const peakRaw = ensureLen(packet.bands_peak);
|
||||||
if (!src || (expectedLen && src.length !== expectedLen)) return null;
|
const requested = ['average', 'peak', 'both'].includes(CONFIG.RTA_BALLISTICS_MODE)
|
||||||
const out = new Float32Array(src.length);
|
? CONFIG.RTA_BALLISTICS_MODE
|
||||||
for (let i = 0; i < src.length; i++) {
|
: 'average';
|
||||||
const v = Number(src[i]);
|
const convert = (src) => {
|
||||||
out[i] = clamp((Number.isFinite(v) ? v : bottom) + gain, bottom, top);
|
if (!src) return null;
|
||||||
}
|
const out = new Float32Array(src.length);
|
||||||
return out;
|
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) {
|
function isVectorLike(v) {
|
||||||
|
|||||||
+13
-285
@@ -1,300 +1,28 @@
|
|||||||
// views/split_view.js — Split-View: zwei nebeneinander gerenderte Views (links/rechts)
|
// views/split_view.js — Split-View: zwei nebeneinander gerenderte Views (links/rechts)
|
||||||
|
|
||||||
import * as viewGoni from './goniometer_rtw.js';
|
import { DEFAULT_TOP_INSET } from '../core/theme.js';
|
||||||
import * as viewPhaseWheel from './phase_wheel.js';
|
import {
|
||||||
import * as viewPanel from './panel.js';
|
CHILD_VIEWS,
|
||||||
import * as viewRealtime from './realtime.js';
|
computeMultiViewBaseLayout,
|
||||||
import * as viewClassicNeedles from './classic_needles.js';
|
drawEmptyPlot,
|
||||||
import * as viewPeakHistory from './peak_history.js';
|
drawMetersPanel,
|
||||||
import * as viewClock from './clock.js';
|
readMultiViewMeters,
|
||||||
import * as viewWaveform from './waveform.js';
|
sanitizeChildId,
|
||||||
import * as viewSpectrogram from './spectrogram.js';
|
sanitizePlotId,
|
||||||
import { DEFAULT_TOP_INSET, FRAME_COLOR, PANEL_BG } from '../core/theme.js';
|
withClippedSubRect,
|
||||||
|
} from './multi_view_shared.js';
|
||||||
|
|
||||||
export const id = 'split-view';
|
export const id = 'split-view';
|
||||||
|
|
||||||
const CONTENT_TOP = DEFAULT_TOP_INSET;
|
const CONTENT_TOP = DEFAULT_TOP_INSET;
|
||||||
const CONTENT_BOTTOM = 0;
|
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) {
|
function readSplitMeters(CONFIG) {
|
||||||
const count = clampMeterCount(CONFIG?.SPLIT_VIEW_METER_COUNT);
|
return readMultiViewMeters(CONFIG, 'SPLIT_VIEW');
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeSplitLayout(rect, slots) {
|
function computeSplitLayout(rect, slots) {
|
||||||
const contentH = Math.max(0, rect.h - CONTENT_TOP - CONTENT_BOTTOM);
|
return computeMultiViewBaseLayout(rect, slots, 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function destroyChild(child) {
|
function destroyChild(child) {
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
|
import { createCanvasSurface } from '../core/canvas_surface.js';
|
||||||
|
|
||||||
|
const MAX_STATIC_LAYERS = 24;
|
||||||
|
|
||||||
export function createStaticLayerCanvas(width, height) {
|
export function createStaticLayerCanvas(width, height) {
|
||||||
const w = Math.max(1, width | 0);
|
return createCanvasSurface(width, height)?.canvas || null;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function drawCachedStaticLayer(state, g, layerId, key, rect, build) {
|
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();
|
if (!state.staticLayers) state.staticLayers = new Map();
|
||||||
const fullKey = `${layerId}:${key}:${Math.max(0, rect.w | 0)}x${Math.max(0, rect.h | 0)}`;
|
const fullKey = `${layerId}:${key}:${Math.max(0, rect.w | 0)}x${Math.max(0, rect.h | 0)}`;
|
||||||
let layer = state.staticLayers.get(fullKey);
|
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) {
|
if (!layer) {
|
||||||
const canvas = createStaticLayerCanvas(rect.w, rect.h);
|
const canvas = createStaticLayerCanvas(rect.w, rect.h);
|
||||||
|
if (!canvas) return;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
build(ctx, rect);
|
build(ctx, rect);
|
||||||
layer = { canvas };
|
layer = { canvas };
|
||||||
state.staticLayers.set(fullKey, layer);
|
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);
|
g.drawImage(layer.canvas, rect.x, rect.y);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user