Recover analyzer DSP and realtime pipeline corrections
This commit is contained in:
@@ -0,0 +1,171 @@
|
|||||||
|
# Phoenix Analyzer - Soll-/Ist-To-do
|
||||||
|
|
||||||
|
## Verbindliches Ziel
|
||||||
|
|
||||||
|
- Referenz ist der RTW PortaMonitor 1064X/1064X-PLUS, insbesondere dessen RTA-, PPM-, Peakmeter-, Goniometer- und Korrelationsverhalten.
|
||||||
|
- Der primäre RTW-nahe RTA arbeitet als IIR-Fractional-Octave-Filterbank. FFT bleibt eine optionale, getrennt gekennzeichnete Spektrumsansicht.
|
||||||
|
- Der RTW-RTA-Modus soll 31 Bänder in 1/3-Oktaven von 20 Hz bis 20 kHz sowie ein Verhalten entsprechend IEC 225/ANSI Class 2 beziehungsweise der passenden aktuellen Nachfolgenorm bieten.
|
||||||
|
- Vorgesehene RTA-Modi: Fast, Medium, Slow, Average und Peak; Peak Hold 2,5 s, 4 s oder manuell.
|
||||||
|
- Zwischen hörbarem Signal und Anzeige soll nur die unvermeidbare Mess- und Bildschirmlatenz liegen. Alte Messframes dürfen niemals eine anwachsende Verzögerung erzeugen.
|
||||||
|
- Normbedingte Ballistiken werden nicht künstlich verkürzt. Technische Transportlatenz und gewollte Instrumententrägheit werden getrennt behandelt.
|
||||||
|
- LUFS und LRA bleiben vorerst außerhalb dieser Aufgabenliste.
|
||||||
|
|
||||||
|
## Was bereits vorhanden und grundsätzlich brauchbar ist
|
||||||
|
|
||||||
|
- Direkte ALSA-Aufnahme mit kleiner Standardperiode von 128 Samples.
|
||||||
|
- Ein IIR-RTA ist bereits vorhanden und in Frontend und Backend der Standardmodus.
|
||||||
|
- Die 31 RTW-Mittenfrequenzen für 1/3-Oktaven von 20 Hz bis 20 kHz sind bereits hinterlegt.
|
||||||
|
- Zusätzlich existieren 1/6- und 1/12-Oktav-Modi als Phoenix-Erweiterungen.
|
||||||
|
- RTA-Bandwerte und Peakwerte werden getrennt geführt.
|
||||||
|
- DIN-PPM, EBU-PPM, True Peak, VU, RMS, Goniometer, Korrelation, Waveform und Spektrogramm sind grundsätzlich vorhanden.
|
||||||
|
- Das Frontend zeichnet mit bis zu 60 Hz und verwendet für Waveform und Spektrogramm teilweise Worker.
|
||||||
|
- Konfigurierbare Eingangspegelkorrektur und ein L/R-Laufzeitausgleich existieren.
|
||||||
|
|
||||||
|
Diese vorhandenen Funktionen sind nicht automatisch messtechnisch korrekt. Die folgenden Punkte beschreiben den jeweiligen Soll-/Ist-Unterschied.
|
||||||
|
|
||||||
|
## Priorität 0 - Anwachsende Anzeigeverzögerung verhindern
|
||||||
|
|
||||||
|
- [x] **1. WebSocket auf "latest value wins" umstellen**
|
||||||
|
- **Soll:** Ein langsamer Client erhält immer den neuesten Messzustand; alte Zustände werden verworfen.
|
||||||
|
- **Ist:** Der interne Kanal ist auf 32 Frames begrenzt; der WebSocket sendet im 16-ms-Takt und leert vor jedem Versand bis zum neuesten Zustand. Spektrogrammdaten laufen getrennt.
|
||||||
|
- **Abnahme:** Lokaler Laufzeittest liefert 68 aktuelle Messframes in 1,1 s; alte Zustände werden beim Leeren verworfen.
|
||||||
|
|
||||||
|
- [x] **2. Mess-, Visualisierungs- und Konfigurationsdaten trennen**
|
||||||
|
- **Soll:** DSP läuft samplegenau; übertragen wird nur so häufig und so umfangreich wie für die jeweilige Anzeige nötig.
|
||||||
|
- **Ist:** Skalare Messzustände laufen mit etwa 60 JSON-Paketen/s. Spektrogramm sowie Goniometer/Waveform besitzen getrennte, kompakte Binär-WebSockets. Roh-Waveformsamples werden nicht mehr in jedem Capture-Frame vervielfacht; Waveform-Hüllkurven werden beim serverseitigen Leeren lückenlos zusammengeführt. Das doppelte RTA-Bandfeld wurde vollständig entfernt; Konfiguration wird nur separat bei Änderungen synchronisiert.
|
||||||
|
- **Interne Last:** Der Verteiler reicht Messframes als gemeinsam genutzte Referenz weiter. Beim Verwerfen alter Frames werden deshalb keine kompletten Spektrogramm-, XY- und Waveformvektoren mehr kopiert.
|
||||||
|
- **Geprüft:** Protokolltests prüfen Header, Nutzdaten und beschädigte Paketlängen. Ein Servertest stellt sicher, dass große Visualisierungsfelder nicht wieder im JSON-Messstrom landen.
|
||||||
|
- **Abnahme:** Datenwege sind softwareseitig getrennt; die Lastmessung auf der Zielhardware bleibt Bestandteil der End-to-End-Abnahme unter Punkt 17.
|
||||||
|
|
||||||
|
- [x] **3. Browser-Verarbeitung auf den neuesten Zustand begrenzen**
|
||||||
|
- **Soll:** Pro Bildschirmframe wird höchstens der neueste vollständige Messzustand verarbeitet.
|
||||||
|
- **Ist:** Der Browser besitzt für Messwerte, Spektrogramm und Visualisierungsdaten jeweils einen Single-Slot-Puffer und verarbeitet höchstens einen aktuellen Zustand pro Animation Frame. Während einer laufenden Verarbeitung ersetzt ein neuer Zustand den wartenden alten. Nicht mehr steigende Sequenznummern werden verworfen.
|
||||||
|
- **Abnahme:** Es existiert kein unbeschränktes Paket- oder Promise-Backlog mehr.
|
||||||
|
|
||||||
|
- [x] **3a. Spektrogramm dauerhaft echtzeitfähig machen**
|
||||||
|
- **Ist:** Inkrementelles Spaltenzeichnen statt Vollbild-Neuberechnung, Worker-ACK/Single-Slot, 750-ms-Watchdog mit sauberem Neustart, begrenzter Sprung statt nachträglichem Aufholen sowie eigener Binärstrom mit Quellsequenz.
|
||||||
|
- **Geschwindigkeit:** Feste Zeitbasis von 60 CSS-Pixeln/s bei 1×; 0,5×, 2×, 4× und 6× skalieren exakt und bleiben unabhängig von FFT-Größe und Display-DPI. Gamma und Geschwindigkeit werden global gespeichert.
|
||||||
|
- **Abnahme:** Automatische Tests prüfen alle Geschwindigkeiten, FFT-Größenunabhängigkeit, DPI-Skalierung, Worker-ACK, ausschließlich inkrementelles Hochladen der neuen Bildspalte und 60.000 Spalten als synthetischen Zehn-Minuten-Lauf.
|
||||||
|
|
||||||
|
## Priorität 1 - RTW-naher IIR-RTA
|
||||||
|
|
||||||
|
- [x] **4. IIR verbindlich als RTW-RTA-Modus behandeln**
|
||||||
|
- **Soll:** RTW-Modus bedeutet eindeutig IIR-Filterbank; FFT ist eine gesonderte Zusatzansicht.
|
||||||
|
- **Ist:** Backend, gespeicherte Konfiguration, Optionsdialog und Laufzeitprofil erzwingen im RTW-Layout jetzt IIR, 31 Dritteloktavbänder, Normalbereich und Filterordnung 6. Alte Browserkonfigurationen können den Motor nicht mehr heimlich auf FFT zurückstellen; das Backend-Paket ist für die Anzeige maßgeblich.
|
||||||
|
- **Geprüft:** Ein automatischer Profiltest prüft sowohl das gesperrte RTW-Profil als auch die weiterhin freie IEC-/Phoenix-Erweiterung.
|
||||||
|
- **Abnahme:** Das RTW-Profil startet reproduzierbar immer mit der validierten IIR-Filterbank.
|
||||||
|
|
||||||
|
- [ ] **5. IIR-Filterbank fachgerecht für 31 Dritteloktavbänder auslegen**
|
||||||
|
- **Soll:** 31 Bänder, 20 Hz bis 20 kHz, korrekte Mittenfrequenzen, Bandkanten und Class-2-Toleranzen.
|
||||||
|
- **Ist:** Der RTW-Kern verwendet jetzt je Band einen vollständigen Butterworth-Bandpass sechster Ordnung aus drei unterschiedlichen SOS-Sektionen mit vorverzerrten Bandgrenzen und Normierung auf die Bandmitte. Die Anzeige behält die gerundeten RTW-Nominalwerte, während die Filter mit den exakten IEC-Basis-10-Mitten und -Bandkanten rechnen. Koeffizienten, Zustände und Leistungsrechnung laufen intern in `f64`, damit insbesondere das 20-Hz-Band stabil und genau bleibt.
|
||||||
|
- **Geprüft:** Automatische Frequenzgang- und Zeitbereichstests prüfen alle 31 Mitten, beide -3-dB-Bandkanten, Nachbarbandunterdrückung sowie 44,1, 48 und 96 kHz. A-, C- und Z-Bewertung werden sampleweise vor der Filterbank angewandt und gegen Normformeln geprüft.
|
||||||
|
- **Noch offen:** Der vollständige Class-2-Toleranzmasken-Nachweis und eine formelle Geräte-/Laborvalidierung fehlen; deshalb bleibt der Gesamtpunkt offen.
|
||||||
|
- **Abnahme:** Jedes der 31 Bänder besteht automatisierte Sweep- und Pegeltests innerhalb der festgelegten Toleranzen.
|
||||||
|
|
||||||
|
- [ ] **6. RTW-Integrationsmodi vollständig und energetisch korrekt implementieren**
|
||||||
|
- **Soll:** Fast, Medium, Slow, Average und Peak mit dokumentiertem RTW-nahem Verhalten.
|
||||||
|
- **Ist:** Fast, Medium, Slow und Impulse integrieren jetzt blockgrößenunabhängig im Leistungsbereich; Average bildet das kumulative Energiemittel, Peak den höchsten gefilterten Samplewert. Erst danach erfolgt die dB-Umrechnung.
|
||||||
|
- **Geprüft:** Ein Regressionstest bestätigt identische Integration bei unterschiedlichen Blockgrößen.
|
||||||
|
- **Noch offen:** Die gewählte Medium-Zeitkonstante von 0,5 s und die übrigen Profile müssen noch mit vollständigem RTW-Handbuch oder Referenzgerät abgeglichen werden; deshalb bleibt der Gesamtpunkt offen.
|
||||||
|
- **Abnahme:** Sprung-, Burst- und Rauschtests zeigen für jeden Modus reproduzierbares RTW-nahes Verhalten.
|
||||||
|
|
||||||
|
- [ ] **7. Peak Hold und Bandspeicher wie beim PortaMonitor ergänzen**
|
||||||
|
- **Soll:** Peak Hold 2,5 s, 4 s oder manuell; Speicher für acht Bänder plus Hold.
|
||||||
|
- **Ist:** Die Anzeige unterstützt jetzt Peak Hold 2,5 s, 4 s und manuell. Der ältere kontinuierliche Backend-Peak sowie der achtbandige RTW-Speicher sind noch nicht vollständig ersetzt beziehungsweise ergänzt.
|
||||||
|
- **Falsch/unvollständig:** Ein kontinuierlich fallender Peak ist funktional nicht dasselbe wie Peak Hold.
|
||||||
|
- **Aufgabe:** Hold-Zeit, manuellen Hold, Reset, Rücklauf nach Hold-Ende und acht auswählbare Speicherbänder implementieren. Aktuellwert und Holdwert visuell eindeutig trennen.
|
||||||
|
- **Abnahme:** Hold-Zeiten und Reset-Verhalten stimmen zeitlich und visuell mit der Referenz überein.
|
||||||
|
|
||||||
|
- [ ] **8. RTA-Anzeigeoptionen am PortaMonitor-Profil ausrichten**
|
||||||
|
- **Soll:** 31 Dritteloktavbänder, wählbarer Mess-/Anzeigebereich von 15, 30 oder 45 dB und optionales zweikanaliges Peakmeter.
|
||||||
|
- **Ist:** Mehrere BPO-Modi, RTW-/IEC-Layouts, frei konfigurierte Skalierung und Peak-Overlay sind vorhanden.
|
||||||
|
- **Unstimmig:** Phoenix-Erweiterungen und originale RTW-Funktionen sind nicht getrennt; die Skalen entsprechen nicht zwingend den drei RTW-Bereichen.
|
||||||
|
- **Aufgabe:** Ein gesperrtes RTW-1064X-Profil mit den Originaloptionen anbieten. 1/6 und 1/12 Oktave sowie andere Bereiche ausdrücklich als Phoenix-Erweiterung kennzeichnen.
|
||||||
|
- **Abnahme:** Das RTW-Profil lässt sich direkt anhand des Datenblatts und Referenzgeräts nachvollziehen.
|
||||||
|
|
||||||
|
## Priorität 2 - Gemeinsame Messgrundlage
|
||||||
|
|
||||||
|
- [x] **9. Tatsächliche ALSA-Parameter verwenden**
|
||||||
|
- **Soll:** Alle Filter, Zeitkonstanten, Frequenzachsen und Aufnahmen verwenden die tatsächlich ausgehandelte Hardwarekonfiguration.
|
||||||
|
- **Ist:** Phoenix liest Rate, Periode und Puffergröße nach der ALSA-Aushandlung zurück. DSP, RTA-Filter, Zeitkonstanten, Aufnahme-Metadaten, Status und Browserfrequenzachse verwenden die tatsächliche Rate; die tatsächliche Periode wird ebenfalls im Messpaket übertragen.
|
||||||
|
- **Geprüft:** RTA-Filtertests laufen bei 44,1, 48 und 96 kHz; die abschließende Prüfung mit real unterschiedlich aushandelnder Hardware bleibt Teil der Geräteabnahme.
|
||||||
|
- **Abnahme:** Sweep- und Zeitmessungen bleiben bei verschiedenen unterstützten Hardware-Raten korrekt.
|
||||||
|
|
||||||
|
- [ ] **10. True Peak kontinuierlich und blockübergreifend korrigieren**
|
||||||
|
- **Soll:** Intersample-Peaks werden unabhängig von ihrer Lage zum ALSA-Block zuverlässig erkannt.
|
||||||
|
- **Ist:** Eine 4-fache Sinc-Interpolation mit kurzer Historie existiert.
|
||||||
|
- **Falsch/kaputt:** Ungefähr die letzten acht Intervalle jedes Capture-Blocks werden nicht interpoliert und können zu niedrige dBTP-Werte liefern.
|
||||||
|
- **Aufgabe:** Kontinuierlichen Oversampling-Filter mit vollständiger Historie verwenden und gegen ITU-Testmaterial sowie synthetische Grenzfälle prüfen.
|
||||||
|
- **Abnahme:** Gleiche Peakwerte unabhängig von Blockgrenze, Periodengröße und Samplerate.
|
||||||
|
|
||||||
|
- [x] **11. DIN- und EBU-PPM softwareseitig norm- und RTW-nah auslegen**
|
||||||
|
- **Soll:** Richtige Skalen, Referenzpegel, Tonburst-Reaktion, Integration, Rücklauf, Peak Hold, Peak Memory und Over-Anzeige.
|
||||||
|
- **Ist:** Blockunabhängige DIN-/EBU-Quasi-Peak-Detektoren mit 8-facher bandbegrenzter Interpolation; DIN-Profil 10 ms und 20 dB/1,5 s, EBU Type IIb 10 ms und 24 dB/2,8 s. Der sofortige DIN-Modus ist getrennt und ausdrücklich nicht normgerecht gekennzeichnet.
|
||||||
|
- **Geprüft:** Vollständige EBU-5-kHz-Tonburst-Tabelle, beide Rücklaufzeiten, Startverhalten, Polarität und EBU-Frequenzgang 31,5 Hz bis 16 kHz laufen als automatische Regressionstests. Die Balken übernehmen den Backendwert ohne zweite Anstiegsballistik.
|
||||||
|
- **Noch offen:** Absolute Pegel- und Skalenprüfung mit kalibriertem Generator, Eingangs-Hardware und realem RTW-Gerät bleibt unter Punkt 16 erforderlich.
|
||||||
|
- **Abnahme:** Softwaretests bestehen; die endgültige Aussage zur Messgeräte-Konformität erfolgt erst nach der Hardwarevergleichsmessung.
|
||||||
|
|
||||||
|
- [ ] **12. VU und RMS eindeutig und reproduzierbar definieren**
|
||||||
|
- **Soll VU:** RTW-artige Moving-Coil-Ballistik mit richtigem Einschwingen, Rücklauf und Überschwingen.
|
||||||
|
- **Ist VU:** 300-ms-Rechteckmittel der gleichgerichteten Samples.
|
||||||
|
- **Falsch VU:** Boxcar-Mittelung entspricht nicht der mechanischen VU-Ballistik.
|
||||||
|
- **Soll RMS:** Dokumentiertes gleitendes Messfenster mit eindeutigem dBFS-/Kalibrierbezug.
|
||||||
|
- **Ist RMS:** RMS nur über den aktuellen ALSA-Block, bei 128 Samples etwa 2,67 ms.
|
||||||
|
- **Falsch RMS:** Wert und Unruhe hängen von der Periodengröße ab.
|
||||||
|
- **Aufgabe:** Beide Detektoren unabhängig von der Capture-Blockgröße implementieren und separat testen.
|
||||||
|
- **Abnahme:** Identische Werte und Ballistiken bei verschiedenen ALSA-Perioden.
|
||||||
|
|
||||||
|
- [ ] **13. FFT-Modus als optionale Spektrumsansicht fachlich korrigieren**
|
||||||
|
- **Soll:** FFT ist eine korrekte Zusatzansicht, aber nicht die RTW-IIR-Referenz.
|
||||||
|
- **Ist:** FFT-RTA und Spektrogramm sind vorhanden.
|
||||||
|
- **Falsch:** FFT-Bins werden innerhalb eines Bandes normiert gemittelt statt zur Bandenergie summiert. Rauschsignale und unterschiedlich breite Bänder werden dadurch falsch bewertet.
|
||||||
|
- **Aufgabe:** Binleistungen energetisch integrieren, Fensterleistung korrekt kompensieren und FFT-Ergebnisse gegen die IIR-Referenz testen.
|
||||||
|
- **Abnahme:** Konsistente Pegel bei FFT-Größenwechseln und eindeutig getrennte Kennzeichnung im UI.
|
||||||
|
|
||||||
|
## Priorität 3 - Stereoanzeigen und visuelles RTW-Verhalten
|
||||||
|
|
||||||
|
- [ ] **14. Goniometer-Datenweg und Persistenz optimieren**
|
||||||
|
- **Soll:** Aktuelle XY-Daten erscheinen im nächsten möglichen Bildschirmframe; Fast/Medium/Slow-Persistenz wirkt wie das RTW-Instrument.
|
||||||
|
- **Ist:** M/S-Darstellung, Gain, AGC, Linien/Punkte und eine 300-ms-Spur sind vorhanden.
|
||||||
|
- **Datenweg erledigt:** XY-Punkte werden mit begrenzter Rate über den eigenen Binärstrom übertragen; JSON-Kopien und unbeschränkte Warteschlangen entfallen.
|
||||||
|
- **Noch offen:** RTW-Persistenzmodi sind nicht verbindlich abgeglichen.
|
||||||
|
- **Aufgabe:** Fast/Medium/Slow-Persistenz am Referenzgerät abstimmen und die vorhandene Ringpufferdarstellung darauf kalibrieren.
|
||||||
|
- **Abnahme:** Geringe Reaktionslatenz ohne unnötige Allokationen, bei vergleichbarem visuellen Nachleuchten.
|
||||||
|
|
||||||
|
- [ ] **15. Korrelation vollständig am RTW-Verhalten prüfen**
|
||||||
|
- **Soll:** Bereich -1 bis +1, passende Farben, wählbare Ansprechzeiten von 1,0 s und 2,5 s sowie Negative-Peak-Memory.
|
||||||
|
- **Ist:** Korrelations- und Phasendarstellungen sind grundsätzlich vorhanden.
|
||||||
|
- **Ungeklärt:** Zeitkonstanten, Speicherverhalten und Genauigkeit sind nicht systematisch gegen das PortaMonitor geprüft.
|
||||||
|
- **Aufgabe:** Testsignale für +1, 0 und -1 sowie gleitende Phasenlagen verwenden; Memory und Reset ergänzen beziehungsweise validieren.
|
||||||
|
- **Abnahme:** Statische und dynamische Testsignale stimmen innerhalb der festgelegten Toleranz mit der Referenz überein.
|
||||||
|
|
||||||
|
## Priorität 4 - Nachweis und dauerhafte Absicherung
|
||||||
|
|
||||||
|
- [ ] **16. RTW-Referenzprofil vervollständigen**
|
||||||
|
- **Soll:** Modell, Firmware, Eingangsart, Skala, Referenzpegel und jedes nachzubildende Preset sind dokumentiert.
|
||||||
|
- **Ist:** Das Datenblatt des 1063X-/1064X-PLUS liegt vor und definiert viele Optionen, aber nicht alle internen Zeitkonstanten und Toleranzdetails.
|
||||||
|
- **Fehlt:** Vollständiges Bedienhandbuch und/oder reale Vergleichsmessungen.
|
||||||
|
- **Aufgabe:** Handbuchdaten und Messprotokolle ergänzen; exakt nachzubildende Funktionen von bewussten Phoenix-Erweiterungen trennen.
|
||||||
|
- **Abnahme:** Jede RTW-nahe Option besitzt eine nachvollziehbare Quelle oder ein Referenzmessprotokoll.
|
||||||
|
|
||||||
|
- [ ] **17. End-to-End-Latenz messen und begrenzen**
|
||||||
|
- **Soll:** Bei 60-Hz-Display typisch unter 25 ms zusätzliche Anzeigeverzögerung, normal unter 35 ms; bei 120 Hz möglichst 10 bis 20 ms. Keine anwachsende Warteschlange.
|
||||||
|
- **Ist:** 128-Sample-Capture und 60-Hz-Rendering sind grundsätzlich schnell, aber es gibt keine durchgängige Latenzmessung.
|
||||||
|
- **Fehlt:** Zeitstempel für Capture, DSP, Versand, Browserempfang und tatsächlichen Paint.
|
||||||
|
- **Aufgabe:** Monotone Zeitstempel, Median/P95/Maximum sowie sicht-/hörbaren Impulstest einführen. Instrumentenballistik separat ausweisen.
|
||||||
|
- **Abnahme:** Ziele werden unter realistischer Volllast eingehalten und Regressionen automatisch erkannt.
|
||||||
|
|
||||||
|
- [ ] **18. Automatisierte DSP- und Darstellungsregressionstests aufbauen**
|
||||||
|
- **Soll:** Keine Änderung kann unbemerkt Pegel, Frequenzgang, Ballistik, Latenz oder RTW-Darstellung verschlechtern.
|
||||||
|
- **Ist:** DIN-/EBU-PPM, Spektrogramm-Zeitbasis und Langlauf, Worker-Verhalten sowie die Binärprotokolle sind automatisiert abgesichert. Servertests prüfen außerdem das Zusammenführen der Waveform-Hüllkurve und die Trennung großer Nutzdaten vom JSON-Messstrom.
|
||||||
|
- **Noch offen:** Der RTA besitzt nun Tests für alle Bandmitten und -kanten, Nachbarbandunterdrückung, Zeitbereich, A/C/Z, mehrere Sampleraten und blockgrößenunabhängige Integration. True Peak, VU/RMS, Korrelation, reale Periodenvariation und End-to-End-Latenz besitzen noch keine vollständige automatische Regression. Deshalb bleibt dieser Gesamtpunkt offen.
|
||||||
|
- **Aufgabe:** Einzeltöne aller 31 Bänder, Sweeps, Weiß-/Rosarauschen, Pegelsprünge, Tonbursts, Phasen-/Korrelationssignale und Intersample-Peaks testen. Sampleraten, Perioden und Blockgrenzen variieren. RTW-Screenshots und Messprotokolle als Referenz verwenden, soweit rechtlich möglich.
|
||||||
|
- **Abnahme:** Automatischer Bericht mit Erwartungswerten und Toleranzen für jede Messfunktion.
|
||||||
|
|
||||||
|
## Empfohlene Bearbeitungsreihenfolge
|
||||||
|
|
||||||
|
1. Punkte 1 bis 3: anwachsende Transport- und Browserlatenz beseitigen.
|
||||||
|
2. Punkt 9: korrekte gemeinsame Zeit- und Frequenzbasis herstellen.
|
||||||
|
3. Punkte 4 bis 8: IIR-RTA als RTW-Kern verbindlich und korrekt auslegen.
|
||||||
|
4. Punkte 10 bis 15: weitere Messinstrumente korrigieren und abstimmen.
|
||||||
|
5. Punkte 16 bis 18: Referenz, Latenz und Messrichtigkeit dauerhaft nachweisen.
|
||||||
@@ -9,17 +9,19 @@
|
|||||||
"alMarkersEnabled": false,
|
"alMarkersEnabled": false,
|
||||||
"meterBarThin": 0.55,
|
"meterBarThin": 0.55,
|
||||||
"panelDividersEnabled": true,
|
"panelDividersEnabled": true,
|
||||||
"ppmDinAttackMs": 5.0,
|
"ppmDinAttackMs": 10.0,
|
||||||
"ppmDinDecayDbPerS": 11.8,
|
"ppmDinDecayDbPerS": 13.333333,
|
||||||
"ppmDinFastAttack": false,
|
"ppmDinFastAttack": false,
|
||||||
"ppmDinLoudnessBoxes": true,
|
"ppmDinLoudnessBoxes": true,
|
||||||
"ppmEbuAttackMs": 10.0,
|
"ppmEbuAttackMs": 10.0,
|
||||||
"ppmEbuDecayDbPerS": 8.6,
|
"ppmEbuDecayDbPerS": 8.571429,
|
||||||
"lufsIWindowMin": 4,
|
"lufsIWindowMin": 4,
|
||||||
"lufsINormEnabled": false,
|
"lufsINormEnabled": false,
|
||||||
"ppmDinLoudnessOffsetDb": 0.0,
|
"ppmDinLoudnessOffsetDb": 0.0,
|
||||||
"xyPoints": 1024,
|
"xyPoints": 1024,
|
||||||
"gonioDisplayGainDb": -5.0,
|
"gonioDisplayGainDb": -5.0,
|
||||||
|
"spectroGamma": 0.9,
|
||||||
|
"spectroScrollMode": 1.0,
|
||||||
"phaseAmplitudeMode": "ppm-din",
|
"phaseAmplitudeMode": "ppm-din",
|
||||||
"recordOutputFormat": "wav",
|
"recordOutputFormat": "wav",
|
||||||
"recordMp3BitrateKbps": 192,
|
"recordMp3BitrateKbps": 192,
|
||||||
|
|||||||
@@ -9,17 +9,19 @@
|
|||||||
"alMarkersEnabled": false,
|
"alMarkersEnabled": false,
|
||||||
"meterBarThin": 0.55,
|
"meterBarThin": 0.55,
|
||||||
"panelDividersEnabled": true,
|
"panelDividersEnabled": true,
|
||||||
"ppmDinAttackMs": 5.0,
|
"ppmDinAttackMs": 10.0,
|
||||||
"ppmDinDecayDbPerS": 11.8,
|
"ppmDinDecayDbPerS": 13.333333,
|
||||||
"ppmDinFastAttack": false,
|
"ppmDinFastAttack": false,
|
||||||
"ppmDinLoudnessBoxes": true,
|
"ppmDinLoudnessBoxes": true,
|
||||||
"ppmEbuAttackMs": 10.0,
|
"ppmEbuAttackMs": 10.0,
|
||||||
"ppmEbuDecayDbPerS": 8.6,
|
"ppmEbuDecayDbPerS": 8.571429,
|
||||||
"lufsIWindowMin": 4,
|
"lufsIWindowMin": 4,
|
||||||
"lufsINormEnabled": false,
|
"lufsINormEnabled": false,
|
||||||
"ppmDinLoudnessOffsetDb": 0.0,
|
"ppmDinLoudnessOffsetDb": 0.0,
|
||||||
"xyPoints": 1024,
|
"xyPoints": 1024,
|
||||||
"gonioDisplayGainDb": -5.0,
|
"gonioDisplayGainDb": -5.0,
|
||||||
|
"spectroGamma": 0.9,
|
||||||
|
"spectroScrollMode": 1.0,
|
||||||
"phaseAmplitudeMode": "ppm-din",
|
"phaseAmplitudeMode": "ppm-din",
|
||||||
"recordOutputFormat": "wav",
|
"recordOutputFormat": "wav",
|
||||||
"recordMp3BitrateKbps": 192,
|
"recordMp3BitrateKbps": 192,
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
|
||||||
|
const source = fs.readFileSync(new URL('../www/core/binary_protocol.js', import.meta.url), 'utf8');
|
||||||
|
const context = vm.createContext({ ArrayBuffer, DataView, Float32Array, Number });
|
||||||
|
vm.runInContext(source.replaceAll('export function', 'function'), context, {
|
||||||
|
filename: 'binary_protocol.js',
|
||||||
|
});
|
||||||
|
|
||||||
|
function spectroPacket() {
|
||||||
|
const buffer = new ArrayBuffer(28);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
view.setUint32(0, 0x50585350, true);
|
||||||
|
view.setUint32(4, 42, true);
|
||||||
|
view.setUint32(8, 48000, true);
|
||||||
|
view.setUint32(12, 8192, true);
|
||||||
|
view.setUint32(16, 2, true);
|
||||||
|
view.setFloat32(20, -80, true);
|
||||||
|
view.setFloat32(24, -12.5, true);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visualsPacket() {
|
||||||
|
const buffer = new ArrayBuffer(64);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
view.setUint32(0, 0x50585653, true);
|
||||||
|
view.setUint16(4, 1, true);
|
||||||
|
view.setUint16(6, 3, true);
|
||||||
|
view.setUint32(8, 7, true);
|
||||||
|
view.setUint32(12, 2, true);
|
||||||
|
view.setUint32(16, 2, true);
|
||||||
|
view.setUint16(20, 1, true);
|
||||||
|
view.setUint32(24, 5, true);
|
||||||
|
view.setUint32(28, 48000, true);
|
||||||
|
[-0.5, 0.25, 0.5, -0.25, -0.5, 0.5, -0.25, 0.25]
|
||||||
|
.forEach((value, index) => view.setFloat32(32 + index * 4, value, true));
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const spectro = context.decodePhoenixSpectroBuffer(spectroPacket());
|
||||||
|
assert.equal(spectro.seq, 42);
|
||||||
|
assert.equal(spectro.sampleRate, 48000);
|
||||||
|
assert.equal(spectro.fftSize, 8192);
|
||||||
|
assert.deepEqual(Array.from(spectro.bins), [-80, -12.5]);
|
||||||
|
|
||||||
|
const visual = context.decodePhoenixVisualsBuffer(visualsPacket());
|
||||||
|
assert.equal(visual.seq, 7);
|
||||||
|
assert.deepEqual(Array.from(visual.left), [-0.5, 0.5]);
|
||||||
|
assert.deepEqual(Array.from(visual.right), [0.25, -0.25]);
|
||||||
|
assert.equal(visual.wave.columns, 2);
|
||||||
|
assert.equal(visual.wave.channels, 1);
|
||||||
|
assert.deepEqual(Array.from(visual.wave.data), [-0.5, 0.5, -0.25, 0.25]);
|
||||||
|
|
||||||
|
assert.equal(context.decodePhoenixSpectroBuffer(spectroPacket().slice(0, 24)), null);
|
||||||
|
assert.equal(context.decodePhoenixVisualsBuffer(visualsPacket().slice(0, 60)), null);
|
||||||
|
const wrongVersion = visualsPacket();
|
||||||
|
new DataView(wrongVersion).setUint16(4, 2, true);
|
||||||
|
assert.equal(context.decodePhoenixVisualsBuffer(wrongVersion), null);
|
||||||
|
|
||||||
|
console.log('binary protocol regression tests passed');
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
|
||||||
|
const source = fs.readFileSync(new URL('../www/core/audio.js', import.meta.url), 'utf8');
|
||||||
|
const functionSource = source.match(/export function buildRtaRuntimeConfig[\s\S]*?\n\}/)?.[0];
|
||||||
|
assert.ok(functionSource, 'buildRtaRuntimeConfig must remain testable');
|
||||||
|
const context = vm.createContext({
|
||||||
|
Number,
|
||||||
|
getRtwCenters(mode) {
|
||||||
|
return mode === '1_3' ? new Array(31).fill(0) : [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vm.runInContext(functionSource.replace('export function', 'function'), context);
|
||||||
|
|
||||||
|
const forced = context.buildRtaRuntimeConfig({
|
||||||
|
RTA_BAR_LAYOUT: 'rtw',
|
||||||
|
RTA_ENGINE: 'fft',
|
||||||
|
RTA_BPO_MODE: '1_12',
|
||||||
|
RTA_FREQ_RANGE: 'lf',
|
||||||
|
RTA_IIR_ORDER: 2,
|
||||||
|
});
|
||||||
|
assert.equal(forced.engine, 'iir');
|
||||||
|
assert.equal(forced.bpo, '1_3');
|
||||||
|
assert.equal(forced.freqRange, 'norm');
|
||||||
|
assert.equal(forced.order, 6);
|
||||||
|
assert.equal(forced.rtwCenters.length, 31);
|
||||||
|
|
||||||
|
const extension = context.buildRtaRuntimeConfig({
|
||||||
|
RTA_BAR_LAYOUT: 'iec',
|
||||||
|
RTA_ENGINE: 'fft',
|
||||||
|
RTA_BPO_MODE: '1_12',
|
||||||
|
RTA_FREQ_RANGE: 'lf',
|
||||||
|
RTA_IIR_ORDER: 8,
|
||||||
|
});
|
||||||
|
assert.equal(extension.engine, 'fft');
|
||||||
|
assert.equal(extension.bpo, '1_12');
|
||||||
|
assert.equal(extension.freqRange, 'lf');
|
||||||
|
assert.equal(extension.order, 8);
|
||||||
|
|
||||||
|
console.log('RTA profile regression tests passed');
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
|
||||||
|
const viewSource = fs.readFileSync(new URL('../www/views/spectrogram.js', import.meta.url), 'utf8');
|
||||||
|
const timingSource = viewSource.match(/export function stepScrollAccumulator[\s\S]*?\n\}/)?.[0];
|
||||||
|
assert.ok(timingSource, 'stepScrollAccumulator must remain testable');
|
||||||
|
const timingContext = vm.createContext({ Number, Math });
|
||||||
|
vm.runInContext(timingSource.replace('export function', 'function'), timingContext);
|
||||||
|
const { stepScrollAccumulator } = timingContext;
|
||||||
|
const pixelsSource = viewSource.match(/export function spectrogramPixelsPerSourceFrame[\s\S]*?\n\}/)?.[0];
|
||||||
|
assert.ok(pixelsSource, 'spectrogramPixelsPerSourceFrame must remain testable');
|
||||||
|
timingContext.BASE_SCROLL_CSS_PX_PER_SECOND = 60;
|
||||||
|
vm.runInContext(pixelsSource.replace('export function', 'function'), timingContext);
|
||||||
|
const { spectrogramPixelsPerSourceFrame } = timingContext;
|
||||||
|
|
||||||
|
function emittedColumns(rate, frames, pixelScale = 1) {
|
||||||
|
let accumulator = 0;
|
||||||
|
let columns = 0;
|
||||||
|
for (let frame = 0; frame < frames; frame++) {
|
||||||
|
const step = stepScrollAccumulator(accumulator, rate * pixelScale, 1);
|
||||||
|
accumulator = step.accumulator;
|
||||||
|
columns += step.columns;
|
||||||
|
}
|
||||||
|
return columns;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(emittedColumns(0.5, 20), 10, '0.5x must emit every second source frame');
|
||||||
|
assert.equal(emittedColumns(1, 20), 20);
|
||||||
|
assert.equal(emittedColumns(2, 20), 40);
|
||||||
|
assert.equal(emittedColumns(4, 20), 80);
|
||||||
|
assert.equal(emittedColumns(6, 20), 120);
|
||||||
|
assert.equal(emittedColumns(1, 10, 1.7), 17, 'DPR scaling must preserve CSS scroll speed');
|
||||||
|
|
||||||
|
function emittedInOneSecond(rate, fftSize) {
|
||||||
|
const sampleRate = 48000;
|
||||||
|
const hop = Math.max(128, Math.floor(fftSize / 8));
|
||||||
|
const sourceFrames = sampleRate / hop;
|
||||||
|
const pixelsPerFrame = spectrogramPixelsPerSourceFrame(rate, sampleRate, fftSize);
|
||||||
|
return emittedColumns(pixelsPerFrame, sourceFrames);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(emittedInOneSecond(0.5, 4096), 30);
|
||||||
|
assert.equal(emittedInOneSecond(1, 4096), 60);
|
||||||
|
assert.equal(emittedInOneSecond(1, 8192), 60, 'FFT size must not alter real-time scroll speed');
|
||||||
|
assert.equal(emittedInOneSecond(2, 8192), 120);
|
||||||
|
assert.equal(emittedInOneSecond(4, 8192), 240);
|
||||||
|
assert.equal(emittedInOneSecond(6, 8192), 360);
|
||||||
|
|
||||||
|
const messages = [];
|
||||||
|
const calls = { drawImage: 0, putImageData: [] };
|
||||||
|
const context2d = {
|
||||||
|
imageSmoothingEnabled: true,
|
||||||
|
globalCompositeOperation: 'source-over',
|
||||||
|
fillStyle: '#000',
|
||||||
|
save() {},
|
||||||
|
restore() {},
|
||||||
|
fillRect() {},
|
||||||
|
drawImage() { calls.drawImage += 1; },
|
||||||
|
createImageData(width, height) {
|
||||||
|
return { width, height, data: new Uint8ClampedArray(width * height * 4) };
|
||||||
|
},
|
||||||
|
putImageData(image, x, y) { calls.putImageData.push({ image, x, y }); },
|
||||||
|
};
|
||||||
|
const canvas = {
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
getContext() { return context2d; },
|
||||||
|
};
|
||||||
|
const workerContext = vm.createContext({
|
||||||
|
self: {},
|
||||||
|
postMessage(message) { messages.push(message); },
|
||||||
|
performance: { now: (() => { let now = 0; return () => ++now; })() },
|
||||||
|
Float32Array,
|
||||||
|
Uint8ClampedArray,
|
||||||
|
Number,
|
||||||
|
Math,
|
||||||
|
});
|
||||||
|
const workerSource = fs.readFileSync(new URL('../www/workers/spectrogram.worker.js', import.meta.url), 'utf8');
|
||||||
|
vm.runInContext(workerSource, workerContext, { filename: 'spectrogram.worker.js' });
|
||||||
|
|
||||||
|
workerContext.self.onmessage({
|
||||||
|
data: { type: 'init', canvas, width: 8, height: 4, topDb: 0, bottomDb: -80, gamma: 1 },
|
||||||
|
});
|
||||||
|
assert.equal(messages.at(-1)?.type, 'ready');
|
||||||
|
|
||||||
|
workerContext.self.onmessage({
|
||||||
|
data: { type: 'column', id: 7, repeat: 2, data: new Float32Array([-80, -40, -20, 0]) },
|
||||||
|
});
|
||||||
|
const ack = messages.at(-1);
|
||||||
|
assert.equal(ack.type, 'drawn');
|
||||||
|
assert.equal(ack.id, 7);
|
||||||
|
assert.equal(ack.repeat, 2);
|
||||||
|
assert.equal(calls.drawImage, 1, 'existing canvas should be shifted once');
|
||||||
|
assert.equal(calls.putImageData.length, 1, 'only the new stripe should be uploaded');
|
||||||
|
assert.equal(calls.putImageData[0].image.width, 2);
|
||||||
|
assert.equal(calls.putImageData[0].x, 6);
|
||||||
|
|
||||||
|
// Roughly ten minutes at 100 source columns/s. The worker has no history or
|
||||||
|
// pending-message collection that can grow with runtime.
|
||||||
|
messages.length = 0;
|
||||||
|
context2d.putImageData = () => {};
|
||||||
|
const longRunColumn = new Float32Array([-80, -40, -20, 0]);
|
||||||
|
for (let index = 0; index < 60_000; index++) {
|
||||||
|
workerContext.self.onmessage({
|
||||||
|
data: { type: 'column', id: 100 + index, repeat: 1, data: longRunColumn },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert.equal(messages.length, 60_000);
|
||||||
|
assert.equal(messages.at(-1)?.type, 'drawn');
|
||||||
|
assert.equal(messages.at(-1)?.id, 60_099);
|
||||||
|
|
||||||
|
console.log('spectrogram timing and worker tests passed');
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
const base = process.env.PHOENIX_TEST_WS_BASE || 'ws://127.0.0.1:8789';
|
||||||
|
|
||||||
|
function openSocket(path) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = new WebSocket(`${base}${path}`);
|
||||||
|
socket.addEventListener('open', () => resolve(socket), { once: true });
|
||||||
|
socket.addEventListener('error', () => reject(new Error(`failed to open ${path}`)), { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sockets = await Promise.all([
|
||||||
|
openSocket('/api/v1/metrics/ws'),
|
||||||
|
openSocket('/api/v1/spectro/ws'),
|
||||||
|
openSocket('/api/v1/visuals/ws'),
|
||||||
|
]);
|
||||||
|
const [metrics] = sockets;
|
||||||
|
let count = 0;
|
||||||
|
let lastSeq = 0;
|
||||||
|
let invalidPayload = false;
|
||||||
|
metrics.addEventListener('message', (event) => {
|
||||||
|
const frame = JSON.parse(String(event.data));
|
||||||
|
const seq = Number(frame.seq);
|
||||||
|
if (seq <= lastSeq || 'spectro' in frame || 'wave_env' in frame || 'xy_l' in frame || 'wave_l' in frame) {
|
||||||
|
invalidPayload = true;
|
||||||
|
}
|
||||||
|
lastSeq = seq;
|
||||||
|
count += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||||
|
sockets.forEach((socket) => socket.close());
|
||||||
|
assert.equal(invalidPayload, false, 'metrics must be ordered and contain no large visual payloads');
|
||||||
|
assert.ok(count >= 50 && count <= 75, `expected about 60 metrics/s, received ${count}`);
|
||||||
|
console.log(`runtime websocket test passed (${count} metrics in 1.1 s; all three streams opened)`);
|
||||||
+374
-325
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -80,7 +80,12 @@ impl PhoenixConfig {
|
|||||||
.unwrap_or(2 * 1024 * 1024 * 1024),
|
.unwrap_or(2 * 1024 * 1024 * 1024),
|
||||||
lr_fractional_delay_enabled: std::env::var("PHOENIX_LR_FRAC_DELAY_ENABLED")
|
lr_fractional_delay_enabled: std::env::var("PHOENIX_LR_FRAC_DELAY_ENABLED")
|
||||||
.ok()
|
.ok()
|
||||||
.map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
|
.map(|v| {
|
||||||
|
matches!(
|
||||||
|
v.trim().to_ascii_lowercase().as_str(),
|
||||||
|
"1" | "true" | "yes" | "on"
|
||||||
|
)
|
||||||
|
})
|
||||||
.unwrap_or(true),
|
.unwrap_or(true),
|
||||||
lr_fractional_delay_samples: std::env::var("PHOENIX_LR_FRAC_DELAY_SAMPLES")
|
lr_fractional_delay_samples: std::env::var("PHOENIX_LR_FRAC_DELAY_SAMPLES")
|
||||||
.ok()
|
.ok()
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
//! Continuous stereo correlation meter.
|
||||||
|
//!
|
||||||
|
//! The detector integrates L², R² and L·R with the same time constant and
|
||||||
|
//! derives the normalized correlation only afterwards. Its timing therefore
|
||||||
|
//! does not depend on ALSA period size or browser frame rate.
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct CorrelationMeter {
|
||||||
|
sample_rate: u32,
|
||||||
|
response_seconds: f32,
|
||||||
|
alpha: f64,
|
||||||
|
power_l: f64,
|
||||||
|
power_r: f64,
|
||||||
|
cross_power: f64,
|
||||||
|
value: f32,
|
||||||
|
negative_peak: f32,
|
||||||
|
reset_token: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CorrelationMeter {
|
||||||
|
pub fn new(sample_rate: u32, response_seconds: f32, reset_token: u64) -> Self {
|
||||||
|
let mut meter = Self {
|
||||||
|
sample_rate: 0,
|
||||||
|
response_seconds: 0.0,
|
||||||
|
alpha: 1.0,
|
||||||
|
power_l: 0.0,
|
||||||
|
power_r: 0.0,
|
||||||
|
cross_power: 0.0,
|
||||||
|
value: 0.0,
|
||||||
|
negative_peak: 1.0,
|
||||||
|
reset_token,
|
||||||
|
};
|
||||||
|
meter.configure(sample_rate, response_seconds, reset_token);
|
||||||
|
meter
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn configure(&mut self, sample_rate: u32, response_seconds: f32, reset_token: u64) {
|
||||||
|
let sample_rate = sample_rate.max(8_000);
|
||||||
|
let response_seconds = normalize_response_seconds(response_seconds);
|
||||||
|
if self.sample_rate != sample_rate || self.response_seconds != response_seconds {
|
||||||
|
self.sample_rate = sample_rate;
|
||||||
|
self.response_seconds = response_seconds;
|
||||||
|
self.alpha = 1.0
|
||||||
|
- (-1.0 / (f64::from(sample_rate) * f64::from(response_seconds))).exp();
|
||||||
|
}
|
||||||
|
if self.reset_token != reset_token {
|
||||||
|
self.reset_token = reset_token;
|
||||||
|
self.negative_peak = 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process(&mut self, left: f32, right: f32) {
|
||||||
|
let l = f64::from(left);
|
||||||
|
let r = f64::from(right);
|
||||||
|
self.power_l += self.alpha * (l * l - self.power_l);
|
||||||
|
self.power_r += self.alpha * (r * r - self.power_r);
|
||||||
|
self.cross_power += self.alpha * (l * r - self.cross_power);
|
||||||
|
|
||||||
|
// Below roughly -100 dBFS RMS per channel the quotient is no longer a
|
||||||
|
// useful phase measurement and should settle at the neutral position.
|
||||||
|
const MIN_POWER: f64 = 1.0e-10;
|
||||||
|
let denominator = (self.power_l * self.power_r).sqrt();
|
||||||
|
self.value = if self.power_l > MIN_POWER && self.power_r > MIN_POWER && denominator > 0.0 {
|
||||||
|
(self.cross_power / denominator).clamp(-1.0, 1.0) as f32
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
if self.value < self.negative_peak {
|
||||||
|
self.negative_peak = self.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn value(&self) -> f32 {
|
||||||
|
self.value
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn negative_peak(&self) -> f32 {
|
||||||
|
self.negative_peak
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_response_seconds(value: f32) -> f32 {
|
||||||
|
if value.is_finite() && value >= 1.75 {
|
||||||
|
2.5
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn run_signal<F>(meter: &mut CorrelationMeter, seconds: usize, mut signal: F)
|
||||||
|
where
|
||||||
|
F: FnMut(usize) -> (f32, f32),
|
||||||
|
{
|
||||||
|
let count = meter.sample_rate as usize * seconds;
|
||||||
|
for index in 0..count {
|
||||||
|
let (left, right) = signal(index);
|
||||||
|
meter.process(left, right);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_positive_negative_and_quadrature_signals() {
|
||||||
|
let sample_rate = 48_000;
|
||||||
|
let phase_step = 2.0 * std::f32::consts::PI * 1_000.0 / sample_rate as f32;
|
||||||
|
for (phase, expected) in [(0.0, 1.0), (std::f32::consts::PI, -1.0), (std::f32::consts::FRAC_PI_2, 0.0)] {
|
||||||
|
let mut meter = CorrelationMeter::new(sample_rate, 1.0, 0);
|
||||||
|
run_signal(&mut meter, 5, |index| {
|
||||||
|
let angle = phase_step * index as f32;
|
||||||
|
(angle.sin(), (angle + phase).sin())
|
||||||
|
});
|
||||||
|
assert!((meter.value() - expected).abs() < 0.002, "phase {phase}: {}", meter.value());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn response_time_and_peak_reset_are_deterministic() {
|
||||||
|
let sample_rate = 48_000;
|
||||||
|
let mut fast = CorrelationMeter::new(sample_rate, 1.0, 0);
|
||||||
|
let mut slow = CorrelationMeter::new(sample_rate, 2.5, 0);
|
||||||
|
for index in 0..sample_rate as usize {
|
||||||
|
let sample = if index & 1 == 0 { 0.5 } else { -0.5 };
|
||||||
|
fast.process(sample, sample);
|
||||||
|
slow.process(sample, sample);
|
||||||
|
}
|
||||||
|
assert!(fast.value() > 0.999);
|
||||||
|
assert!(slow.value() > 0.999);
|
||||||
|
run_signal(&mut fast, 2, |index| {
|
||||||
|
let sample = if index & 1 == 0 { 0.5 } else { -0.5 };
|
||||||
|
(sample, -sample)
|
||||||
|
});
|
||||||
|
assert!(fast.negative_peak() < -0.7);
|
||||||
|
fast.configure(sample_rate, 1.0, 1);
|
||||||
|
assert_eq!(fast.negative_peak(), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn silence_and_single_channel_are_neutral() {
|
||||||
|
let mut meter = CorrelationMeter::new(48_000, 1.0, 0);
|
||||||
|
run_signal(&mut meter, 2, |_| (0.0, 0.0));
|
||||||
|
assert_eq!(meter.value(), 0.0);
|
||||||
|
run_signal(&mut meter, 2, |index| {
|
||||||
|
let sample = if index & 1 == 0 { 0.5 } else { -0.5 };
|
||||||
|
(sample, 0.0)
|
||||||
|
});
|
||||||
|
assert_eq!(meter.value(), 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
-11
@@ -1,13 +1,19 @@
|
|||||||
mod audio;
|
mod audio;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod correlation;
|
||||||
mod model;
|
mod model;
|
||||||
|
mod ppm;
|
||||||
mod routes;
|
mod routes;
|
||||||
|
mod rta;
|
||||||
mod state;
|
mod state;
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use axum::{routing::{get, post}, Router};
|
use axum::{
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
use config::PhoenixConfig;
|
use config::PhoenixConfig;
|
||||||
use state::AppState;
|
use state::AppState;
|
||||||
use tower_http::{cors::CorsLayer, trace::TraceLayer};
|
use tower_http::{cors::CorsLayer, trace::TraceLayer};
|
||||||
@@ -30,18 +36,50 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/health", get(routes::health))
|
.route("/health", get(routes::health))
|
||||||
.route("/api/v1/status", get(routes::status))
|
.route("/api/v1/status", get(routes::status))
|
||||||
.route("/api/v1/global-config", get(routes::get_global_config).post(routes::set_global_config))
|
.route(
|
||||||
.route("/api/v1/frontend-presets", get(routes::get_frontend_presets).post(routes::set_frontend_preset))
|
"/api/v1/global-config",
|
||||||
.route("/api/v1/frontend-layouts", get(routes::get_frontend_layouts).post(routes::set_frontend_layout))
|
get(routes::get_global_config).post(routes::set_global_config),
|
||||||
.route("/api/v1/update/download", post(routes::download_online_update))
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/frontend-presets",
|
||||||
|
get(routes::get_frontend_presets).post(routes::set_frontend_preset),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/frontend-layouts",
|
||||||
|
get(routes::get_frontend_layouts).post(routes::set_frontend_layout),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/update/download",
|
||||||
|
post(routes::download_online_update),
|
||||||
|
)
|
||||||
.route("/api/v1/update/log", get(routes::get_online_update_log))
|
.route("/api/v1/update/log", get(routes::get_online_update_log))
|
||||||
.route("/api/v1/update/restart", post(routes::restart_after_online_update))
|
.route(
|
||||||
.route("/api/v1/rta-config", get(routes::get_rta_config).post(routes::set_rta_config))
|
"/api/v1/update/restart",
|
||||||
.route("/api/v1/recordings/wav/start/:session_id", post(routes::start_wav_recording))
|
post(routes::restart_after_online_update),
|
||||||
.route("/api/v1/recordings/wav/stop/:session_id", post(routes::stop_wav_recording))
|
)
|
||||||
.route("/api/v1/recordings/stop/:session_id/:format", post(routes::stop_recording_with_format))
|
.route(
|
||||||
.route("/api/v1/recordings/save/:target/:filename", post(routes::save_recording_file))
|
"/api/v1/rta-config",
|
||||||
|
get(routes::get_rta_config).post(routes::set_rta_config),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/recordings/wav/start/:session_id",
|
||||||
|
post(routes::start_wav_recording),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/recordings/wav/stop/:session_id",
|
||||||
|
post(routes::stop_wav_recording),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/recordings/stop/:session_id/:format",
|
||||||
|
post(routes::stop_recording_with_format),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/recordings/save/:target/:filename",
|
||||||
|
post(routes::save_recording_file),
|
||||||
|
)
|
||||||
.route("/api/v1/metrics/ws", get(routes::metrics_ws))
|
.route("/api/v1/metrics/ws", get(routes::metrics_ws))
|
||||||
|
.route("/api/v1/spectro/ws", get(routes::spectro_ws))
|
||||||
|
.route("/api/v1/visuals/ws", get(routes::visuals_ws))
|
||||||
.layer(CorsLayer::permissive())
|
.layer(CorsLayer::permissive())
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|||||||
+24
-10
@@ -21,7 +21,6 @@ pub struct RtaFrame {
|
|||||||
pub engine: String,
|
pub engine: String,
|
||||||
pub bands_avg: Vec<f32>,
|
pub bands_avg: Vec<f32>,
|
||||||
pub bands_peak: Vec<f32>,
|
pub bands_peak: Vec<f32>,
|
||||||
pub bands: Vec<f32>,
|
|
||||||
pub centers: Vec<f32>,
|
pub centers: Vec<f32>,
|
||||||
pub freq_min: f32,
|
pub freq_min: f32,
|
||||||
pub freq_max: f32,
|
pub freq_max: f32,
|
||||||
@@ -33,6 +32,7 @@ pub struct RtaFrame {
|
|||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
pub struct SpectroFrame {
|
pub struct SpectroFrame {
|
||||||
|
pub seq: u64,
|
||||||
pub bins: Vec<f32>,
|
pub bins: Vec<f32>,
|
||||||
pub sample_rate: u32,
|
pub sample_rate: u32,
|
||||||
pub fft_size: u32,
|
pub fft_size: u32,
|
||||||
@@ -72,6 +72,9 @@ pub struct PhoenixRtaConfig {
|
|||||||
pub ppm_ebu_decay_db_per_s: f32,
|
pub ppm_ebu_decay_db_per_s: f32,
|
||||||
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_reset_token: u64,
|
||||||
|
pub xy_points: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for PhoenixRtaConfig {
|
impl Default for PhoenixRtaConfig {
|
||||||
@@ -82,23 +85,26 @@ impl Default for PhoenixRtaConfig {
|
|||||||
mono_input: false,
|
mono_input: false,
|
||||||
lr_fractional_delay_enabled: true,
|
lr_fractional_delay_enabled: true,
|
||||||
lr_fractional_delay_samples: 0.05,
|
lr_fractional_delay_samples: 0.05,
|
||||||
bpo: "1_6".to_string(),
|
bpo: "1_3".to_string(),
|
||||||
freq_range: "norm".to_string(),
|
freq_range: "norm".to_string(),
|
||||||
weighting: "z".to_string(),
|
weighting: "z".to_string(),
|
||||||
order: 4,
|
order: 6,
|
||||||
tau_fast: 0.12,
|
tau_fast: 0.12,
|
||||||
tau_slow: 1.0,
|
tau_slow: 1.0,
|
||||||
integration: "fast".to_string(),
|
integration: "fast".to_string(),
|
||||||
layout: "rtw".to_string(),
|
layout: "rtw".to_string(),
|
||||||
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: 5.0,
|
ppm_din_attack_ms: 10.0,
|
||||||
ppm_din_decay_db_per_s: 11.8,
|
ppm_din_decay_db_per_s: 20.0 / 1.5,
|
||||||
ppm_din_fast_attack: false,
|
ppm_din_fast_attack: false,
|
||||||
ppm_ebu_attack_ms: 10.0,
|
ppm_ebu_attack_ms: 10.0,
|
||||||
ppm_ebu_decay_db_per_s: 8.6,
|
ppm_ebu_decay_db_per_s: 24.0 / 2.8,
|
||||||
lufs_i_window_min: 4,
|
lufs_i_window_min: 4,
|
||||||
lufs_i_norm_enabled: false,
|
lufs_i_norm_enabled: false,
|
||||||
|
correlation_response_s: 1.0,
|
||||||
|
correlation_reset_token: 0,
|
||||||
|
xy_points: 1024,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,6 +142,8 @@ pub struct PhoenixGlobalConfig {
|
|||||||
pub clock_led_color: String,
|
pub clock_led_color: String,
|
||||||
pub header_text_color: String,
|
pub header_text_color: String,
|
||||||
pub rta_bar_base_color: String,
|
pub rta_bar_base_color: String,
|
||||||
|
pub spectro_gamma: f32,
|
||||||
|
pub spectro_scroll_mode: f32,
|
||||||
pub peak_history_scroll_mode: f32,
|
pub peak_history_scroll_mode: f32,
|
||||||
pub peak_history_fill_enabled: bool,
|
pub peak_history_fill_enabled: bool,
|
||||||
pub peak_history_fill_invert: bool,
|
pub peak_history_fill_invert: bool,
|
||||||
@@ -175,7 +183,7 @@ impl Default for PhoenixGlobalConfig {
|
|||||||
Self {
|
Self {
|
||||||
fft_size: 8192,
|
fft_size: 8192,
|
||||||
input_source: InputSource::Line,
|
input_source: InputSource::Line,
|
||||||
rta_bpo_mode: "1_6".to_string(),
|
rta_bpo_mode: "1_3".to_string(),
|
||||||
input_offset_db_l: -5.0,
|
input_offset_db_l: -5.0,
|
||||||
input_offset_db_r: -5.0,
|
input_offset_db_r: -5.0,
|
||||||
mono_input: false,
|
mono_input: false,
|
||||||
@@ -184,11 +192,11 @@ impl Default for PhoenixGlobalConfig {
|
|||||||
al_markers_enabled: false,
|
al_markers_enabled: false,
|
||||||
meter_bar_thin: 0.55,
|
meter_bar_thin: 0.55,
|
||||||
panel_dividers_enabled: true,
|
panel_dividers_enabled: true,
|
||||||
ppm_din_attack_ms: 5.0,
|
ppm_din_attack_ms: 10.0,
|
||||||
ppm_din_decay_db_per_s: 11.8,
|
ppm_din_decay_db_per_s: 20.0 / 1.5,
|
||||||
ppm_din_fast_attack: false,
|
ppm_din_fast_attack: false,
|
||||||
ppm_ebu_attack_ms: 10.0,
|
ppm_ebu_attack_ms: 10.0,
|
||||||
ppm_ebu_decay_db_per_s: 8.6,
|
ppm_ebu_decay_db_per_s: 24.0 / 2.8,
|
||||||
lufs_i_window_min: 4,
|
lufs_i_window_min: 4,
|
||||||
lufs_i_norm_enabled: false,
|
lufs_i_norm_enabled: false,
|
||||||
ppm_din_loudness_boxes: true,
|
ppm_din_loudness_boxes: true,
|
||||||
@@ -203,6 +211,8 @@ impl Default for PhoenixGlobalConfig {
|
|||||||
clock_led_color: "#ff0000".to_string(),
|
clock_led_color: "#ff0000".to_string(),
|
||||||
header_text_color: "#ffe066".to_string(),
|
header_text_color: "#ffe066".to_string(),
|
||||||
rta_bar_base_color: "#ffe066".to_string(),
|
rta_bar_base_color: "#ffe066".to_string(),
|
||||||
|
spectro_gamma: 0.9,
|
||||||
|
spectro_scroll_mode: 1.0,
|
||||||
peak_history_scroll_mode: 1.0,
|
peak_history_scroll_mode: 1.0,
|
||||||
peak_history_fill_enabled: false,
|
peak_history_fill_enabled: false,
|
||||||
peak_history_fill_invert: false,
|
peak_history_fill_invert: false,
|
||||||
@@ -250,6 +260,10 @@ pub struct PhoenixGlobalConfigEnvelope {
|
|||||||
pub struct MeterFrame {
|
pub struct MeterFrame {
|
||||||
pub seq: u64,
|
pub seq: u64,
|
||||||
pub timestamp_ms: u128,
|
pub timestamp_ms: u128,
|
||||||
|
pub sample_rate: u32,
|
||||||
|
pub period_size: u32,
|
||||||
|
pub correlation: f32,
|
||||||
|
pub correlation_negative_peak: f32,
|
||||||
pub rms_l: f32,
|
pub rms_l: f32,
|
||||||
pub rms_r: f32,
|
pub rms_r: f32,
|
||||||
pub vu_l: f32,
|
pub vu_l: f32,
|
||||||
|
|||||||
+342
@@ -0,0 +1,342 @@
|
|||||||
|
//! Continuous quasi-peak detectors for the Phoenix DIN/EBU programme meters.
|
||||||
|
//!
|
||||||
|
//! The detector is deliberately independent of ALSA block boundaries. Its two
|
||||||
|
//! attack branches are calibrated against the 5 kHz tone-burst response in EBU
|
||||||
|
//! Tech 3205-E. DIN uses the RTW PortaMonitor/Peakmeter norm profile (10 ms
|
||||||
|
//! integration, 20 dB return in 1.5 s). The optional DIN sample mode is kept
|
||||||
|
//! separate and must never be labelled as a standards-compliant DIN reading.
|
||||||
|
|
||||||
|
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||||
|
|
||||||
|
const ATTACK_FAST_TAU_S: f32 = 0.001_616_75;
|
||||||
|
const ATTACK_SLOW_TAU_S: f32 = 0.011_369_98;
|
||||||
|
const ATTACK_FAST_MIX: f32 = 0.818_535_6;
|
||||||
|
const STATIC_CALIBRATION_GAIN: f32 = 1.024_6;
|
||||||
|
const OVERSAMPLE: usize = 8;
|
||||||
|
const INTERP_RADIUS: usize = 8;
|
||||||
|
const INTERP_TAPS: usize = INTERP_RADIUS * 2 + 1;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum PpmStandard {
|
||||||
|
Din,
|
||||||
|
DinSample,
|
||||||
|
EbuTypeIib,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PpmStandard {
|
||||||
|
fn return_db_per_second(self) -> f32 {
|
||||||
|
match self {
|
||||||
|
Self::Din | Self::DinSample => 20.0 / 1.5,
|
||||||
|
Self::EbuTypeIib => 24.0 / 2.8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uses_sample_attack(self) -> bool {
|
||||||
|
matches!(self, Self::DinSample)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
|
struct PpmChannel {
|
||||||
|
fast: f32,
|
||||||
|
slow: f32,
|
||||||
|
output: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct PpmDetector {
|
||||||
|
sample_rate: u32,
|
||||||
|
standard: PpmStandard,
|
||||||
|
fast_coeff: f32,
|
||||||
|
slow_coeff: f32,
|
||||||
|
release_coeff: f32,
|
||||||
|
interp_weights: [[f32; INTERP_TAPS]; OVERSAMPLE],
|
||||||
|
raw_l: [f32; INTERP_TAPS],
|
||||||
|
raw_r: [f32; INTERP_TAPS],
|
||||||
|
raw_pos: usize,
|
||||||
|
left: PpmChannel,
|
||||||
|
right: PpmChannel,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PpmDetector {
|
||||||
|
pub fn new(sample_rate: u32, standard: PpmStandard) -> Self {
|
||||||
|
let sr = sample_rate.max(8_000);
|
||||||
|
let detector_rate = if standard.uses_sample_attack() {
|
||||||
|
sr as f32
|
||||||
|
} else {
|
||||||
|
(sr * OVERSAMPLE as u32) as f32
|
||||||
|
};
|
||||||
|
let coeff = |tau_s: f32| (-1.0 / (detector_rate * tau_s)).exp();
|
||||||
|
let release_coeff = 10.0f32.powf(-standard.return_db_per_second() / (20.0 * detector_rate));
|
||||||
|
Self {
|
||||||
|
sample_rate: sr,
|
||||||
|
standard,
|
||||||
|
fast_coeff: coeff(ATTACK_FAST_TAU_S),
|
||||||
|
slow_coeff: coeff(ATTACK_SLOW_TAU_S),
|
||||||
|
release_coeff,
|
||||||
|
interp_weights: Self::create_interp_weights(),
|
||||||
|
raw_l: [0.0; INTERP_TAPS],
|
||||||
|
raw_r: [0.0; INTERP_TAPS],
|
||||||
|
raw_pos: 0,
|
||||||
|
left: PpmChannel::default(),
|
||||||
|
right: PpmChannel::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_profile(&mut self, sample_rate: u32, standard: PpmStandard) {
|
||||||
|
let sr = sample_rate.max(8_000);
|
||||||
|
if self.sample_rate != sr || self.standard != standard {
|
||||||
|
*self = Self::new(sr, standard);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process(&mut self, left: f32, right: f32) -> (f32, f32) {
|
||||||
|
if self.standard.uses_sample_attack() {
|
||||||
|
let l = Self::process_channel(
|
||||||
|
&mut self.left,
|
||||||
|
left.abs(),
|
||||||
|
self.standard,
|
||||||
|
self.fast_coeff,
|
||||||
|
self.slow_coeff,
|
||||||
|
self.release_coeff,
|
||||||
|
);
|
||||||
|
let r = Self::process_channel(
|
||||||
|
&mut self.right,
|
||||||
|
right.abs(),
|
||||||
|
self.standard,
|
||||||
|
self.fast_coeff,
|
||||||
|
self.slow_coeff,
|
||||||
|
self.release_coeff,
|
||||||
|
);
|
||||||
|
return (l, r);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.raw_l[self.raw_pos] = left;
|
||||||
|
self.raw_r[self.raw_pos] = right;
|
||||||
|
self.raw_pos = (self.raw_pos + 1) % INTERP_TAPS;
|
||||||
|
|
||||||
|
let standard = self.standard;
|
||||||
|
let fast_coeff = self.fast_coeff;
|
||||||
|
let slow_coeff = self.slow_coeff;
|
||||||
|
let release_coeff = self.release_coeff;
|
||||||
|
for phase in 0..OVERSAMPLE {
|
||||||
|
let mut l = 0.0f32;
|
||||||
|
let mut r = 0.0f32;
|
||||||
|
for tap in 0..INTERP_TAPS {
|
||||||
|
let ring_index = (self.raw_pos + tap) % INTERP_TAPS;
|
||||||
|
let weight = self.interp_weights[phase][tap];
|
||||||
|
l += self.raw_l[ring_index] * weight;
|
||||||
|
r += self.raw_r[ring_index] * weight;
|
||||||
|
}
|
||||||
|
Self::process_channel(
|
||||||
|
&mut self.left,
|
||||||
|
l.abs(),
|
||||||
|
standard,
|
||||||
|
fast_coeff,
|
||||||
|
slow_coeff,
|
||||||
|
release_coeff,
|
||||||
|
);
|
||||||
|
Self::process_channel(
|
||||||
|
&mut self.right,
|
||||||
|
r.abs(),
|
||||||
|
standard,
|
||||||
|
fast_coeff,
|
||||||
|
slow_coeff,
|
||||||
|
release_coeff,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.levels()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn levels(&self) -> (f32, f32) {
|
||||||
|
(self.left.output, self.right.output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_interp_weights() -> [[f32; INTERP_TAPS]; OVERSAMPLE] {
|
||||||
|
let mut weights = [[0.0; INTERP_TAPS]; OVERSAMPLE];
|
||||||
|
for (phase, phase_weights) in weights.iter_mut().enumerate() {
|
||||||
|
let position = INTERP_RADIUS as f32 + phase as f32 / OVERSAMPLE as f32;
|
||||||
|
let mut sum = 0.0f32;
|
||||||
|
for (tap, weight) in phase_weights.iter_mut().enumerate() {
|
||||||
|
let distance = position - tap as f32;
|
||||||
|
*weight = Self::sinc(distance) * Self::sinc(distance / INTERP_RADIUS as f32);
|
||||||
|
sum += *weight;
|
||||||
|
}
|
||||||
|
if sum.abs() > 1.0e-9 {
|
||||||
|
for weight in phase_weights {
|
||||||
|
*weight /= sum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
weights
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sinc(value: f32) -> f32 {
|
||||||
|
if value.abs() < 1.0e-7 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
let x = std::f32::consts::PI * value;
|
||||||
|
x.sin() / x
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_channel(
|
||||||
|
state: &mut PpmChannel,
|
||||||
|
input: f32,
|
||||||
|
standard: PpmStandard,
|
||||||
|
fast_coeff: f32,
|
||||||
|
slow_coeff: f32,
|
||||||
|
release_coeff: f32,
|
||||||
|
) -> f32 {
|
||||||
|
if standard.uses_sample_attack() {
|
||||||
|
state.output = input.max(state.output * release_coeff);
|
||||||
|
state.fast = state.output;
|
||||||
|
state.slow = state.output;
|
||||||
|
return state.output;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.fast = if input > state.fast {
|
||||||
|
fast_coeff * state.fast + (1.0 - fast_coeff) * input
|
||||||
|
} else {
|
||||||
|
state.fast * release_coeff
|
||||||
|
};
|
||||||
|
state.slow = if input > state.slow {
|
||||||
|
slow_coeff * state.slow + (1.0 - slow_coeff) * input
|
||||||
|
} else {
|
||||||
|
state.slow * release_coeff
|
||||||
|
};
|
||||||
|
|
||||||
|
let attack = (ATTACK_FAST_MIX * state.fast + (1.0 - ATTACK_FAST_MIX) * state.slow)
|
||||||
|
* STATIC_CALIBRATION_GAIN;
|
||||||
|
state.output = attack.max(state.output * release_coeff);
|
||||||
|
state.output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const SR: u32 = 48_000;
|
||||||
|
|
||||||
|
fn sine_sample(index: usize, frequency: f32, amplitude: f32) -> f32 {
|
||||||
|
let phase = 2.0 * std::f32::consts::PI * frequency * index as f32 / SR as f32;
|
||||||
|
phase.sin() * amplitude
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_tone(
|
||||||
|
detector: &mut PpmDetector,
|
||||||
|
frequency: f32,
|
||||||
|
amplitude: f32,
|
||||||
|
duration_ms: f32,
|
||||||
|
) -> f32 {
|
||||||
|
let samples = (duration_ms * SR as f32 / 1000.0).round() as usize;
|
||||||
|
let mut peak = 0.0f32;
|
||||||
|
for index in 0..samples {
|
||||||
|
let value = sine_sample(index, frequency, amplitude);
|
||||||
|
peak = peak.max(detector.process(value, value).0);
|
||||||
|
}
|
||||||
|
// The band-limited interpolator uses eight samples of look-ahead.
|
||||||
|
// Silence advances its final interval without materially changing PPM return.
|
||||||
|
for _ in 0..INTERP_RADIUS + 1 {
|
||||||
|
peak = peak.max(detector.process(0.0, 0.0).0);
|
||||||
|
}
|
||||||
|
peak
|
||||||
|
}
|
||||||
|
|
||||||
|
fn db(value: f32) -> f32 {
|
||||||
|
20.0 * value.max(1.0e-12).log10()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ebu_type_iib_matches_official_tone_burst_table() {
|
||||||
|
// EBU Tech 3205-E table 2, expressed relative to the continuous-tone
|
||||||
|
// indication: 100/10/5/1.5/0.5 ms -> 0/-2/-4/-9/-17 dB.
|
||||||
|
let cases = [
|
||||||
|
(100.0, 0.0, 0.5),
|
||||||
|
(10.0, -2.0, 0.5),
|
||||||
|
(5.0, -4.0, 0.75),
|
||||||
|
(1.5, -9.0, 1.0),
|
||||||
|
(0.5, -17.0, 2.0),
|
||||||
|
];
|
||||||
|
let mut continuous = PpmDetector::new(SR, PpmStandard::EbuTypeIib);
|
||||||
|
let reference = run_tone(&mut continuous, 5_000.0, 0.5, 500.0);
|
||||||
|
|
||||||
|
for (duration_ms, expected_db, tolerance_db) in cases {
|
||||||
|
let mut detector = PpmDetector::new(SR, PpmStandard::EbuTypeIib);
|
||||||
|
let measured = run_tone(&mut detector, 5_000.0, 0.5, duration_ms);
|
||||||
|
let relative_db = db(measured / reference);
|
||||||
|
assert!(
|
||||||
|
(relative_db - expected_db).abs() <= tolerance_db,
|
||||||
|
"{duration_ms} ms: measured {relative_db:.3} dB, expected {expected_db:.3} +/- {tolerance_db:.3} dB"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ebu_return_time_is_24_db_in_2_8_seconds() {
|
||||||
|
assert_return_time(PpmStandard::EbuTypeIib, 24.0, 2.8, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn din_return_time_is_20_db_in_1_5_seconds() {
|
||||||
|
assert_return_time(PpmStandard::Din, 20.0, 1.5, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fresh_detector_does_not_bypass_attack_integration() {
|
||||||
|
let mut detector = PpmDetector::new(SR, PpmStandard::Din);
|
||||||
|
let first = detector.process(1.0, 1.0).0;
|
||||||
|
assert!(first < 0.02, "first sample unexpectedly reached {first}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn polarity_is_reversible() {
|
||||||
|
let mut positive = PpmDetector::new(SR, PpmStandard::EbuTypeIib);
|
||||||
|
let mut negative = PpmDetector::new(SR, PpmStandard::EbuTypeIib);
|
||||||
|
for index in 0..SR as usize {
|
||||||
|
let value = sine_sample(index, 1_000.0, 0.5);
|
||||||
|
positive.process(value, value);
|
||||||
|
negative.process(-value, -value);
|
||||||
|
}
|
||||||
|
assert!((positive.levels().0 - negative.levels().0).abs() < 1.0e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ebu_frequency_response_is_flat_through_16_khz() {
|
||||||
|
let frequencies = [31.5, 1_000.0, 5_000.0, 10_000.0, 12_500.0, 16_000.0];
|
||||||
|
let mut reference_detector = PpmDetector::new(SR, PpmStandard::EbuTypeIib);
|
||||||
|
let reference = run_tone(&mut reference_detector, 1_000.0, 0.5, 1_000.0);
|
||||||
|
for frequency in frequencies {
|
||||||
|
let mut detector = PpmDetector::new(SR, PpmStandard::EbuTypeIib);
|
||||||
|
let measured = run_tone(&mut detector, frequency, 0.5, 1_000.0);
|
||||||
|
let relative_db = db(measured / reference);
|
||||||
|
assert!(
|
||||||
|
relative_db.abs() <= 0.3,
|
||||||
|
"{frequency} Hz: measured {relative_db:.3} dB relative to 1 kHz"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_return_time(
|
||||||
|
standard: PpmStandard,
|
||||||
|
drop_db: f32,
|
||||||
|
expected_seconds: f32,
|
||||||
|
tolerance_seconds: f32,
|
||||||
|
) {
|
||||||
|
let mut detector = PpmDetector::new(SR, standard);
|
||||||
|
run_tone(&mut detector, 1_000.0, 0.5, 1_000.0);
|
||||||
|
let start = detector.levels().0;
|
||||||
|
let target = start * 10.0f32.powf(-drop_db / 20.0);
|
||||||
|
let mut elapsed = 0usize;
|
||||||
|
while detector.levels().0 > target && elapsed < SR as usize * 10 {
|
||||||
|
detector.process(0.0, 0.0);
|
||||||
|
elapsed += 1;
|
||||||
|
}
|
||||||
|
let seconds = elapsed as f32 / SR as f32;
|
||||||
|
assert!(
|
||||||
|
(seconds - expected_seconds).abs() <= tolerance_seconds,
|
||||||
|
"return took {seconds:.4} s, expected {expected_seconds:.4} +/- {tolerance_seconds:.4} s"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+491
-56
@@ -1,3 +1,4 @@
|
|||||||
|
use anyhow::Context;
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::Body,
|
||||||
extract::{Path, Query, Request, State, WebSocketUpgrade},
|
extract::{Path, Query, Request, State, WebSocketUpgrade},
|
||||||
@@ -5,19 +6,19 @@ use axum::{
|
|||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use anyhow::Context;
|
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::{
|
use std::{
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
process::Stdio,
|
process::Stdio,
|
||||||
|
sync::Arc,
|
||||||
time::{SystemTime, UNIX_EPOCH},
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
use tokio::{fs, io::AsyncWriteExt, process::Command};
|
use tokio::{fs, io::AsyncWriteExt, process::Command};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
model::{PhoenixGlobalConfig, PhoenixRtaConfig},
|
model::{MeterFrame, PhoenixGlobalConfig, PhoenixRtaConfig, WaveEnvFrame},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,7 +57,9 @@ pub async fn get_rta_config(State(state): State<AppState>) -> Json<PhoenixRtaCon
|
|||||||
Json(state.rta_config().await)
|
Json(state.rta_config().await)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_global_config(State(state): State<AppState>) -> Json<crate::model::PhoenixGlobalConfigEnvelope> {
|
pub async fn get_global_config(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Json<crate::model::PhoenixGlobalConfigEnvelope> {
|
||||||
Json(state.global_config_envelope().await)
|
Json(state.global_config_envelope().await)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +212,9 @@ pub async fn download_online_update() -> Response {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let log_path = build_online_update_log_path(&phoenix_root);
|
let log_path = build_online_update_log_path(&phoenix_root);
|
||||||
let script = phoenix_root.join("scripts").join("update_phoenix_from_zip.sh");
|
let script = phoenix_root
|
||||||
|
.join("scripts")
|
||||||
|
.join("update_phoenix_from_zip.sh");
|
||||||
if !script.is_file() {
|
if !script.is_file() {
|
||||||
return (
|
return (
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
@@ -303,7 +308,9 @@ pub async fn restart_after_online_update() -> Response {
|
|||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let script = phoenix_root.join("scripts").join("restart_phoenix_services.sh");
|
let script = phoenix_root
|
||||||
|
.join("scripts")
|
||||||
|
.join("restart_phoenix_services.sh");
|
||||||
if !script.is_file() {
|
if !script.is_file() {
|
||||||
return (
|
return (
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
@@ -317,9 +324,7 @@ pub async fn restart_after_online_update() -> Response {
|
|||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(750)).await;
|
tokio::time::sleep(tokio::time::Duration::from_millis(750)).await;
|
||||||
let _ = stable_script_command("bash")
|
let _ = stable_script_command("bash").arg(script).spawn();
|
||||||
.arg(script)
|
|
||||||
.spawn();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
(
|
(
|
||||||
@@ -419,6 +424,14 @@ pub async fn metrics_ws(ws: WebSocketUpgrade, State(state): State<AppState>) ->
|
|||||||
ws.on_upgrade(move |socket| metrics_ws_inner(socket, state))
|
ws.on_upgrade(move |socket| metrics_ws_inner(socket, state))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn spectro_ws(ws: WebSocketUpgrade, State(state): State<AppState>) -> Response {
|
||||||
|
ws.on_upgrade(move |socket| spectro_ws_inner(socket, state))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn visuals_ws(ws: WebSocketUpgrade, State(state): State<AppState>) -> Response {
|
||||||
|
ws.on_upgrade(move |socket| visuals_ws_inner(socket, state))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn start_wav_recording(
|
pub async fn start_wav_recording(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(session_id): Path<u64>,
|
Path(session_id): Path<u64>,
|
||||||
@@ -458,7 +471,12 @@ pub async fn stop_recording_with_format(
|
|||||||
stop_recording_inner(state, session_id, &format, query.bitrate_kbps).await
|
stop_recording_inner(state, session_id, &format, query.bitrate_kbps).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn stop_recording_inner(state: AppState, session_id: u64, format: &str, bitrate_kbps: Option<u32>) -> Response {
|
async fn stop_recording_inner(
|
||||||
|
state: AppState,
|
||||||
|
session_id: u64,
|
||||||
|
format: &str,
|
||||||
|
bitrate_kbps: Option<u32>,
|
||||||
|
) -> Response {
|
||||||
let normalized_format = normalize_recording_format(format);
|
let normalized_format = normalize_recording_format(format);
|
||||||
if normalized_format.is_empty() {
|
if normalized_format.is_empty() {
|
||||||
return (
|
return (
|
||||||
@@ -474,10 +492,7 @@ async fn stop_recording_inner(state: AppState, session_id: u64, format: &str, bi
|
|||||||
Ok(capture) => match build_capture_bytes(normalized_format, &capture, bitrate_kbps).await {
|
Ok(capture) => match build_capture_bytes(normalized_format, &capture, bitrate_kbps).await {
|
||||||
Ok((mime_type, payload)) => (
|
Ok((mime_type, payload)) => (
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
[
|
[("content-type", mime_type), ("cache-control", "no-store")],
|
||||||
("content-type", mime_type),
|
|
||||||
("cache-control", "no-store"),
|
|
||||||
],
|
|
||||||
Body::from(payload),
|
Body::from(payload),
|
||||||
)
|
)
|
||||||
.into_response(),
|
.into_response(),
|
||||||
@@ -514,7 +529,8 @@ async fn build_capture_bytes(
|
|||||||
capture.channels,
|
capture.channels,
|
||||||
&capture.pcm_bytes,
|
&capture.pcm_bytes,
|
||||||
normalize_mp3_bitrate_kbps(bitrate_kbps),
|
normalize_mp3_bitrate_kbps(bitrate_kbps),
|
||||||
).await?,
|
)
|
||||||
|
.await?,
|
||||||
)),
|
)),
|
||||||
"webm" => Ok((
|
"webm" => Ok((
|
||||||
"audio/webm",
|
"audio/webm",
|
||||||
@@ -527,7 +543,12 @@ async fn build_capture_bytes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn encode_mp3_bytes(sample_rate: u32, channels: u16, pcm_bytes: &[u8], bitrate_kbps: u32) -> anyhow::Result<Vec<u8>> {
|
async fn encode_mp3_bytes(
|
||||||
|
sample_rate: u32,
|
||||||
|
channels: u16,
|
||||||
|
pcm_bytes: &[u8],
|
||||||
|
bitrate_kbps: u32,
|
||||||
|
) -> anyhow::Result<Vec<u8>> {
|
||||||
let wav = build_wav_bytes(sample_rate, channels, pcm_bytes);
|
let wav = build_wav_bytes(sample_rate, channels, pcm_bytes);
|
||||||
let token = format!(
|
let token = format!(
|
||||||
"{}-{}-{}",
|
"{}-{}-{}",
|
||||||
@@ -557,7 +578,11 @@ async fn encode_mp3_bytes(sample_rate: u32, channels: u16, pcm_bytes: &[u8], bit
|
|||||||
Ok(payload)
|
Ok(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn try_encode_mp3_with_commands(input_path: &PathBuf, output_path: &PathBuf, bitrate_kbps: u32) -> anyhow::Result<()> {
|
async fn try_encode_mp3_with_commands(
|
||||||
|
input_path: &PathBuf,
|
||||||
|
output_path: &PathBuf,
|
||||||
|
bitrate_kbps: u32,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
let ffmpeg = Command::new("ffmpeg")
|
let ffmpeg = Command::new("ffmpeg")
|
||||||
.arg("-y")
|
.arg("-y")
|
||||||
.arg("-hide_banner")
|
.arg("-hide_banner")
|
||||||
@@ -602,7 +627,11 @@ async fn try_encode_mp3_with_commands(input_path: &PathBuf, output_path: &PathBu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn encode_webm_bytes(sample_rate: u32, channels: u16, pcm_bytes: &[u8]) -> anyhow::Result<Vec<u8>> {
|
async fn encode_webm_bytes(
|
||||||
|
sample_rate: u32,
|
||||||
|
channels: u16,
|
||||||
|
pcm_bytes: &[u8],
|
||||||
|
) -> anyhow::Result<Vec<u8>> {
|
||||||
let wav = build_wav_bytes(sample_rate, channels, pcm_bytes);
|
let wav = build_wav_bytes(sample_rate, channels, pcm_bytes);
|
||||||
let token = format!(
|
let token = format!(
|
||||||
"{}-{}-{}",
|
"{}-{}-{}",
|
||||||
@@ -735,27 +764,53 @@ pub async fn save_recording_file(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn read_frontend_presets(config: &crate::config::PhoenixConfig) -> anyhow::Result<serde_json::Map<String, serde_json::Value>> {
|
async fn read_frontend_presets(
|
||||||
|
config: &crate::config::PhoenixConfig,
|
||||||
|
) -> anyhow::Result<serde_json::Map<String, serde_json::Value>> {
|
||||||
let path = config.frontend_presets_path.clone();
|
let path = config.frontend_presets_path.clone();
|
||||||
let raw = match fs::read_to_string(&path).await {
|
let raw = match fs::read_to_string(&path).await {
|
||||||
Ok(raw) => raw,
|
Ok(raw) => raw,
|
||||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(serde_json::Map::new()),
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(serde_json::Map::new()),
|
||||||
Err(err) => return Err(anyhow::anyhow!("failed to read frontend presets {} ({})", path.display(), err)),
|
Err(err) => {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"failed to read frontend presets {} ({})",
|
||||||
|
path.display(),
|
||||||
|
err
|
||||||
|
))
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let parsed = serde_json::from_str::<serde_json::Value>(&raw)
|
let parsed = serde_json::from_str::<serde_json::Value>(&raw).map_err(|err| {
|
||||||
.map_err(|err| anyhow::anyhow!("failed to parse frontend presets {} ({})", path.display(), err))?;
|
anyhow::anyhow!(
|
||||||
|
"failed to parse frontend presets {} ({})",
|
||||||
|
path.display(),
|
||||||
|
err
|
||||||
|
)
|
||||||
|
})?;
|
||||||
Ok(parsed.as_object().cloned().unwrap_or_default())
|
Ok(parsed.as_object().cloned().unwrap_or_default())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn read_frontend_layouts(config: &crate::config::PhoenixConfig) -> anyhow::Result<serde_json::Map<String, serde_json::Value>> {
|
async fn read_frontend_layouts(
|
||||||
|
config: &crate::config::PhoenixConfig,
|
||||||
|
) -> anyhow::Result<serde_json::Map<String, serde_json::Value>> {
|
||||||
let path = config.frontend_layouts_path.clone();
|
let path = config.frontend_layouts_path.clone();
|
||||||
let raw = match fs::read_to_string(&path).await {
|
let raw = match fs::read_to_string(&path).await {
|
||||||
Ok(raw) => raw,
|
Ok(raw) => raw,
|
||||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(serde_json::Map::new()),
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(serde_json::Map::new()),
|
||||||
Err(err) => return Err(anyhow::anyhow!("failed to read frontend layouts {} ({})", path.display(), err)),
|
Err(err) => {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"failed to read frontend layouts {} ({})",
|
||||||
|
path.display(),
|
||||||
|
err
|
||||||
|
))
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let parsed = serde_json::from_str::<serde_json::Value>(&raw)
|
let parsed = serde_json::from_str::<serde_json::Value>(&raw).map_err(|err| {
|
||||||
.map_err(|err| anyhow::anyhow!("failed to parse frontend layouts {} ({})", path.display(), err))?;
|
anyhow::anyhow!(
|
||||||
|
"failed to parse frontend layouts {} ({})",
|
||||||
|
path.display(),
|
||||||
|
err
|
||||||
|
)
|
||||||
|
})?;
|
||||||
Ok(parsed.as_object().cloned().unwrap_or_default())
|
Ok(parsed.as_object().cloned().unwrap_or_default())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -765,9 +820,9 @@ async fn persist_frontend_presets(
|
|||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let path = config.frontend_presets_path.clone();
|
let path = config.frontend_presets_path.clone();
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent)
|
fs::create_dir_all(parent).await.with_context(|| {
|
||||||
.await
|
format!("failed to create frontend preset dir {}", parent.display())
|
||||||
.with_context(|| format!("failed to create frontend preset dir {}", parent.display()))?;
|
})?;
|
||||||
}
|
}
|
||||||
let json = serde_json::to_vec_pretty(&serde_json::Value::Object(presets.clone()))?;
|
let json = serde_json::to_vec_pretty(&serde_json::Value::Object(presets.clone()))?;
|
||||||
fs::write(&path, json)
|
fs::write(&path, json)
|
||||||
@@ -782,9 +837,9 @@ async fn persist_frontend_layouts(
|
|||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let path = config.frontend_layouts_path.clone();
|
let path = config.frontend_layouts_path.clone();
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent)
|
fs::create_dir_all(parent).await.with_context(|| {
|
||||||
.await
|
format!("failed to create frontend layout dir {}", parent.display())
|
||||||
.with_context(|| format!("failed to create frontend layout dir {}", parent.display()))?;
|
})?;
|
||||||
}
|
}
|
||||||
let json = serde_json::to_vec_pretty(&serde_json::Value::Object(layouts.clone()))?;
|
let json = serde_json::to_vec_pretty(&serde_json::Value::Object(layouts.clone()))?;
|
||||||
fs::write(&path, json)
|
fs::write(&path, json)
|
||||||
@@ -835,37 +890,269 @@ fn normalize_frontend_layout_id(raw: &str) -> String {
|
|||||||
|
|
||||||
async fn metrics_ws_inner(mut socket: axum::extract::ws::WebSocket, state: AppState) {
|
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 {
|
||||||
match rx.recv().await {
|
ticker.tick().await;
|
||||||
Ok(frame) => {
|
let Some(mut latest) = recv_latest_meter_frame(&mut rx).await else {
|
||||||
let payload = match serde_json::to_string(&frame) {
|
break;
|
||||||
Ok(payload) => payload,
|
};
|
||||||
Err(err) => {
|
drain_latest_meter_frame(&mut rx, &mut latest);
|
||||||
warn!("failed to encode metrics frame: {}", err);
|
let mut frame = (*latest).clone();
|
||||||
continue;
|
strip_visual_payloads(&mut frame);
|
||||||
}
|
|
||||||
};
|
let payload = match serde_json::to_string(&frame) {
|
||||||
if socket
|
Ok(payload) => payload,
|
||||||
.send(axum::extract::ws::Message::Text(payload))
|
|
||||||
.await
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
match err {
|
warn!("failed to encode metrics frame: {}", err);
|
||||||
tokio::sync::broadcast::error::RecvError::Lagged(skipped) => {
|
continue;
|
||||||
warn!("metrics channel lagged by {}; dropping stale frames", skipped);
|
}
|
||||||
continue;
|
};
|
||||||
}
|
if socket
|
||||||
tokio::sync::broadcast::error::RecvError::Closed => {
|
.send(axum::extract::ws::Message::Text(payload))
|
||||||
warn!("metrics channel closed");
|
.await
|
||||||
break;
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_visual_payloads(frame: &mut MeterFrame) {
|
||||||
|
// Large visual payloads have bounded binary streams of their own.
|
||||||
|
frame.spectro = None;
|
||||||
|
frame.wave_l.clear();
|
||||||
|
frame.wave_r.clear();
|
||||||
|
frame.wave_channels = 0;
|
||||||
|
frame.xy_l.clear();
|
||||||
|
frame.xy_r.clear();
|
||||||
|
frame.wave_env = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn visuals_ws_inner(mut socket: axum::extract::ws::WebSocket, state: AppState) {
|
||||||
|
let mut rx = state.subscribe_metrics();
|
||||||
|
let mut ticker = tokio::time::interval(std::time::Duration::from_millis(16));
|
||||||
|
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
|
loop {
|
||||||
|
ticker.tick().await;
|
||||||
|
let Some(mut latest) = recv_latest_meter_frame(&mut rx).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (wave_env, xy) = drain_visual_meter_frames(&mut rx, &mut latest);
|
||||||
|
let mut frame = (*latest).clone();
|
||||||
|
frame.wave_env = wave_env;
|
||||||
|
if let Some((left, right)) = xy {
|
||||||
|
frame.xy_l = left;
|
||||||
|
frame.xy_r = right;
|
||||||
|
}
|
||||||
|
let Some(payload) = encode_visual_frame(&frame) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if socket
|
||||||
|
.send(axum::extract::ws::Message::Binary(payload))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spectro_ws_inner(mut socket: axum::extract::ws::WebSocket, state: AppState) {
|
||||||
|
let mut rx = state.subscribe_metrics();
|
||||||
|
loop {
|
||||||
|
let mut latest = loop {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(frame) => {
|
||||||
|
if frame.spectro.is_some() {
|
||||||
|
break frame;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match rx.try_recv() {
|
||||||
|
Ok(frame) => {
|
||||||
|
if frame.spectro.is_some() {
|
||||||
|
latest = frame;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue,
|
||||||
|
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => break,
|
||||||
|
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => return,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let payload = encode_spectro_frame(latest.spectro.as_ref().expect("checked above"));
|
||||||
|
if socket
|
||||||
|
.send(axum::extract::ws::Message::Binary(payload))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_spectro_frame(frame: &crate::model::SpectroFrame) -> Vec<u8> {
|
||||||
|
const MAGIC: u32 = 0x5058_5350; // "PXSP"
|
||||||
|
let count = frame.bins.len().min(u32::MAX as usize) as u32;
|
||||||
|
let mut output = Vec::with_capacity(20 + count as usize * 4);
|
||||||
|
output.extend_from_slice(&MAGIC.to_le_bytes());
|
||||||
|
output.extend_from_slice(&(frame.seq as u32).to_le_bytes());
|
||||||
|
output.extend_from_slice(&frame.sample_rate.to_le_bytes());
|
||||||
|
output.extend_from_slice(&frame.fft_size.to_le_bytes());
|
||||||
|
output.extend_from_slice(&count.to_le_bytes());
|
||||||
|
for value in frame.bins.iter().take(count as usize) {
|
||||||
|
output.extend_from_slice(&value.to_le_bytes());
|
||||||
|
}
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_visual_frame(frame: &MeterFrame) -> Option<Vec<u8>> {
|
||||||
|
const MAGIC: u32 = 0x5058_5653; // Phoenix visual stream
|
||||||
|
const FLAG_XY: u16 = 1;
|
||||||
|
const FLAG_WAVE_ENV: u16 = 2;
|
||||||
|
|
||||||
|
let xy_count = frame
|
||||||
|
.xy_l
|
||||||
|
.len()
|
||||||
|
.min(frame.xy_r.len())
|
||||||
|
.min(u32::MAX as usize);
|
||||||
|
let wave = frame.wave_env.as_ref().filter(|value| {
|
||||||
|
matches!(value.channels, 1 | 2)
|
||||||
|
&& value.columns > 0
|
||||||
|
&& value.data.len()
|
||||||
|
== value
|
||||||
|
.columns
|
||||||
|
.saturating_mul(value.channels as usize)
|
||||||
|
.saturating_mul(2)
|
||||||
|
});
|
||||||
|
let wave_values = wave.map(|value| value.data.len()).unwrap_or(0);
|
||||||
|
let mut flags = 0u16;
|
||||||
|
if xy_count > 0 {
|
||||||
|
flags |= FLAG_XY;
|
||||||
|
}
|
||||||
|
if wave_values > 0 {
|
||||||
|
flags |= FLAG_WAVE_ENV;
|
||||||
|
}
|
||||||
|
if flags == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let wave_columns = wave
|
||||||
|
.map(|value| value.columns)
|
||||||
|
.unwrap_or(0)
|
||||||
|
.min(u32::MAX as usize) as u32;
|
||||||
|
let wave_channels = wave.map(|value| value.channels).unwrap_or(0) as u16;
|
||||||
|
let wave_column_samples = wave
|
||||||
|
.map(|value| value.column_samples)
|
||||||
|
.unwrap_or(0)
|
||||||
|
.min(u32::MAX as usize) as u32;
|
||||||
|
let wave_sample_rate = wave.map(|value| value.sample_rate).unwrap_or(0);
|
||||||
|
let mut output = Vec::with_capacity(32 + xy_count * 8 + wave_values * 4);
|
||||||
|
output.extend_from_slice(&MAGIC.to_le_bytes());
|
||||||
|
output.extend_from_slice(&1u16.to_le_bytes());
|
||||||
|
output.extend_from_slice(&flags.to_le_bytes());
|
||||||
|
output.extend_from_slice(&(frame.seq as u32).to_le_bytes());
|
||||||
|
output.extend_from_slice(&(xy_count as u32).to_le_bytes());
|
||||||
|
output.extend_from_slice(&wave_columns.to_le_bytes());
|
||||||
|
output.extend_from_slice(&wave_channels.to_le_bytes());
|
||||||
|
output.extend_from_slice(&0u16.to_le_bytes());
|
||||||
|
output.extend_from_slice(&wave_column_samples.to_le_bytes());
|
||||||
|
output.extend_from_slice(&wave_sample_rate.to_le_bytes());
|
||||||
|
for index in 0..xy_count {
|
||||||
|
output.extend_from_slice(&frame.xy_l[index].to_le_bytes());
|
||||||
|
output.extend_from_slice(&frame.xy_r[index].to_le_bytes());
|
||||||
|
}
|
||||||
|
if let Some(wave) = wave {
|
||||||
|
for value in &wave.data {
|
||||||
|
output.extend_from_slice(&value.to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn recv_latest_meter_frame(
|
||||||
|
rx: &mut tokio::sync::broadcast::Receiver<Arc<MeterFrame>>,
|
||||||
|
) -> Option<Arc<MeterFrame>> {
|
||||||
|
loop {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(frame) => return Some(frame),
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
|
||||||
|
warn!("metrics channel skipped {} stale frames", skipped);
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain_latest_meter_frame(
|
||||||
|
rx: &mut tokio::sync::broadcast::Receiver<Arc<MeterFrame>>,
|
||||||
|
latest: &mut Arc<MeterFrame>,
|
||||||
|
) {
|
||||||
|
loop {
|
||||||
|
match rx.try_recv() {
|
||||||
|
Ok(newer) => *latest = newer,
|
||||||
|
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => break,
|
||||||
|
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(skipped)) => {
|
||||||
|
warn!("metrics drain skipped {} stale frames", skipped);
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain_visual_meter_frames(
|
||||||
|
rx: &mut tokio::sync::broadcast::Receiver<Arc<MeterFrame>>,
|
||||||
|
latest: &mut Arc<MeterFrame>,
|
||||||
|
) -> (Option<WaveEnvFrame>, Option<(Vec<f32>, Vec<f32>)>) {
|
||||||
|
let mut combined_wave_env = latest.wave_env.clone();
|
||||||
|
let mut latest_xy = if latest.xy_l.is_empty() || latest.xy_r.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some((latest.xy_l.clone(), latest.xy_r.clone()))
|
||||||
|
};
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match rx.try_recv() {
|
||||||
|
Ok(newer) => {
|
||||||
|
merge_wave_env(&mut combined_wave_env, newer.wave_env.clone());
|
||||||
|
if !newer.xy_l.is_empty() && !newer.xy_r.is_empty() {
|
||||||
|
latest_xy = Some((newer.xy_l.clone(), newer.xy_r.clone()));
|
||||||
|
}
|
||||||
|
*latest = newer;
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => break,
|
||||||
|
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(skipped)) => {
|
||||||
|
warn!("metrics drain skipped {} stale frames", skipped);
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(combined_wave_env, latest_xy)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_wave_env(target: &mut Option<WaveEnvFrame>, incoming: Option<WaveEnvFrame>) {
|
||||||
|
let Some(mut incoming) = incoming else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(current) = target.as_mut() else {
|
||||||
|
*target = Some(incoming);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let compatible = current.channels == incoming.channels
|
||||||
|
&& current.column_samples == incoming.column_samples
|
||||||
|
&& current.sample_rate == incoming.sample_rate;
|
||||||
|
if compatible {
|
||||||
|
current.columns = current.columns.saturating_add(incoming.columns);
|
||||||
|
current.data.append(&mut incoming.data);
|
||||||
|
} else {
|
||||||
|
*current = incoming;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -982,3 +1269,151 @@ async fn write_recording_body(
|
|||||||
tokio::fs::rename(&tmp_path, &path).await?;
|
tokio::fs::rename(&tmp_path, &path).await?;
|
||||||
Ok(path.display().to_string())
|
Ok(path.display().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn meter_frame() -> MeterFrame {
|
||||||
|
MeterFrame {
|
||||||
|
seq: 7,
|
||||||
|
timestamp_ms: 123,
|
||||||
|
sample_rate: 48_000,
|
||||||
|
period_size: 128,
|
||||||
|
correlation: 0.25,
|
||||||
|
correlation_negative_peak: -0.5,
|
||||||
|
rms_l: -20.0,
|
||||||
|
rms_r: -21.0,
|
||||||
|
vu_l: -20.0,
|
||||||
|
vu_r: -21.0,
|
||||||
|
tp_l: -18.0,
|
||||||
|
tp_r: -19.0,
|
||||||
|
ppm_din_l: -18.5,
|
||||||
|
ppm_din_r: -19.5,
|
||||||
|
ppm_ebu_l: -18.5,
|
||||||
|
ppm_ebu_r: -19.5,
|
||||||
|
lufs_m: None,
|
||||||
|
lufs_s: None,
|
||||||
|
lufs_i: None,
|
||||||
|
lra: None,
|
||||||
|
lufs_ml: None,
|
||||||
|
lufs_mr: None,
|
||||||
|
lufs_sl: None,
|
||||||
|
lufs_sr: None,
|
||||||
|
ppm_box_l: None,
|
||||||
|
ppm_box_r: None,
|
||||||
|
wave_l: vec![0.1, 0.2],
|
||||||
|
wave_r: vec![0.3, 0.4],
|
||||||
|
wave_channels: 2,
|
||||||
|
xy_l: vec![-0.5, 0.5],
|
||||||
|
xy_r: vec![0.25, -0.25],
|
||||||
|
rta: None,
|
||||||
|
spectro: None,
|
||||||
|
wave_env: Some(WaveEnvFrame {
|
||||||
|
data: vec![-0.5, 0.5, -0.25, 0.25],
|
||||||
|
columns: 2,
|
||||||
|
channels: 1,
|
||||||
|
column_samples: 5,
|
||||||
|
sample_rate: 48_000,
|
||||||
|
}),
|
||||||
|
global_config_rev: 0,
|
||||||
|
input: crate::model::InputSource::Line,
|
||||||
|
source: "test",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spectro_binary_frame_has_stable_header_and_samples() {
|
||||||
|
let frame = crate::model::SpectroFrame {
|
||||||
|
seq: 42,
|
||||||
|
bins: vec![-80.0, -12.5, 0.0],
|
||||||
|
sample_rate: 48_000,
|
||||||
|
fft_size: 8_192,
|
||||||
|
};
|
||||||
|
let encoded = encode_spectro_frame(&frame);
|
||||||
|
assert_eq!(encoded.len(), 20 + frame.bins.len() * 4);
|
||||||
|
assert_eq!(
|
||||||
|
u32::from_le_bytes(encoded[0..4].try_into().unwrap()),
|
||||||
|
0x5058_5350
|
||||||
|
);
|
||||||
|
assert_eq!(u32::from_le_bytes(encoded[4..8].try_into().unwrap()), 42);
|
||||||
|
assert_eq!(
|
||||||
|
u32::from_le_bytes(encoded[8..12].try_into().unwrap()),
|
||||||
|
48_000
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
u32::from_le_bytes(encoded[12..16].try_into().unwrap()),
|
||||||
|
8_192
|
||||||
|
);
|
||||||
|
assert_eq!(u32::from_le_bytes(encoded[16..20].try_into().unwrap()), 3);
|
||||||
|
for (index, expected) in frame.bins.iter().enumerate() {
|
||||||
|
let offset = 20 + index * 4;
|
||||||
|
let actual = f32::from_le_bytes(encoded[offset..offset + 4].try_into().unwrap());
|
||||||
|
assert_eq!(actual, *expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wave_envelopes_merge_without_losing_columns() {
|
||||||
|
let mut target = Some(WaveEnvFrame {
|
||||||
|
data: vec![-0.5, 0.5],
|
||||||
|
columns: 1,
|
||||||
|
channels: 1,
|
||||||
|
column_samples: 5,
|
||||||
|
sample_rate: 48_000,
|
||||||
|
});
|
||||||
|
merge_wave_env(
|
||||||
|
&mut target,
|
||||||
|
Some(WaveEnvFrame {
|
||||||
|
data: vec![-0.25, 0.25, -0.1, 0.1],
|
||||||
|
columns: 2,
|
||||||
|
channels: 1,
|
||||||
|
column_samples: 5,
|
||||||
|
sample_rate: 48_000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let merged = target.unwrap();
|
||||||
|
assert_eq!(merged.columns, 3);
|
||||||
|
assert_eq!(merged.data, vec![-0.5, 0.5, -0.25, 0.25, -0.1, 0.1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn visual_binary_contains_xy_and_wave_envelope() {
|
||||||
|
let frame = meter_frame();
|
||||||
|
let encoded = encode_visual_frame(&frame).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
u32::from_le_bytes(encoded[0..4].try_into().unwrap()),
|
||||||
|
0x5058_5653
|
||||||
|
);
|
||||||
|
assert_eq!(u16::from_le_bytes(encoded[4..6].try_into().unwrap()), 1);
|
||||||
|
assert_eq!(u16::from_le_bytes(encoded[6..8].try_into().unwrap()), 3);
|
||||||
|
assert_eq!(u32::from_le_bytes(encoded[8..12].try_into().unwrap()), 7);
|
||||||
|
assert_eq!(u32::from_le_bytes(encoded[12..16].try_into().unwrap()), 2);
|
||||||
|
assert_eq!(u32::from_le_bytes(encoded[16..20].try_into().unwrap()), 2);
|
||||||
|
assert_eq!(u16::from_le_bytes(encoded[20..22].try_into().unwrap()), 1);
|
||||||
|
assert_eq!(u32::from_le_bytes(encoded[24..28].try_into().unwrap()), 5);
|
||||||
|
assert_eq!(
|
||||||
|
u32::from_le_bytes(encoded[28..32].try_into().unwrap()),
|
||||||
|
48_000
|
||||||
|
);
|
||||||
|
assert_eq!(encoded.len(), 32 + 2 * 8 + 4 * 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_metrics_do_not_repeat_large_visual_payloads() {
|
||||||
|
let mut frame = meter_frame();
|
||||||
|
strip_visual_payloads(&mut frame);
|
||||||
|
let json = serde_json::to_string(&frame).unwrap();
|
||||||
|
assert!(!json.contains("wave_l"));
|
||||||
|
assert!(!json.contains("wave_r"));
|
||||||
|
assert!(!json.contains("xy_l"));
|
||||||
|
assert!(!json.contains("xy_r"));
|
||||||
|
assert!(!json.contains("wave_env"));
|
||||||
|
assert!(!json.contains("spectro"));
|
||||||
|
assert!(
|
||||||
|
json.len() < 1_000,
|
||||||
|
"scalar metrics JSON grew to {} bytes",
|
||||||
|
json.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+551
@@ -0,0 +1,551 @@
|
|||||||
|
//! Realtime-analyzer filter primitives.
|
||||||
|
//!
|
||||||
|
//! Fractional-octave bands are designed as complete Butterworth bandpasses:
|
||||||
|
//! an analog low-pass prototype is transformed to a bandpass, pre-warped and
|
||||||
|
//! mapped with the bilinear transform, then emitted as distinct SOS sections.
|
||||||
|
|
||||||
|
pub const RTW_THIRD_OCTAVE_CENTERS: &[f32] = &[
|
||||||
|
20.0, 25.0, 31.5, 40.0, 50.0, 63.0, 80.0, 100.0, 125.0, 160.0, 200.0, 250.0, 315.0, 400.0,
|
||||||
|
500.0, 630.0, 800.0, 1_000.0, 1_250.0, 1_600.0, 2_000.0, 2_500.0, 3_150.0, 4_000.0, 5_000.0,
|
||||||
|
6_300.0, 8_000.0, 10_000.0, 12_500.0, 16_000.0, 20_000.0,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Maps a rounded preferred label to its exact IEC base-ten center frequency.
|
||||||
|
pub fn exact_fractional_octave_center(nominal_hz: f32, bands_per_octave: usize) -> f32 {
|
||||||
|
let bpo = bands_per_octave.max(1) as f32;
|
||||||
|
let step = (bpo * (nominal_hz / 1_000.0).log10() / 0.3).round();
|
||||||
|
1_000.0 * 10.0f32.powf(0.3 * step / bpo)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fractional_octave_edges(center_hz: f32, bands_per_octave: usize) -> (f32, f32) {
|
||||||
|
let factor = 10.0f32.powf(3.0 / (20.0 * bands_per_octave.max(1) as f32));
|
||||||
|
(center_hz / factor, center_hz * factor)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct BiquadCoeffs {
|
||||||
|
pub b0: f64,
|
||||||
|
pub b1: f64,
|
||||||
|
pub b2: f64,
|
||||||
|
pub a1: f64,
|
||||||
|
pub a2: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
struct Complex {
|
||||||
|
re: f64,
|
||||||
|
im: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Complex {
|
||||||
|
fn new(re: f64, im: f64) -> Self {
|
||||||
|
Self { re, im }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add(self, other: Self) -> Self {
|
||||||
|
Self::new(self.re + other.re, self.im + other.im)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sub(self, other: Self) -> Self {
|
||||||
|
Self::new(self.re - other.re, self.im - other.im)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mul(self, other: Self) -> Self {
|
||||||
|
Self::new(
|
||||||
|
self.re * other.re - self.im * other.im,
|
||||||
|
self.re * other.im + self.im * other.re,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scale(self, value: f64) -> Self {
|
||||||
|
Self::new(self.re * value, self.im * value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn div(self, other: Self) -> Self {
|
||||||
|
let denom = other.re * other.re + other.im * other.im;
|
||||||
|
Self::new(
|
||||||
|
(self.re * other.re + self.im * other.im) / denom,
|
||||||
|
(self.im * other.re - self.re * other.im) / denom,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn abs(self) -> f64 {
|
||||||
|
(self.re * self.re + self.im * self.im).sqrt()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sqrt(self) -> Self {
|
||||||
|
let magnitude = self.abs();
|
||||||
|
let re = ((magnitude + self.re) * 0.5).max(0.0).sqrt();
|
||||||
|
let im = ((magnitude - self.re) * 0.5)
|
||||||
|
.max(0.0)
|
||||||
|
.sqrt()
|
||||||
|
.copysign(self.im);
|
||||||
|
Self::new(re, im)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prewarp(freq_hz: f64, sample_rate: f64) -> f64 {
|
||||||
|
2.0 * sample_rate * (std::f64::consts::PI * freq_hz / sample_rate).tan()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bilinear_pole(pole: Complex, sample_rate: f64) -> Complex {
|
||||||
|
let two_fs = Complex::new(2.0 * sample_rate, 0.0);
|
||||||
|
two_fs.add(pole).div(two_fs.sub(pole))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Designs a 2N-order digital Butterworth bandpass from an N-order prototype.
|
||||||
|
/// `prototype_order = 3` produces the conventional sixth-order analyzer band.
|
||||||
|
pub fn design_fractional_octave_band(
|
||||||
|
center_hz: f32,
|
||||||
|
lower_hz: f32,
|
||||||
|
upper_hz: f32,
|
||||||
|
sample_rate: u32,
|
||||||
|
prototype_order: usize,
|
||||||
|
) -> Vec<BiquadCoeffs> {
|
||||||
|
let fs = f64::from(sample_rate.max(8_000));
|
||||||
|
let nyquist = fs * 0.5;
|
||||||
|
let lower = f64::from(lower_hz).clamp(0.01, nyquist * 0.999_8);
|
||||||
|
let upper = f64::from(upper_hz).clamp(lower * 1.000_001, nyquist * 0.999_9);
|
||||||
|
let order = prototype_order.clamp(1, 4);
|
||||||
|
let omega_1 = prewarp(lower, fs);
|
||||||
|
let omega_2 = prewarp(upper, fs);
|
||||||
|
let bandwidth = omega_2 - omega_1;
|
||||||
|
let omega_0_sq = omega_1 * omega_2;
|
||||||
|
let mut positive_poles = Vec::with_capacity(order);
|
||||||
|
|
||||||
|
for index in 0..order {
|
||||||
|
let angle = std::f64::consts::PI * (2 * index + 1 + order) as f64 / (2 * order) as f64;
|
||||||
|
let prototype_pole = Complex::new(angle.cos(), angle.sin());
|
||||||
|
let bp = prototype_pole.scale(bandwidth);
|
||||||
|
let discriminant = bp.mul(bp).sub(Complex::new(4.0 * omega_0_sq, 0.0)).sqrt();
|
||||||
|
for root in [
|
||||||
|
bp.add(discriminant).scale(0.5),
|
||||||
|
bp.sub(discriminant).scale(0.5),
|
||||||
|
] {
|
||||||
|
let digital = bilinear_pole(root, fs);
|
||||||
|
if digital.im > 1.0e-10 {
|
||||||
|
positive_poles.push(digital);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
positive_poles.sort_by(|left, right| left.re.total_cmp(&right.re));
|
||||||
|
let mut sections: Vec<BiquadCoeffs> = positive_poles
|
||||||
|
.into_iter()
|
||||||
|
.map(|pole| BiquadCoeffs {
|
||||||
|
// Each section receives one zero at DC and one at Nyquist.
|
||||||
|
b0: 1.0,
|
||||||
|
b1: 0.0,
|
||||||
|
b2: -1.0,
|
||||||
|
a1: -2.0 * pole.re,
|
||||||
|
a2: pole.re * pole.re + pole.im * pole.im,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if sections.is_empty() {
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
let magnitude = cascade_magnitude(§ions, center_hz, sample_rate).max(1.0e-30);
|
||||||
|
let per_section_gain = (1.0 / magnitude).powf(1.0 / sections.len() as f64);
|
||||||
|
for section in &mut sections {
|
||||||
|
section.b0 *= per_section_gain;
|
||||||
|
section.b1 *= per_section_gain;
|
||||||
|
section.b2 *= per_section_gain;
|
||||||
|
}
|
||||||
|
sections
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cascade_magnitude(sections: &[BiquadCoeffs], freq_hz: f32, sample_rate: u32) -> f64 {
|
||||||
|
let omega = 2.0 * std::f64::consts::PI * f64::from(freq_hz) / f64::from(sample_rate.max(8_000));
|
||||||
|
let z1 = Complex::new(omega.cos(), -omega.sin());
|
||||||
|
let z2 = z1.mul(z1);
|
||||||
|
sections.iter().fold(1.0, |magnitude, section| {
|
||||||
|
let numerator = Complex::new(section.b0, 0.0)
|
||||||
|
.add(z1.scale(section.b1))
|
||||||
|
.add(z2.scale(section.b2));
|
||||||
|
let denominator = Complex::new(1.0, 0.0)
|
||||||
|
.add(z1.scale(section.a1))
|
||||||
|
.add(z2.scale(section.a2));
|
||||||
|
magnitude * numerator.div(denominator).abs()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cascade_db(sections: &[BiquadCoeffs], freq_hz: f32, sample_rate: u32) -> f32 {
|
||||||
|
(20.0
|
||||||
|
* cascade_magnitude(sections, freq_hz, sample_rate)
|
||||||
|
.max(1.0e-30)
|
||||||
|
.log10()) as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn integrate_power(
|
||||||
|
previous: f64,
|
||||||
|
block_power: f64,
|
||||||
|
block_samples: usize,
|
||||||
|
sample_rate: u32,
|
||||||
|
tau_seconds: f32,
|
||||||
|
) -> f64 {
|
||||||
|
let dt = block_samples as f64 / f64::from(sample_rate.max(1));
|
||||||
|
let tau = f64::from(tau_seconds.max(0.001));
|
||||||
|
let alpha = 1.0 - (-dt / tau).exp();
|
||||||
|
previous + alpha * (block_power - previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct StereoBiquad {
|
||||||
|
coeffs: BiquadCoeffs,
|
||||||
|
z1_l: f64,
|
||||||
|
z2_l: f64,
|
||||||
|
z1_r: f64,
|
||||||
|
z2_r: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct StereoCascade {
|
||||||
|
sections: Vec<StereoBiquad>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StereoCascade {
|
||||||
|
pub fn new(coefficients: Vec<BiquadCoeffs>) -> Self {
|
||||||
|
Self {
|
||||||
|
sections: coefficients
|
||||||
|
.into_iter()
|
||||||
|
.map(|coeffs| StereoBiquad {
|
||||||
|
coeffs,
|
||||||
|
z1_l: 0.0,
|
||||||
|
z2_l: 0.0,
|
||||||
|
z1_r: 0.0,
|
||||||
|
z2_r: 0.0,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process(&mut self, left: f32, right: f32) -> (f32, f32) {
|
||||||
|
let mut out_l = f64::from(left);
|
||||||
|
let mut out_r = f64::from(right);
|
||||||
|
for section in &mut self.sections {
|
||||||
|
out_l = section.process(out_l, false);
|
||||||
|
out_r = section.process(out_r, true);
|
||||||
|
}
|
||||||
|
(out_l as f32, out_r as f32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StereoBiquad {
|
||||||
|
fn process(&mut self, input: f64, right: bool) -> f64 {
|
||||||
|
let (z1, z2) = if right {
|
||||||
|
(&mut self.z1_r, &mut self.z2_r)
|
||||||
|
} else {
|
||||||
|
(&mut self.z1_l, &mut self.z2_l)
|
||||||
|
};
|
||||||
|
let output = self.coeffs.b0 * input + *z1;
|
||||||
|
*z1 = self.coeffs.b1 * input - self.coeffs.a1 * output + *z2;
|
||||||
|
*z2 = self.coeffs.b2 * input - self.coeffs.a2 * output;
|
||||||
|
output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FrequencyWeighting {
|
||||||
|
mode: &'static str,
|
||||||
|
cascade: StereoCascade,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrequencyWeighting {
|
||||||
|
pub fn new(mode: &str, sample_rate: u32) -> Self {
|
||||||
|
let mode = normalize_weighting(mode);
|
||||||
|
let cascade = StereoCascade::new(design_weighting(mode, sample_rate));
|
||||||
|
Self { mode, cascade }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process(&mut self, left: f32, right: f32) -> (f32, f32) {
|
||||||
|
if self.mode == "z" {
|
||||||
|
return (left, right);
|
||||||
|
}
|
||||||
|
self.cascade.process(left, right)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_weighting(value: &str) -> &'static str {
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"a" => "a",
|
||||||
|
"c" => "c",
|
||||||
|
_ => "z",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mapped_real_pole(freq_hz: f64, sample_rate: f64) -> f64 {
|
||||||
|
let analog = prewarp(freq_hz.min(sample_rate * 0.499), sample_rate);
|
||||||
|
(2.0 * sample_rate - analog) / (2.0 * sample_rate + analog)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn real_pole_section(zero_1: f64, zero_2: f64, pole_1: f64, pole_2: f64) -> BiquadCoeffs {
|
||||||
|
BiquadCoeffs {
|
||||||
|
b0: 1.0,
|
||||||
|
b1: -(zero_1 + zero_2),
|
||||||
|
b2: zero_1 * zero_2,
|
||||||
|
a1: -(pole_1 + pole_2),
|
||||||
|
a2: pole_1 * pole_2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn design_weighting(mode: &str, sample_rate: u32) -> Vec<BiquadCoeffs> {
|
||||||
|
if mode == "z" {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let fs = f64::from(sample_rate.max(8_000));
|
||||||
|
let p1 = mapped_real_pole(20.598_997, fs);
|
||||||
|
let p2 = mapped_real_pole(107.652_65, fs);
|
||||||
|
let p3 = mapped_real_pole(737.862_23, fs);
|
||||||
|
let p4 = mapped_real_pole(12_194.217, fs);
|
||||||
|
let mut sections = if mode == "a" {
|
||||||
|
vec![
|
||||||
|
real_pole_section(1.0, 1.0, p1, p1),
|
||||||
|
real_pole_section(1.0, 1.0, p2, p3),
|
||||||
|
real_pole_section(-1.0, -1.0, p4, p4),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
vec![
|
||||||
|
real_pole_section(1.0, 1.0, p1, p1),
|
||||||
|
real_pole_section(-1.0, -1.0, p4, p4),
|
||||||
|
]
|
||||||
|
};
|
||||||
|
let magnitude = cascade_magnitude(§ions, 1_000.0, sample_rate).max(1.0e-30);
|
||||||
|
let gain = (1.0 / magnitude).powf(1.0 / sections.len() as f64);
|
||||||
|
for section in &mut sections {
|
||||||
|
section.b0 *= gain;
|
||||||
|
section.b1 *= gain;
|
||||||
|
section.b2 *= gain;
|
||||||
|
}
|
||||||
|
sections
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn weighting_response_db(mode: &str, freq_hz: f32, sample_rate: u32) -> f32 {
|
||||||
|
cascade_db(
|
||||||
|
&design_weighting(normalize_weighting(mode), sample_rate),
|
||||||
|
freq_hz,
|
||||||
|
sample_rate,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn weighting_reference_db(mode: &str, freq_hz: f32) -> f32 {
|
||||||
|
if !freq_hz.is_finite() || freq_hz <= 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let f2 = freq_hz * freq_hz;
|
||||||
|
match normalize_weighting(mode) {
|
||||||
|
"a" => {
|
||||||
|
let numerator = (12_194.0f32 * 12_194.0) * f2 * f2;
|
||||||
|
let denominator = (f2 + 20.6f32 * 20.6)
|
||||||
|
* ((f2 + 107.7f32 * 107.7) * (f2 + 737.9f32 * 737.9)).sqrt()
|
||||||
|
* (f2 + 12_194.0f32 * 12_194.0);
|
||||||
|
20.0 * (numerator / denominator).max(1.0e-12).log10() + 2.0
|
||||||
|
}
|
||||||
|
"c" => {
|
||||||
|
let numerator = (12_194.0f32 * 12_194.0) * f2;
|
||||||
|
let denominator = (f2 + 20.6f32 * 20.6) * (f2 + 12_194.0f32 * 12_194.0);
|
||||||
|
20.0 * (numerator / denominator).max(1.0e-12).log10() + 0.06
|
||||||
|
}
|
||||||
|
_ => 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Corrects the finite-rate digital weighting filter at a band's center to
|
||||||
|
/// the standardized A/C reference curve. This is especially important near
|
||||||
|
/// Nyquist, where the bilinear filter necessarily bends toward zero.
|
||||||
|
pub fn weighting_power_correction(mode: &str, freq_hz: f32, sample_rate: u32) -> f64 {
|
||||||
|
if normalize_weighting(mode) == "z" {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
let difference =
|
||||||
|
weighting_reference_db(mode, freq_hz) - weighting_response_db(mode, freq_hz, sample_rate);
|
||||||
|
10.0f64.powf(f64::from(difference) / 10.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn third_octave_butterworth_has_correct_center_and_edges() {
|
||||||
|
assert_eq!(RTW_THIRD_OCTAVE_CENTERS.len(), 31);
|
||||||
|
for &nominal_center in RTW_THIRD_OCTAVE_CENTERS {
|
||||||
|
let center = exact_fractional_octave_center(nominal_center, 3);
|
||||||
|
let (lower, upper) = fractional_octave_edges(center, 3);
|
||||||
|
let sections = design_fractional_octave_band(center, lower, upper, 48_000, 3);
|
||||||
|
assert_eq!(sections.len(), 3);
|
||||||
|
assert!(cascade_db(§ions, center, 48_000).abs() < 0.01);
|
||||||
|
let lower_db = cascade_db(§ions, lower, 48_000);
|
||||||
|
let upper_db = cascade_db(§ions, upper, 48_000);
|
||||||
|
assert!(
|
||||||
|
(lower_db + 3.0103).abs() < 0.08,
|
||||||
|
"{center}: lower {lower_db}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(upper_db + 3.0103).abs() < 0.08,
|
||||||
|
"{center}: upper {upper_db}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preferred_labels_map_to_exact_iec_centers() {
|
||||||
|
assert!((exact_fractional_octave_center(31.5, 3) - 31.622_776).abs() < 0.000_1);
|
||||||
|
assert_eq!(exact_fractional_octave_center(1_000.0, 3), 1_000.0);
|
||||||
|
assert!((exact_fractional_octave_center(20_000.0, 3) - 19_952.623).abs() < 0.01);
|
||||||
|
let center = exact_fractional_octave_center(1_250.0, 3);
|
||||||
|
let (lower, upper) = fractional_octave_edges(center, 3);
|
||||||
|
assert!(((lower * upper).sqrt() - center).abs() < 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn neighboring_third_octave_centers_are_suppressed() {
|
||||||
|
let factor = 2.0f32.powf(1.0 / 6.0);
|
||||||
|
let sections =
|
||||||
|
design_fractional_octave_band(1_000.0, 1_000.0 / factor, 1_000.0 * factor, 48_000, 3);
|
||||||
|
assert!(cascade_db(§ions, 1_000.0 * 2.0f32.powf(1.0 / 3.0), 48_000) < -18.0);
|
||||||
|
assert!(cascade_db(§ions, 1_000.0 * 2.0f32.powf(-1.0 / 3.0), 48_000) < -18.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn design_tracks_sample_rate() {
|
||||||
|
let factor = 2.0f32.powf(1.0 / 6.0);
|
||||||
|
for sample_rate in [44_100, 48_000, 96_000] {
|
||||||
|
let sections = design_fractional_octave_band(
|
||||||
|
10_000.0,
|
||||||
|
10_000.0 / factor,
|
||||||
|
10_000.0 * factor,
|
||||||
|
sample_rate,
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
assert!(cascade_db(§ions, 10_000.0, sample_rate).abs() < 0.01);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn power_integration_is_block_size_independent() {
|
||||||
|
fn run(block_size: usize) -> f64 {
|
||||||
|
let sample_rate = 48_000;
|
||||||
|
let mut value = 0.0;
|
||||||
|
let mut processed = 0usize;
|
||||||
|
while processed < sample_rate as usize {
|
||||||
|
let count = block_size.min(sample_rate as usize - processed);
|
||||||
|
value = integrate_power(value, 1.0, count, sample_rate, 0.125);
|
||||||
|
processed += count;
|
||||||
|
}
|
||||||
|
value
|
||||||
|
}
|
||||||
|
let reference = run(1);
|
||||||
|
for block_size in [64, 128, 192, 512, 1_024] {
|
||||||
|
let actual = run(block_size);
|
||||||
|
assert!(
|
||||||
|
(actual - reference).abs() < 1.0e-12,
|
||||||
|
"block {block_size}: {actual} vs {reference}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn weighting_is_normalized_and_directionally_correct() {
|
||||||
|
for mode in ["a", "c"] {
|
||||||
|
assert!(weighting_response_db(mode, 1_000.0, 48_000).abs() < 0.01);
|
||||||
|
}
|
||||||
|
assert!(weighting_response_db("a", 31.5, 48_000) < -35.0);
|
||||||
|
assert!(weighting_response_db("c", 31.5, 48_000) < -2.0);
|
||||||
|
assert!(weighting_response_db("a", 8_000.0, 48_000) < 0.0);
|
||||||
|
assert_eq!(weighting_response_db("z", 31.5, 48_000), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn weighting_center_calibration_tracks_standard_reference_points() {
|
||||||
|
let a_points = [
|
||||||
|
(31.5, -39.5),
|
||||||
|
(63.0, -26.2),
|
||||||
|
(125.0, -16.1),
|
||||||
|
(1_000.0, 0.0),
|
||||||
|
(8_000.0, -1.1),
|
||||||
|
(16_000.0, -6.6),
|
||||||
|
];
|
||||||
|
let c_points = [
|
||||||
|
(31.5, -3.0),
|
||||||
|
(63.0, -0.8),
|
||||||
|
(1_000.0, 0.0),
|
||||||
|
(8_000.0, -3.0),
|
||||||
|
(16_000.0, -8.5),
|
||||||
|
];
|
||||||
|
for (freq, expected) in a_points {
|
||||||
|
let raw = weighting_response_db("a", freq, 48_000);
|
||||||
|
let correction = 10.0 * weighting_power_correction("a", freq, 48_000).log10() as f32;
|
||||||
|
let actual = raw + correction;
|
||||||
|
assert!((actual - expected).abs() < 1.0, "A {freq} Hz: {actual} dB");
|
||||||
|
}
|
||||||
|
for (freq, expected) in c_points {
|
||||||
|
let raw = weighting_response_db("c", freq, 48_000);
|
||||||
|
let correction = 10.0 * weighting_power_correction("c", freq, 48_000).log10() as f32;
|
||||||
|
let actual = raw + correction;
|
||||||
|
assert!((actual - expected).abs() < 1.0, "C {freq} Hz: {actual} dB");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn weighting_time_domain_is_finite_and_normalized() {
|
||||||
|
let sample_rate = 48_000;
|
||||||
|
for freq in [31.5, 1_000.0, 16_000.0] {
|
||||||
|
let mut weighting = FrequencyWeighting::new("a", sample_rate);
|
||||||
|
let mut input_power = 0.0f64;
|
||||||
|
let mut output_power = 0.0f64;
|
||||||
|
for index in 0..sample_rate as usize * 2 {
|
||||||
|
let input =
|
||||||
|
(2.0 * std::f32::consts::PI * freq * index as f32 / sample_rate as f32).sin();
|
||||||
|
let (output, _) = weighting.process(input, input);
|
||||||
|
assert!(output.is_finite());
|
||||||
|
if index >= sample_rate as usize {
|
||||||
|
input_power += f64::from(input * input);
|
||||||
|
output_power += f64::from(output * output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let raw_db = 10.0 * (output_power / input_power).log10();
|
||||||
|
let correction_db = 10.0 * weighting_power_correction("a", freq, sample_rate).log10();
|
||||||
|
let calibrated_db = raw_db + correction_db;
|
||||||
|
let expected = f64::from(weighting_reference_db("a", freq));
|
||||||
|
assert!(
|
||||||
|
(calibrated_db - expected).abs() < 0.05,
|
||||||
|
"A {freq} Hz: {calibrated_db} dB vs {expected} dB"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn measured_sine_gain(filter: &mut StereoCascade, freq: f32, sample_rate: u32) -> f32 {
|
||||||
|
let total = sample_rate as usize * 3;
|
||||||
|
let settle = sample_rate as usize * 2;
|
||||||
|
let mut input_power = 0.0f64;
|
||||||
|
let mut output_power = 0.0f64;
|
||||||
|
for index in 0..total {
|
||||||
|
let phase = 2.0 * std::f32::consts::PI * freq * index as f32 / sample_rate as f32;
|
||||||
|
let input = phase.sin();
|
||||||
|
let (output, _) = filter.process(input, input);
|
||||||
|
if index >= settle {
|
||||||
|
input_power += f64::from(input * input);
|
||||||
|
output_power += f64::from(output * output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(10.0 * (output_power / input_power).log10()) as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn time_domain_filter_matches_designed_response() {
|
||||||
|
let factor = 2.0f32.powf(1.0 / 6.0);
|
||||||
|
let coefficients =
|
||||||
|
design_fractional_octave_band(1_000.0, 1_000.0 / factor, 1_000.0 * factor, 48_000, 3);
|
||||||
|
let center = measured_sine_gain(
|
||||||
|
&mut StereoCascade::new(coefficients.clone()),
|
||||||
|
1_000.0,
|
||||||
|
48_000,
|
||||||
|
);
|
||||||
|
let edge = measured_sine_gain(
|
||||||
|
&mut StereoCascade::new(coefficients),
|
||||||
|
1_000.0 * factor,
|
||||||
|
48_000,
|
||||||
|
);
|
||||||
|
assert!(center.abs() < 0.02, "center gain {center}");
|
||||||
|
assert!((edge + 3.0103).abs() < 0.08, "edge gain {edge}");
|
||||||
|
}
|
||||||
|
}
|
||||||
+224
-50
@@ -2,8 +2,7 @@ use std::{
|
|||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
sync::{
|
sync::{
|
||||||
atomic::{AtomicU64, Ordering},
|
atomic::{AtomicU64, Ordering},
|
||||||
Arc,
|
Arc, Mutex,
|
||||||
Mutex,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -16,7 +15,10 @@ use tokio::{
|
|||||||
use crate::{
|
use crate::{
|
||||||
audio::{spawn_audio_capture_worker, AudioWorkerDeps},
|
audio::{spawn_audio_capture_worker, AudioWorkerDeps},
|
||||||
config::PhoenixConfig,
|
config::PhoenixConfig,
|
||||||
model::{InputSource, MeterFrame, PhoenixGlobalConfig, PhoenixGlobalConfigEnvelope, PhoenixRtaConfig, ServiceStatus},
|
model::{
|
||||||
|
InputSource, MeterFrame, PhoenixGlobalConfig, PhoenixGlobalConfigEnvelope,
|
||||||
|
PhoenixRtaConfig, ServiceStatus,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -24,7 +26,8 @@ pub struct AppState {
|
|||||||
pub config: PhoenixConfig,
|
pub config: PhoenixConfig,
|
||||||
current_input: Arc<RwLock<InputSource>>,
|
current_input: Arc<RwLock<InputSource>>,
|
||||||
seq: Arc<AtomicU64>,
|
seq: Arc<AtomicU64>,
|
||||||
metrics_tx: broadcast::Sender<MeterFrame>,
|
actual_sample_rate: Arc<AtomicU64>,
|
||||||
|
metrics_tx: broadcast::Sender<Arc<MeterFrame>>,
|
||||||
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>>,
|
||||||
@@ -49,7 +52,6 @@ pub(crate) struct NativeWavRecorder {
|
|||||||
pub pending_discontinuity: bool,
|
pub pending_discontinuity: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const NATIVE_RECORDING_EDGE_FADE_FRAMES: usize = 128;
|
const NATIVE_RECORDING_EDGE_FADE_FRAMES: usize = 128;
|
||||||
|
|
||||||
fn apply_native_recording_edge_fades(pcm_bytes: &mut [u8], channels: u16) {
|
fn apply_native_recording_edge_fades(pcm_bytes: &mut [u8], channels: u16) {
|
||||||
@@ -62,7 +64,9 @@ fn apply_native_recording_edge_fades(pcm_bytes: &mut [u8], channels: u16) {
|
|||||||
if total_frames < 2 {
|
if total_frames < 2 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let fade_frames = NATIVE_RECORDING_EDGE_FADE_FRAMES.min(total_frames / 2).max(1);
|
let fade_frames = NATIVE_RECORDING_EDGE_FADE_FRAMES
|
||||||
|
.min(total_frames / 2)
|
||||||
|
.max(1);
|
||||||
|
|
||||||
for frame_idx in 0..fade_frames {
|
for frame_idx in 0..fade_frames {
|
||||||
let in_gain = (frame_idx as f32 + 1.0) / fade_frames as f32;
|
let in_gain = (frame_idx as f32 + 1.0) / fade_frames as f32;
|
||||||
@@ -71,12 +75,16 @@ fn apply_native_recording_edge_fades(pcm_bytes: &mut [u8], channels: u16) {
|
|||||||
for ch_idx in 0..ch {
|
for ch_idx in 0..ch {
|
||||||
let in_off = frame_idx * frame_size + ch_idx * 2;
|
let in_off = frame_idx * frame_size + ch_idx * 2;
|
||||||
let in_sample = i16::from_le_bytes([pcm_bytes[in_off], pcm_bytes[in_off + 1]]);
|
let in_sample = i16::from_le_bytes([pcm_bytes[in_off], pcm_bytes[in_off + 1]]);
|
||||||
let in_scaled = (in_sample as f32 * in_gain).round().clamp(i16::MIN as f32, i16::MAX as f32) as i16;
|
let in_scaled = (in_sample as f32 * in_gain)
|
||||||
|
.round()
|
||||||
|
.clamp(i16::MIN as f32, i16::MAX as f32) as i16;
|
||||||
pcm_bytes[in_off..in_off + 2].copy_from_slice(&in_scaled.to_le_bytes());
|
pcm_bytes[in_off..in_off + 2].copy_from_slice(&in_scaled.to_le_bytes());
|
||||||
|
|
||||||
let out_off = out_frame_idx * frame_size + ch_idx * 2;
|
let out_off = out_frame_idx * frame_size + ch_idx * 2;
|
||||||
let out_sample = i16::from_le_bytes([pcm_bytes[out_off], pcm_bytes[out_off + 1]]);
|
let out_sample = i16::from_le_bytes([pcm_bytes[out_off], pcm_bytes[out_off + 1]]);
|
||||||
let out_scaled = (out_sample as f32 * out_gain).round().clamp(i16::MIN as f32, i16::MAX as f32) as i16;
|
let out_scaled = (out_sample as f32 * out_gain)
|
||||||
|
.round()
|
||||||
|
.clamp(i16::MIN as f32, i16::MAX as f32) as i16;
|
||||||
pcm_bytes[out_off..out_off + 2].copy_from_slice(&out_scaled.to_le_bytes());
|
pcm_bytes[out_off..out_off + 2].copy_from_slice(&out_scaled.to_le_bytes());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,13 +92,18 @@ fn apply_native_recording_edge_fades(pcm_bytes: &mut [u8], channels: u16) {
|
|||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
pub fn new(config: PhoenixConfig) -> Self {
|
pub fn new(config: PhoenixConfig) -> Self {
|
||||||
let (metrics_tx, _) = broadcast::channel(512);
|
// WebSocket consumers always drain to the newest state. A small
|
||||||
|
// channel bounds memory and prevents seconds of stale measurements.
|
||||||
|
let (metrics_tx, _) = broadcast::channel(32);
|
||||||
let initial_global_config = load_global_config(&config);
|
let initial_global_config = load_global_config(&config);
|
||||||
let initial_rta_config = apply_global_to_rta(config.default_rta_config(), &initial_global_config);
|
let initial_rta_config =
|
||||||
|
apply_global_to_rta(config.default_rta_config(), &initial_global_config);
|
||||||
|
let configured_sample_rate = config.sample_rate;
|
||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
current_input: Arc::new(RwLock::new(InputSource::Line)),
|
current_input: Arc::new(RwLock::new(InputSource::Line)),
|
||||||
seq: Arc::new(AtomicU64::new(0)),
|
seq: Arc::new(AtomicU64::new(0)),
|
||||||
|
actual_sample_rate: Arc::new(AtomicU64::new(configured_sample_rate as u64)),
|
||||||
metrics_tx,
|
metrics_tx,
|
||||||
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)),
|
||||||
@@ -100,7 +113,7 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn subscribe_metrics(&self) -> broadcast::Receiver<MeterFrame> {
|
pub fn subscribe_metrics(&self) -> broadcast::Receiver<Arc<MeterFrame>> {
|
||||||
self.metrics_tx.subscribe()
|
self.metrics_tx.subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +128,10 @@ impl AppState {
|
|||||||
audio_engine: "alsa-direct",
|
audio_engine: "alsa-direct",
|
||||||
metrics_mode: "live",
|
metrics_mode: "live",
|
||||||
alsa_device: self.config.alsa_device(),
|
alsa_device: self.config.alsa_device(),
|
||||||
sample_rate: self.config.sample_rate,
|
sample_rate: self
|
||||||
|
.actual_sample_rate
|
||||||
|
.load(Ordering::SeqCst)
|
||||||
|
.min(u32::MAX as u64) as u32,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +164,7 @@ impl AppState {
|
|||||||
lufs_i_norm_enabled: normalized.lufs_i_norm_enabled,
|
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,
|
||||||
ppm_din_loudness_offset_db: current.ppm_din_loudness_offset_db,
|
ppm_din_loudness_offset_db: current.ppm_din_loudness_offset_db,
|
||||||
xy_points: current.xy_points,
|
xy_points: normalized.xy_points,
|
||||||
gonio_display_gain_db: current.gonio_display_gain_db,
|
gonio_display_gain_db: current.gonio_display_gain_db,
|
||||||
record_output_format: current.record_output_format.clone(),
|
record_output_format: current.record_output_format.clone(),
|
||||||
record_mp3_bitrate_kbps: current.record_mp3_bitrate_kbps,
|
record_mp3_bitrate_kbps: current.record_mp3_bitrate_kbps,
|
||||||
@@ -158,6 +174,8 @@ impl AppState {
|
|||||||
clock_led_color: current.clock_led_color.clone(),
|
clock_led_color: current.clock_led_color.clone(),
|
||||||
header_text_color: current.header_text_color.clone(),
|
header_text_color: current.header_text_color.clone(),
|
||||||
rta_bar_base_color: current.rta_bar_base_color.clone(),
|
rta_bar_base_color: current.rta_bar_base_color.clone(),
|
||||||
|
spectro_gamma: current.spectro_gamma,
|
||||||
|
spectro_scroll_mode: current.spectro_scroll_mode,
|
||||||
peak_history_scroll_mode: current.peak_history_scroll_mode,
|
peak_history_scroll_mode: current.peak_history_scroll_mode,
|
||||||
peak_history_fill_enabled: current.peak_history_fill_enabled,
|
peak_history_fill_enabled: current.peak_history_fill_enabled,
|
||||||
peak_history_fill_invert: current.peak_history_fill_invert,
|
peak_history_fill_invert: current.peak_history_fill_invert,
|
||||||
@@ -248,6 +266,7 @@ impl AppState {
|
|||||||
global_config_rev: self.global_config_rev.clone(),
|
global_config_rev: self.global_config_rev.clone(),
|
||||||
native_wav_recorders: self.native_wav_recorders.clone(),
|
native_wav_recorders: self.native_wav_recorders.clone(),
|
||||||
seq: self.seq.clone(),
|
seq: self.seq.clone(),
|
||||||
|
actual_sample_rate: self.actual_sample_rate.clone(),
|
||||||
metrics_tx: self.metrics_tx.clone(),
|
metrics_tx: self.metrics_tx.clone(),
|
||||||
restart_token: self.restart_token.clone(),
|
restart_token: self.restart_token.clone(),
|
||||||
});
|
});
|
||||||
@@ -264,7 +283,10 @@ impl AppState {
|
|||||||
guard.insert(
|
guard.insert(
|
||||||
session_id,
|
session_id,
|
||||||
NativeWavRecorder {
|
NativeWavRecorder {
|
||||||
sample_rate: self.config.sample_rate,
|
sample_rate: self
|
||||||
|
.actual_sample_rate
|
||||||
|
.load(Ordering::SeqCst)
|
||||||
|
.min(u32::MAX as u64) as u32,
|
||||||
channels: 2,
|
channels: 2,
|
||||||
pcm_bytes: Vec::new(),
|
pcm_bytes: Vec::new(),
|
||||||
total_frames: 0,
|
total_frames: 0,
|
||||||
@@ -334,34 +356,69 @@ fn normalize_rta_config(mut config: PhoenixRtaConfig) -> PhoenixRtaConfig {
|
|||||||
};
|
};
|
||||||
config.integration = match config.integration.trim().to_ascii_lowercase().as_str() {
|
config.integration = match config.integration.trim().to_ascii_lowercase().as_str() {
|
||||||
"impulse" => "impulse".to_string(),
|
"impulse" => "impulse".to_string(),
|
||||||
|
"medium" => "medium".to_string(),
|
||||||
"slow" => "slow".to_string(),
|
"slow" => "slow".to_string(),
|
||||||
|
"average" => "average".to_string(),
|
||||||
"peak" => "peak".to_string(),
|
"peak" => "peak".to_string(),
|
||||||
_ => "fast".to_string(),
|
_ => "fast".to_string(),
|
||||||
};
|
};
|
||||||
config.order = config.order.clamp(2, 8);
|
config.order = config.order.clamp(2, 8);
|
||||||
|
if config.order % 2 != 0 {
|
||||||
|
config.order = (config.order + 1).min(8);
|
||||||
|
}
|
||||||
|
if config.layout == "rtw" {
|
||||||
|
config.engine = "iir".to_string();
|
||||||
|
config.bpo = "1_3".to_string();
|
||||||
|
config.order = 6;
|
||||||
|
config.freq_range = "norm".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
let tau_fast = if config.tau_fast.is_finite() { config.tau_fast } else { 0.12 };
|
let tau_fast = if config.tau_fast.is_finite() {
|
||||||
let tau_slow = if config.tau_slow.is_finite() { config.tau_slow } else { 1.0 };
|
config.tau_fast
|
||||||
|
} else {
|
||||||
|
0.12
|
||||||
|
};
|
||||||
|
let tau_slow = if config.tau_slow.is_finite() {
|
||||||
|
config.tau_slow
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
config.tau_fast = tau_fast.clamp(0.01, 3.0);
|
config.tau_fast = tau_fast.clamp(0.01, 3.0);
|
||||||
config.tau_slow = tau_slow.clamp(0.05, 10.0);
|
config.tau_slow = tau_slow.clamp(0.05, 10.0);
|
||||||
if config.tau_slow < config.tau_fast {
|
if config.tau_slow < config.tau_fast {
|
||||||
config.tau_slow = config.tau_fast;
|
config.tau_slow = config.tau_fast;
|
||||||
}
|
}
|
||||||
let offset_l = if config.input_offset_db_l.is_finite() { config.input_offset_db_l } else { -5.0 };
|
let offset_l = if config.input_offset_db_l.is_finite() {
|
||||||
let offset_r = if config.input_offset_db_r.is_finite() { config.input_offset_db_r } else { -5.0 };
|
config.input_offset_db_l
|
||||||
|
} else {
|
||||||
|
-5.0
|
||||||
|
};
|
||||||
|
let offset_r = if config.input_offset_db_r.is_finite() {
|
||||||
|
config.input_offset_db_r
|
||||||
|
} else {
|
||||||
|
-5.0
|
||||||
|
};
|
||||||
config.input_offset_db_l = offset_l.clamp(-60.0, 20.0);
|
config.input_offset_db_l = offset_l.clamp(-60.0, 20.0);
|
||||||
config.input_offset_db_r = offset_r.clamp(-60.0, 20.0);
|
config.input_offset_db_r = offset_r.clamp(-60.0, 20.0);
|
||||||
let din_attack_ms = if config.ppm_din_attack_ms.is_finite() { config.ppm_din_attack_ms } else { 5.0 };
|
// Standard profiles are fixed. Keeping the serialized fields preserves
|
||||||
let din_decay = if config.ppm_din_decay_db_per_s.is_finite() { config.ppm_din_decay_db_per_s } else { 20.0 / 1.7 };
|
// compatibility with older clients without allowing silent mistuning.
|
||||||
let ebu_attack_ms = if config.ppm_ebu_attack_ms.is_finite() { config.ppm_ebu_attack_ms } else { 10.0 };
|
config.ppm_din_attack_ms = 10.0;
|
||||||
let ebu_decay = if config.ppm_ebu_decay_db_per_s.is_finite() { config.ppm_ebu_decay_db_per_s } else { 24.0 / 2.8 };
|
config.ppm_din_decay_db_per_s = 20.0 / 1.5;
|
||||||
config.ppm_din_attack_ms = din_attack_ms.clamp(0.1, 100.0);
|
|
||||||
config.ppm_din_decay_db_per_s = din_decay.clamp(0.1, 120.0);
|
|
||||||
config.ppm_din_fast_attack = !!config.ppm_din_fast_attack;
|
config.ppm_din_fast_attack = !!config.ppm_din_fast_attack;
|
||||||
config.ppm_ebu_attack_ms = ebu_attack_ms.clamp(0.1, 100.0);
|
config.ppm_ebu_attack_ms = 10.0;
|
||||||
config.ppm_ebu_decay_db_per_s = ebu_decay.clamp(0.1, 120.0);
|
config.ppm_ebu_decay_db_per_s = 24.0 / 2.8;
|
||||||
config.lufs_i_window_min = config.lufs_i_window_min.clamp(1, 10);
|
config.lufs_i_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 =
|
||||||
|
crate::correlation::normalize_response_seconds(config.correlation_response_s);
|
||||||
|
config.xy_points = match config.xy_points {
|
||||||
|
128 | 256 | 512 | 1024 | 2048 => config.xy_points,
|
||||||
|
n if n < 192 => 128,
|
||||||
|
n if n < 384 => 256,
|
||||||
|
n if n < 768 => 512,
|
||||||
|
n if n < 1536 => 1024,
|
||||||
|
_ => 2048,
|
||||||
|
};
|
||||||
config
|
config
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,7 +445,10 @@ fn normalize_meter_slot(input: String, fallback: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_global_config(mut config: PhoenixGlobalConfig, runtime: &PhoenixConfig) -> PhoenixGlobalConfig {
|
fn normalize_global_config(
|
||||||
|
mut config: PhoenixGlobalConfig,
|
||||||
|
runtime: &PhoenixConfig,
|
||||||
|
) -> PhoenixGlobalConfig {
|
||||||
config.fft_size = match config.fft_size {
|
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,
|
||||||
@@ -402,8 +462,16 @@ fn normalize_global_config(mut config: PhoenixGlobalConfig, runtime: &PhoenixCon
|
|||||||
"1_12" => "1_12".to_string(),
|
"1_12" => "1_12".to_string(),
|
||||||
_ => "1_6".to_string(),
|
_ => "1_6".to_string(),
|
||||||
};
|
};
|
||||||
let offset_l = if config.input_offset_db_l.is_finite() { config.input_offset_db_l } else { -5.0 };
|
let offset_l = if config.input_offset_db_l.is_finite() {
|
||||||
let offset_r = if config.input_offset_db_r.is_finite() { config.input_offset_db_r } else { -5.0 };
|
config.input_offset_db_l
|
||||||
|
} else {
|
||||||
|
-5.0
|
||||||
|
};
|
||||||
|
let offset_r = if config.input_offset_db_r.is_finite() {
|
||||||
|
config.input_offset_db_r
|
||||||
|
} else {
|
||||||
|
-5.0
|
||||||
|
};
|
||||||
config.input_offset_db_l = offset_l.clamp(-10.0, 10.0);
|
config.input_offset_db_l = offset_l.clamp(-10.0, 10.0);
|
||||||
config.input_offset_db_r = offset_r.clamp(-10.0, 10.0);
|
config.input_offset_db_r = offset_r.clamp(-10.0, 10.0);
|
||||||
config.mono_input = !!config.mono_input;
|
config.mono_input = !!config.mono_input;
|
||||||
@@ -415,22 +483,26 @@ fn normalize_global_config(mut config: PhoenixGlobalConfig, runtime: &PhoenixCon
|
|||||||
};
|
};
|
||||||
config.lr_fractional_delay_samples = delay.clamp(-1.5, 1.5);
|
config.lr_fractional_delay_samples = delay.clamp(-1.5, 1.5);
|
||||||
config.al_markers_enabled = !!config.al_markers_enabled;
|
config.al_markers_enabled = !!config.al_markers_enabled;
|
||||||
let thin = if config.meter_bar_thin.is_finite() { config.meter_bar_thin } else { 0.55 };
|
let thin = if config.meter_bar_thin.is_finite() {
|
||||||
|
config.meter_bar_thin
|
||||||
|
} else {
|
||||||
|
0.55
|
||||||
|
};
|
||||||
config.meter_bar_thin = thin.clamp(0.35, 0.9);
|
config.meter_bar_thin = thin.clamp(0.35, 0.9);
|
||||||
config.panel_dividers_enabled = !!config.panel_dividers_enabled;
|
config.panel_dividers_enabled = !!config.panel_dividers_enabled;
|
||||||
let din_attack_ms = if config.ppm_din_attack_ms.is_finite() { config.ppm_din_attack_ms } else { 5.0 };
|
config.ppm_din_attack_ms = 10.0;
|
||||||
let din_decay = if config.ppm_din_decay_db_per_s.is_finite() { config.ppm_din_decay_db_per_s } else { 20.0 / 1.7 };
|
config.ppm_din_decay_db_per_s = 20.0 / 1.5;
|
||||||
let ebu_attack_ms = if config.ppm_ebu_attack_ms.is_finite() { config.ppm_ebu_attack_ms } else { 10.0 };
|
|
||||||
let ebu_decay = if config.ppm_ebu_decay_db_per_s.is_finite() { config.ppm_ebu_decay_db_per_s } else { 24.0 / 2.8 };
|
|
||||||
config.ppm_din_attack_ms = din_attack_ms.clamp(0.1, 100.0);
|
|
||||||
config.ppm_din_decay_db_per_s = din_decay.clamp(0.1, 120.0);
|
|
||||||
config.ppm_din_fast_attack = !!config.ppm_din_fast_attack;
|
config.ppm_din_fast_attack = !!config.ppm_din_fast_attack;
|
||||||
config.ppm_ebu_attack_ms = ebu_attack_ms.clamp(0.1, 100.0);
|
config.ppm_ebu_attack_ms = 10.0;
|
||||||
config.ppm_ebu_decay_db_per_s = ebu_decay.clamp(0.1, 120.0);
|
config.ppm_ebu_decay_db_per_s = 24.0 / 2.8;
|
||||||
config.lufs_i_window_min = config.lufs_i_window_min.clamp(1, 10);
|
config.lufs_i_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;
|
||||||
let loudness_offset = if config.ppm_din_loudness_offset_db.is_finite() { config.ppm_din_loudness_offset_db } else { 0.0 };
|
let loudness_offset = if config.ppm_din_loudness_offset_db.is_finite() {
|
||||||
|
config.ppm_din_loudness_offset_db
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
config.ppm_din_loudness_offset_db = loudness_offset.clamp(-7.0, 7.0);
|
config.ppm_din_loudness_offset_db = loudness_offset.clamp(-7.0, 7.0);
|
||||||
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,
|
||||||
@@ -440,9 +512,18 @@ fn normalize_global_config(mut config: PhoenixGlobalConfig, runtime: &PhoenixCon
|
|||||||
n if n < 1536 => 1024,
|
n if n < 1536 => 1024,
|
||||||
_ => 2048,
|
_ => 2048,
|
||||||
};
|
};
|
||||||
let gonio_gain = if config.gonio_display_gain_db.is_finite() { config.gonio_display_gain_db } else { -5.0 };
|
let gonio_gain = if config.gonio_display_gain_db.is_finite() {
|
||||||
|
config.gonio_display_gain_db
|
||||||
|
} else {
|
||||||
|
-5.0
|
||||||
|
};
|
||||||
config.gonio_display_gain_db = gonio_gain.clamp(-35.0, 35.0);
|
config.gonio_display_gain_db = gonio_gain.clamp(-35.0, 35.0);
|
||||||
config.record_output_format = match config.record_output_format.trim().to_ascii_lowercase().as_str() {
|
config.record_output_format = match config
|
||||||
|
.record_output_format
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
"mp3" => "mp3".to_string(),
|
"mp3" => "mp3".to_string(),
|
||||||
"webm" => "webm".to_string(),
|
"webm" => "webm".to_string(),
|
||||||
_ => "wav".to_string(),
|
_ => "wav".to_string(),
|
||||||
@@ -459,13 +540,39 @@ fn normalize_global_config(mut config: PhoenixGlobalConfig, runtime: &PhoenixCon
|
|||||||
"volumio" => "volumio".to_string(),
|
"volumio" => "volumio".to_string(),
|
||||||
_ => "analyzer".to_string(),
|
_ => "analyzer".to_string(),
|
||||||
};
|
};
|
||||||
let auto_gap = if config.record_auto_split_gap_sec.is_finite() { config.record_auto_split_gap_sec } else { 1.5 };
|
let auto_gap = if config.record_auto_split_gap_sec.is_finite() {
|
||||||
|
config.record_auto_split_gap_sec
|
||||||
|
} else {
|
||||||
|
1.5
|
||||||
|
};
|
||||||
config.record_auto_split_gap_sec = (auto_gap.clamp(0.5, 3.0) * 2.0).round() / 2.0;
|
config.record_auto_split_gap_sec = (auto_gap.clamp(0.5, 3.0) * 2.0).round() / 2.0;
|
||||||
let auto_threshold = if config.record_auto_threshold_dbfs.is_finite() { config.record_auto_threshold_dbfs } else { -50.0 };
|
let auto_threshold = if config.record_auto_threshold_dbfs.is_finite() {
|
||||||
|
config.record_auto_threshold_dbfs
|
||||||
|
} else {
|
||||||
|
-50.0
|
||||||
|
};
|
||||||
config.record_auto_threshold_dbfs = auto_threshold.clamp(-120.0, 20.0);
|
config.record_auto_threshold_dbfs = auto_threshold.clamp(-120.0, 20.0);
|
||||||
config.clock_led_color = normalize_ui_color(config.clock_led_color, "#ff0000");
|
config.clock_led_color = normalize_ui_color(config.clock_led_color, "#ff0000");
|
||||||
config.header_text_color = normalize_ui_color(config.header_text_color, "#ffe066");
|
config.header_text_color = normalize_ui_color(config.header_text_color, "#ffe066");
|
||||||
config.rta_bar_base_color = normalize_ui_color(config.rta_bar_base_color, "#ffe066");
|
config.rta_bar_base_color = normalize_ui_color(config.rta_bar_base_color, "#ffe066");
|
||||||
|
config.spectro_gamma = if config.spectro_gamma.is_finite() {
|
||||||
|
config.spectro_gamma.clamp(0.3, 1.2)
|
||||||
|
} else {
|
||||||
|
0.9
|
||||||
|
};
|
||||||
|
config.spectro_scroll_mode = match config.spectro_scroll_mode {
|
||||||
|
x if !x.is_finite() => 1.0,
|
||||||
|
x if (x - 0.5).abs() < f32::EPSILON => 0.5,
|
||||||
|
x if (x - 1.0).abs() < f32::EPSILON => 1.0,
|
||||||
|
x if (x - 2.0).abs() < f32::EPSILON => 2.0,
|
||||||
|
x if (x - 4.0).abs() < f32::EPSILON => 4.0,
|
||||||
|
x if (x - 6.0).abs() < f32::EPSILON => 6.0,
|
||||||
|
x if x < 0.75 => 0.5,
|
||||||
|
x if x < 1.5 => 1.0,
|
||||||
|
x if x < 3.0 => 2.0,
|
||||||
|
x if x < 5.0 => 4.0,
|
||||||
|
_ => 6.0,
|
||||||
|
};
|
||||||
config.peak_history_scroll_mode = match config.peak_history_scroll_mode {
|
config.peak_history_scroll_mode = match config.peak_history_scroll_mode {
|
||||||
x if !x.is_finite() => 1.0,
|
x if !x.is_finite() => 1.0,
|
||||||
x if (x - 0.5).abs() < f32::EPSILON => 0.5,
|
x if (x - 0.5).abs() < f32::EPSILON => 0.5,
|
||||||
@@ -482,9 +589,18 @@ fn normalize_global_config(mut config: PhoenixGlobalConfig, runtime: &PhoenixCon
|
|||||||
config.peak_history_fill_enabled = !!config.peak_history_fill_enabled;
|
config.peak_history_fill_enabled = !!config.peak_history_fill_enabled;
|
||||||
config.peak_history_fill_invert = !!config.peak_history_fill_invert;
|
config.peak_history_fill_invert = !!config.peak_history_fill_invert;
|
||||||
config.peak_history_slot = normalize_meter_slot(config.peak_history_slot, "ppm-din");
|
config.peak_history_slot = normalize_meter_slot(config.peak_history_slot, "ppm-din");
|
||||||
let phase_gain = if config.phase_display_gain_db.is_finite() { config.phase_display_gain_db } else { 0.0 };
|
let phase_gain = if config.phase_display_gain_db.is_finite() {
|
||||||
|
config.phase_display_gain_db
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
config.phase_display_gain_db = (phase_gain.clamp(-35.0, 35.0) / 5.0).round() * 5.0;
|
config.phase_display_gain_db = (phase_gain.clamp(-35.0, 35.0) / 5.0).round() * 5.0;
|
||||||
config.phase_amplitude_mode = match config.phase_amplitude_mode.trim().to_ascii_lowercase().as_str() {
|
config.phase_amplitude_mode = match config
|
||||||
|
.phase_amplitude_mode
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
"ppm-din" => "ppm-din".to_string(),
|
"ppm-din" => "ppm-din".to_string(),
|
||||||
_ => "bandpass".to_string(),
|
_ => "bandpass".to_string(),
|
||||||
};
|
};
|
||||||
@@ -513,9 +629,17 @@ fn normalize_global_config(mut config: PhoenixGlobalConfig, runtime: &PhoenixCon
|
|||||||
config.lufs_color_s = normalize_ui_color(config.lufs_color_s, "#ff3b3b");
|
config.lufs_color_s = normalize_ui_color(config.lufs_color_s, "#ff3b3b");
|
||||||
config.lufs_scale_label_color = normalize_ui_color(config.lufs_scale_label_color, "#8fd3d4");
|
config.lufs_scale_label_color = normalize_ui_color(config.lufs_scale_label_color, "#8fd3d4");
|
||||||
config.screensaver_enabled = !!config.screensaver_enabled;
|
config.screensaver_enabled = !!config.screensaver_enabled;
|
||||||
let idle = if config.screensaver_idle_min.is_finite() { config.screensaver_idle_min } else { 5.0 };
|
let idle = if config.screensaver_idle_min.is_finite() {
|
||||||
|
config.screensaver_idle_min
|
||||||
|
} else {
|
||||||
|
5.0
|
||||||
|
};
|
||||||
config.screensaver_idle_min = idle.clamp(0.0, 120.0);
|
config.screensaver_idle_min = idle.clamp(0.0, 120.0);
|
||||||
let activity = if config.screensaver_activity_db.is_finite() { config.screensaver_activity_db } else { -50.0 };
|
let activity = if config.screensaver_activity_db.is_finite() {
|
||||||
|
config.screensaver_activity_db
|
||||||
|
} else {
|
||||||
|
-50.0
|
||||||
|
};
|
||||||
config.screensaver_activity_db = activity.clamp(-120.0, 0.0);
|
config.screensaver_activity_db = activity.clamp(-120.0, 0.0);
|
||||||
config.screensaver_mode = match config.screensaver_mode.trim().to_ascii_lowercase().as_str() {
|
config.screensaver_mode = match config.screensaver_mode.trim().to_ascii_lowercase().as_str() {
|
||||||
"starfield" => "starfield".to_string(),
|
"starfield" => "starfield".to_string(),
|
||||||
@@ -525,11 +649,18 @@ fn normalize_global_config(mut config: PhoenixGlobalConfig, runtime: &PhoenixCon
|
|||||||
};
|
};
|
||||||
config.screensaver_led_glow = !!config.screensaver_led_glow;
|
config.screensaver_led_glow = !!config.screensaver_led_glow;
|
||||||
let color = config.screensaver_led_color.trim();
|
let color = config.screensaver_led_color.trim();
|
||||||
config.screensaver_led_color = if color.is_empty() { "#ff0000".to_string() } else { color.to_string() };
|
config.screensaver_led_color = if color.is_empty() {
|
||||||
|
"#ff0000".to_string()
|
||||||
|
} else {
|
||||||
|
color.to_string()
|
||||||
|
};
|
||||||
config
|
config
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_global_to_rta(mut config: PhoenixRtaConfig, global: &PhoenixGlobalConfig) -> PhoenixRtaConfig {
|
fn apply_global_to_rta(
|
||||||
|
mut config: PhoenixRtaConfig,
|
||||||
|
global: &PhoenixGlobalConfig,
|
||||||
|
) -> PhoenixRtaConfig {
|
||||||
config.fft_size = global.fft_size;
|
config.fft_size = global.fft_size;
|
||||||
config.bpo = global.rta_bpo_mode.clone();
|
config.bpo = global.rta_bpo_mode.clone();
|
||||||
config.input_offset_db_l = global.input_offset_db_l;
|
config.input_offset_db_l = global.input_offset_db_l;
|
||||||
@@ -544,6 +675,7 @@ fn apply_global_to_rta(mut config: PhoenixRtaConfig, global: &PhoenixGlobalConfi
|
|||||||
config.ppm_ebu_decay_db_per_s = global.ppm_ebu_decay_db_per_s;
|
config.ppm_ebu_decay_db_per_s = global.ppm_ebu_decay_db_per_s;
|
||||||
config.lufs_i_window_min = global.lufs_i_window_min;
|
config.lufs_i_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;
|
||||||
normalize_rta_config(config)
|
normalize_rta_config(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,7 +690,10 @@ fn load_global_config(runtime: &PhoenixConfig) -> PhoenixGlobalConfig {
|
|||||||
normalize_global_config(parsed, runtime)
|
normalize_global_config(parsed, runtime)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn persist_global_config(runtime: &PhoenixConfig, config: &PhoenixGlobalConfig) -> anyhow::Result<()> {
|
async fn persist_global_config(
|
||||||
|
runtime: &PhoenixConfig,
|
||||||
|
config: &PhoenixGlobalConfig,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
let path = runtime.global_config_path.clone();
|
let path = runtime.global_config_path.clone();
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent)
|
fs::create_dir_all(parent)
|
||||||
@@ -571,3 +706,42 @@ async fn persist_global_config(runtime: &PhoenixConfig, config: &PhoenixGlobalCo
|
|||||||
.with_context(|| format!("failed to write Phoenix config {}", path.display()))?;
|
.with_context(|| format!("failed to write Phoenix config {}", path.display()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rtw_profile_is_always_iir_third_octave() {
|
||||||
|
let config = normalize_rta_config(PhoenixRtaConfig {
|
||||||
|
engine: "fft".to_string(),
|
||||||
|
bpo: "1_12".to_string(),
|
||||||
|
order: 2,
|
||||||
|
freq_range: "lf".to_string(),
|
||||||
|
layout: "rtw".to_string(),
|
||||||
|
..PhoenixRtaConfig::default()
|
||||||
|
});
|
||||||
|
assert_eq!(config.engine, "iir");
|
||||||
|
assert_eq!(config.bpo, "1_3");
|
||||||
|
assert_eq!(config.order, 6);
|
||||||
|
assert_eq!(config.freq_range, "norm");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn phoenix_profile_retains_explicit_extensions() {
|
||||||
|
let config = normalize_rta_config(PhoenixRtaConfig {
|
||||||
|
engine: "fft".to_string(),
|
||||||
|
bpo: "1_12".to_string(),
|
||||||
|
order: 8,
|
||||||
|
freq_range: "lf".to_string(),
|
||||||
|
layout: "iec".to_string(),
|
||||||
|
integration: "medium".to_string(),
|
||||||
|
..PhoenixRtaConfig::default()
|
||||||
|
});
|
||||||
|
assert_eq!(config.engine, "fft");
|
||||||
|
assert_eq!(config.bpo, "1_12");
|
||||||
|
assert_eq!(config.order, 8);
|
||||||
|
assert_eq!(config.freq_range, "lf");
|
||||||
|
assert_eq!(config.integration, "medium");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+277
-32
@@ -2,14 +2,26 @@
|
|||||||
// 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, saveConfig } from './config.js';
|
||||||
|
import { decodePhoenixSpectroBuffer, decodePhoenixVisualsBuffer } from './binary_protocol.js';
|
||||||
import { getRtwCenters } from './rtw_centers.js';
|
import { getRtwCenters } from './rtw_centers.js';
|
||||||
|
|
||||||
// interner Zustand (pro App-Instanz)
|
// interner Zustand (pro App-Instanz)
|
||||||
let phoenixSocket = null;
|
let phoenixSocket = null;
|
||||||
|
let phoenixSpectroSocket = null;
|
||||||
|
let phoenixSpectroRetryTimer = null;
|
||||||
|
let pendingSpectroBuffer = null;
|
||||||
|
let phoenixSpectroRaf = 0;
|
||||||
|
let phoenixVisualsSocket = null;
|
||||||
|
let phoenixVisualsRetryTimer = null;
|
||||||
|
let pendingVisualsBuffer = null;
|
||||||
|
let phoenixVisualsRaf = 0;
|
||||||
let envRef = null;
|
let envRef = null;
|
||||||
let lifecycleHandlersBound = false;
|
let lifecycleHandlersBound = false;
|
||||||
let recoverTimer = null;
|
let recoverTimer = null;
|
||||||
let lastHardRecoverAt = 0;
|
let lastHardRecoverAt = 0;
|
||||||
|
let pendingPhoenixFrame = null;
|
||||||
|
let phoenixPacketRaf = 0;
|
||||||
|
let phoenixPacketBusy = false;
|
||||||
|
|
||||||
const RMS_RING = { L: new Float32Array(512), R: new Float32Array(512), i: 0, n: 0 };
|
const RMS_RING = { L: new Float32Array(512), R: new Float32Array(512), i: 0, n: 0 };
|
||||||
const WAVEFORM_RING_SECONDS = 20;
|
const WAVEFORM_RING_SECONDS = 20;
|
||||||
@@ -50,30 +62,65 @@ function normalizePhoenixBaseUrl(rawValue) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPhoenixWsUrl(baseUrl) {
|
function buildPhoenixWsUrl(baseUrl, pathname = '/api/v1/metrics/ws') {
|
||||||
try {
|
try {
|
||||||
const url = new URL(baseUrl);
|
const url = new URL(baseUrl);
|
||||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
url.pathname = '/api/v1/metrics/ws';
|
url.pathname = pathname;
|
||||||
url.search = '';
|
url.search = '';
|
||||||
url.hash = '';
|
url.hash = '';
|
||||||
return url.toString();
|
return url.toString();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
const fallback = defaultPhoenixBaseUrl();
|
const fallback = defaultPhoenixBaseUrl();
|
||||||
return fallback.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:') + '/api/v1/metrics/ws';
|
return fallback.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:') + pathname;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePhoenixSocket() {
|
function closePhoenixSocket() {
|
||||||
if (!phoenixSocket) return;
|
if (phoenixSocket) {
|
||||||
try {
|
try {
|
||||||
phoenixSocket.onopen = null;
|
phoenixSocket.onopen = null;
|
||||||
phoenixSocket.onmessage = null;
|
phoenixSocket.onmessage = null;
|
||||||
phoenixSocket.onerror = null;
|
phoenixSocket.onerror = null;
|
||||||
phoenixSocket.onclose = null;
|
phoenixSocket.onclose = null;
|
||||||
phoenixSocket.close();
|
phoenixSocket.close();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
}
|
||||||
phoenixSocket = null;
|
phoenixSocket = null;
|
||||||
|
if (phoenixSpectroRetryTimer) clearTimeout(phoenixSpectroRetryTimer);
|
||||||
|
phoenixSpectroRetryTimer = null;
|
||||||
|
if (phoenixSpectroSocket) {
|
||||||
|
try {
|
||||||
|
phoenixSpectroSocket.onopen = null;
|
||||||
|
phoenixSpectroSocket.onmessage = null;
|
||||||
|
phoenixSpectroSocket.onerror = null;
|
||||||
|
phoenixSpectroSocket.onclose = null;
|
||||||
|
phoenixSpectroSocket.close();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
phoenixSpectroSocket = null;
|
||||||
|
pendingSpectroBuffer = null;
|
||||||
|
if (phoenixSpectroRaf) cancelAnimationFrame(phoenixSpectroRaf);
|
||||||
|
phoenixSpectroRaf = 0;
|
||||||
|
if (phoenixVisualsRetryTimer) clearTimeout(phoenixVisualsRetryTimer);
|
||||||
|
phoenixVisualsRetryTimer = null;
|
||||||
|
if (phoenixVisualsSocket) {
|
||||||
|
try {
|
||||||
|
phoenixVisualsSocket.onopen = null;
|
||||||
|
phoenixVisualsSocket.onmessage = null;
|
||||||
|
phoenixVisualsSocket.onerror = null;
|
||||||
|
phoenixVisualsSocket.onclose = null;
|
||||||
|
phoenixVisualsSocket.close();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
phoenixVisualsSocket = null;
|
||||||
|
pendingVisualsBuffer = null;
|
||||||
|
if (phoenixVisualsRaf) cancelAnimationFrame(phoenixVisualsRaf);
|
||||||
|
phoenixVisualsRaf = 0;
|
||||||
|
pendingPhoenixFrame = null;
|
||||||
|
if (phoenixPacketRaf) cancelAnimationFrame(phoenixPacketRaf);
|
||||||
|
phoenixPacketRaf = 0;
|
||||||
|
phoenixPacketBusy = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestPhoenixRtaConfig(baseUrl, config) {
|
async function requestPhoenixRtaConfig(baseUrl, config) {
|
||||||
@@ -164,14 +211,20 @@ function applyRmsActivity(env, rmsL, rmsR, sampleTs) {
|
|||||||
if (hit) env.audio.lastSignalTs = sampleTs;
|
if (hit) env.audio.lastSignalTs = sampleTs;
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyPhoenixSpectroBins(audioState, spectro) {
|
function copyPhoenixSpectroBins(audioState, spectro, frameDelta = 1) {
|
||||||
if (!audioState) return;
|
if (!audioState) return false;
|
||||||
const src = Array.isArray(spectro?.bins) ? spectro.bins : null;
|
const src = Array.isArray(spectro?.bins) || ArrayBuffer.isView(spectro?.bins)
|
||||||
|
? spectro.bins
|
||||||
|
: null;
|
||||||
if (!src || !src.length) {
|
if (!src || !src.length) {
|
||||||
audioState.phoenixSpectroBuffer = null;
|
audioState.phoenixSpectroBuffer = null;
|
||||||
audioState.phoenixSpectroMeta = null;
|
audioState.phoenixSpectroMeta = null;
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
const sourceSeq = Number(spectro?.seq);
|
||||||
|
const previousSourceSeq = Number(audioState.phoenixSpectroSourceSeq || 0);
|
||||||
|
if (Number.isFinite(sourceSeq) && sourceSeq > 0
|
||||||
|
&& previousSourceSeq > 0 && sourceSeq <= previousSourceSeq) return false;
|
||||||
let target = audioState.phoenixSpectroBuffer;
|
let target = audioState.phoenixSpectroBuffer;
|
||||||
if (!(target instanceof Float32Array) || target.length !== src.length) {
|
if (!(target instanceof Float32Array) || target.length !== src.length) {
|
||||||
target = new Float32Array(src.length);
|
target = new Float32Array(src.length);
|
||||||
@@ -190,7 +243,118 @@ function copyPhoenixSpectroBins(audioState, spectro) {
|
|||||||
fftSize,
|
fftSize,
|
||||||
frequencyBinCount: target.length,
|
frequencyBinCount: target.length,
|
||||||
};
|
};
|
||||||
audioState.phoenixSpectroSeq = (audioState.phoenixSpectroSeq || 0) + 1;
|
let delta = Number.isFinite(Number(frameDelta)) ? Math.max(1, Math.floor(Number(frameDelta))) : 1;
|
||||||
|
if (Number.isFinite(sourceSeq) && sourceSeq > 0) {
|
||||||
|
if (previousSourceSeq > 0) delta = Math.max(1, Math.floor(sourceSeq - previousSourceSeq));
|
||||||
|
audioState.phoenixSpectroSourceSeq = sourceSeq;
|
||||||
|
}
|
||||||
|
audioState.phoenixSpectroSeq = (audioState.phoenixSpectroSeq || 0) + delta;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPhoenixSpectroSocket(baseUrl, env) {
|
||||||
|
if (phoenixSpectroSocket && (
|
||||||
|
phoenixSpectroSocket.readyState === WebSocket.OPEN
|
||||||
|
|| phoenixSpectroSocket.readyState === WebSocket.CONNECTING
|
||||||
|
)) return;
|
||||||
|
const socket = new WebSocket(buildPhoenixWsUrl(baseUrl, '/api/v1/spectro/ws'));
|
||||||
|
socket.binaryType = 'arraybuffer';
|
||||||
|
phoenixSpectroSocket = socket;
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
if (!(event.data instanceof ArrayBuffer)) return;
|
||||||
|
pendingSpectroBuffer = event.data;
|
||||||
|
scheduleSpectroBufferPump(env);
|
||||||
|
};
|
||||||
|
|
||||||
|
const retry = () => {
|
||||||
|
if (phoenixSpectroSocket === socket) phoenixSpectroSocket = null;
|
||||||
|
if (!phoenixSocket || phoenixSocket.readyState !== WebSocket.OPEN) return;
|
||||||
|
if (phoenixSpectroRetryTimer) clearTimeout(phoenixSpectroRetryTimer);
|
||||||
|
phoenixSpectroRetryTimer = setTimeout(() => {
|
||||||
|
phoenixSpectroRetryTimer = null;
|
||||||
|
openPhoenixSpectroSocket(baseUrl, env);
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
socket.onerror = retry;
|
||||||
|
socket.onclose = retry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSpectroBufferPump(env) {
|
||||||
|
if (phoenixSpectroRaf || !pendingSpectroBuffer) return;
|
||||||
|
phoenixSpectroRaf = requestAnimationFrame(() => {
|
||||||
|
phoenixSpectroRaf = 0;
|
||||||
|
const buffer = pendingSpectroBuffer;
|
||||||
|
pendingSpectroBuffer = null;
|
||||||
|
try {
|
||||||
|
const decoded = decodePhoenixSpectroBuffer(buffer);
|
||||||
|
if (!decoded) return;
|
||||||
|
if (!copyPhoenixSpectroBins(env.audio, decoded)) return;
|
||||||
|
env.requestRender?.('spectro');
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Phoenix spectrogram packet error:', err);
|
||||||
|
} finally {
|
||||||
|
if (pendingSpectroBuffer) scheduleSpectroBufferPump(env);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPhoenixVisualsSocket(baseUrl, env) {
|
||||||
|
if (phoenixVisualsSocket && (
|
||||||
|
phoenixVisualsSocket.readyState === WebSocket.OPEN
|
||||||
|
|| phoenixVisualsSocket.readyState === WebSocket.CONNECTING
|
||||||
|
)) return;
|
||||||
|
const socket = new WebSocket(buildPhoenixWsUrl(baseUrl, '/api/v1/visuals/ws'));
|
||||||
|
socket.binaryType = 'arraybuffer';
|
||||||
|
phoenixVisualsSocket = socket;
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
if (!(event.data instanceof ArrayBuffer)) return;
|
||||||
|
pendingVisualsBuffer = event.data;
|
||||||
|
scheduleVisualsBufferPump(env);
|
||||||
|
};
|
||||||
|
const retry = () => {
|
||||||
|
if (phoenixVisualsSocket === socket) phoenixVisualsSocket = null;
|
||||||
|
if (!phoenixSocket || phoenixSocket.readyState !== WebSocket.OPEN) return;
|
||||||
|
if (phoenixVisualsRetryTimer) clearTimeout(phoenixVisualsRetryTimer);
|
||||||
|
phoenixVisualsRetryTimer = setTimeout(() => {
|
||||||
|
phoenixVisualsRetryTimer = null;
|
||||||
|
openPhoenixVisualsSocket(baseUrl, env);
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
socket.onerror = retry;
|
||||||
|
socket.onclose = retry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleVisualsBufferPump(env) {
|
||||||
|
if (phoenixVisualsRaf || !pendingVisualsBuffer) return;
|
||||||
|
phoenixVisualsRaf = requestAnimationFrame(() => {
|
||||||
|
phoenixVisualsRaf = 0;
|
||||||
|
const buffer = pendingVisualsBuffer;
|
||||||
|
pendingVisualsBuffer = null;
|
||||||
|
try {
|
||||||
|
applyVisualsBuffer(env, buffer);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Phoenix visual packet error:', err);
|
||||||
|
} finally {
|
||||||
|
if (pendingVisualsBuffer) scheduleVisualsBufferPump(env);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyVisualsBuffer(env, buffer) {
|
||||||
|
const decoded = decodePhoenixVisualsBuffer(buffer);
|
||||||
|
if (!decoded) return;
|
||||||
|
const { seq, left, right, wave } = decoded;
|
||||||
|
const previousSeq = Number(env.audio?.phoenixVisualsSeq || 0);
|
||||||
|
if (previousSeq > 0 && seq <= previousSeq) return;
|
||||||
|
if (left && right) {
|
||||||
|
env.audio.xyL = left;
|
||||||
|
env.audio.xyR = right;
|
||||||
|
env.audio.xySeq = seq;
|
||||||
|
}
|
||||||
|
if (wave) updateWaveformEnvelopeStore(env.audio, wave);
|
||||||
|
env.audio.phoenixVisualsSeq = seq;
|
||||||
|
env.requestRender?.('visuals');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateActiveMeters(env, packet, CONFIG) {
|
async function updateActiveMeters(env, packet, CONFIG) {
|
||||||
@@ -230,21 +394,33 @@ async function syncPhoenixGlobalConfig(env, revision) {
|
|||||||
async function applyIncomingAudioPacket(env, packet, CONFIG, sampleTs = performance.now()) {
|
async function applyIncomingAudioPacket(env, packet, CONFIG, sampleTs = performance.now()) {
|
||||||
const d = packet || {};
|
const d = packet || {};
|
||||||
if (!env?.audio) return;
|
if (!env?.audio) return;
|
||||||
|
const seq = Number(d.seq);
|
||||||
|
const previousSeq = Number(env.audio.phoenixMetricsSeq || 0);
|
||||||
|
if (Number.isFinite(seq) && seq > 0 && previousSeq > 0 && seq <= previousSeq) return;
|
||||||
|
if (Number.isFinite(seq) && seq > 0) env.audio.phoenixMetricsSeq = seq;
|
||||||
env.requestRender?.('audio');
|
env.requestRender?.('audio');
|
||||||
env.audio.lastSampleTs = sampleTs;
|
env.audio.lastSampleTs = sampleTs;
|
||||||
env.audio.alive = true;
|
env.audio.alive = true;
|
||||||
|
if (Number.isFinite(Number(d.sampleRate)) && Number(d.sampleRate) > 0) {
|
||||||
|
env.audio.sampleRate = Number(d.sampleRate);
|
||||||
|
env.audio.nyq = Number(d.sampleRate) / 2;
|
||||||
|
}
|
||||||
if (d.xyL && d.xyR) {
|
if (d.xyL && d.xyR) {
|
||||||
env.audio.xyL = d.xyL;
|
env.audio.xyL = d.xyL;
|
||||||
env.audio.xyR = d.xyR;
|
env.audio.xyR = d.xyR;
|
||||||
env.audio.xySeq = Number.isFinite(d.seq) ? d.seq : (env.audio.xySeq || 0);
|
env.audio.xySeq = Number.isFinite(d.seq) ? d.seq : (env.audio.xySeq || 0);
|
||||||
}
|
}
|
||||||
|
if (typeof d.correlation === 'number') env.audio.correlation = d.correlation;
|
||||||
|
if (typeof d.correlationNegativePeak === 'number') {
|
||||||
|
env.audio.correlationNegativePeak = d.correlationNegativePeak;
|
||||||
|
}
|
||||||
if (d.waveL && env.audio.pushWaveSamples) {
|
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;
|
||||||
env.audio.pushWaveSamples(d.waveL, d.waveR || null, channelCount, d.sampleRate);
|
env.audio.pushWaveSamples(d.waveL, d.waveR || null, channelCount, d.sampleRate);
|
||||||
}
|
}
|
||||||
if (d.rta) env.audio.rtaData = d.rta;
|
if (d.rta) env.audio.rtaData = d.rta;
|
||||||
if (d.spectro) copyPhoenixSpectroBins(env.audio, d.spectro);
|
if (d.spectro) copyPhoenixSpectroBins(env.audio, d.spectro, d.spectroFrameDelta);
|
||||||
if (typeof d.ppmDinL === 'number') env.audio.ppmDinL = d.ppmDinL;
|
if (typeof d.ppmDinL === 'number') env.audio.ppmDinL = d.ppmDinL;
|
||||||
if (typeof d.ppmDinR === 'number') env.audio.ppmDinR = d.ppmDinR;
|
if (typeof d.ppmDinR === 'number') env.audio.ppmDinR = d.ppmDinR;
|
||||||
if (typeof d.ppmEbuL === 'number') env.audio.ppmEbuL = d.ppmEbuL;
|
if (typeof d.ppmEbuL === 'number') env.audio.ppmEbuL = d.ppmEbuL;
|
||||||
@@ -294,6 +470,8 @@ function buildPhoenixMeterPacket(frame) {
|
|||||||
const lufsSR = Number(frame?.lufs_sr);
|
const lufsSR = Number(frame?.lufs_sr);
|
||||||
const ppmBoxL = Number(frame?.ppm_box_l);
|
const ppmBoxL = Number(frame?.ppm_box_l);
|
||||||
const ppmBoxR = Number(frame?.ppm_box_r);
|
const ppmBoxR = Number(frame?.ppm_box_r);
|
||||||
|
const correlation = Number(frame?.correlation);
|
||||||
|
const correlationNegativePeak = Number(frame?.correlation_negative_peak);
|
||||||
const xyL = Array.isArray(frame?.xy_l) ? frame.xy_l : null;
|
const 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;
|
||||||
@@ -302,7 +480,11 @@ function buildPhoenixMeterPacket(frame) {
|
|||||||
const spectro = frame?.spectro && typeof frame.spectro === 'object' ? frame.spectro : null;
|
const spectro = frame?.spectro && typeof frame.spectro === 'object' ? frame.spectro : null;
|
||||||
const waveEnv = frame?.wave_env && typeof frame.wave_env === 'object' ? frame.wave_env : null;
|
const waveEnv = frame?.wave_env && typeof frame.wave_env === 'object' ? frame.wave_env : null;
|
||||||
return {
|
return {
|
||||||
sampleRate: 48000,
|
sampleRate: Number.isFinite(Number(frame?.sample_rate)) ? Number(frame.sample_rate) : 48000,
|
||||||
|
correlation: Number.isFinite(correlation) ? Math.max(-1, Math.min(1, correlation)) : 0,
|
||||||
|
correlationNegativePeak: Number.isFinite(correlationNegativePeak)
|
||||||
|
? Math.max(-1, Math.min(1, correlationNegativePeak))
|
||||||
|
: 0,
|
||||||
rmsL: Number.isFinite(rmsL) ? rmsL : -120,
|
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,
|
||||||
@@ -344,10 +526,14 @@ function buildPhoenixMeterPacket(frame) {
|
|||||||
sampleRate: Number.isFinite(Number(rta?.sample_rate)) ? Number(rta.sample_rate) : 48000,
|
sampleRate: Number.isFinite(Number(rta?.sample_rate)) ? Number(rta.sample_rate) : 48000,
|
||||||
} : null,
|
} : null,
|
||||||
spectro: spectro ? {
|
spectro: spectro ? {
|
||||||
|
seq: Number.isFinite(Number(spectro?.seq)) ? Number(spectro.seq) : 0,
|
||||||
bins: Array.isArray(spectro?.bins) ? spectro.bins : [],
|
bins: Array.isArray(spectro?.bins) ? spectro.bins : [],
|
||||||
sampleRate: Number.isFinite(Number(spectro?.sample_rate)) ? Number(spectro.sample_rate) : 48000,
|
sampleRate: Number.isFinite(Number(spectro?.sample_rate)) ? Number(spectro.sample_rate) : 48000,
|
||||||
fftSize: Number.isFinite(Number(spectro?.fft_size)) ? Number(spectro.fft_size) : 4096,
|
fftSize: Number.isFinite(Number(spectro?.fft_size)) ? Number(spectro.fft_size) : 4096,
|
||||||
} : null,
|
} : null,
|
||||||
|
spectroFrameDelta: Number.isFinite(Number(frame?.__spectro_frame_delta))
|
||||||
|
? Math.max(1, Math.floor(Number(frame.__spectro_frame_delta)))
|
||||||
|
: 1,
|
||||||
waveEnv: waveEnv ? {
|
waveEnv: waveEnv ? {
|
||||||
data: Array.isArray(waveEnv?.data) ? waveEnv.data : [],
|
data: Array.isArray(waveEnv?.data) ? waveEnv.data : [],
|
||||||
columns: Number.isFinite(Number(waveEnv?.columns)) ? Number(waveEnv.columns) : 0,
|
columns: Number.isFinite(Number(waveEnv?.columns)) ? Number(waveEnv.columns) : 0,
|
||||||
@@ -363,6 +549,57 @@ function buildPhoenixMeterPacket(frame) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergePendingPhoenixFrame(previous, next) {
|
||||||
|
if (!next) return previous;
|
||||||
|
const previousSeq = Number(previous?.seq || 0);
|
||||||
|
const nextSeq = Number(next?.seq || 0);
|
||||||
|
if (previous && previousSeq > 0 && nextSeq > 0 && nextSeq <= previousSeq) return previous;
|
||||||
|
if (!previous) {
|
||||||
|
if (next?.spectro) next.__spectro_frame_delta = 1;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
const merged = next;
|
||||||
|
if (!merged.wave_env && previous.wave_env) merged.wave_env = previous.wave_env;
|
||||||
|
if ((!merged.xy_l || !merged.xy_l.length) && previous.xy_l?.length) {
|
||||||
|
merged.xy_l = previous.xy_l;
|
||||||
|
merged.xy_r = previous.xy_r;
|
||||||
|
}
|
||||||
|
if (!merged.rta && previous.rta) merged.rta = previous.rta;
|
||||||
|
const previousSpectroCount = Math.max(0, Number(previous.__spectro_frame_delta) || 0);
|
||||||
|
if (merged.spectro) {
|
||||||
|
merged.__spectro_frame_delta = previousSpectroCount + 1;
|
||||||
|
} else if (previous.spectro) {
|
||||||
|
merged.spectro = previous.spectro;
|
||||||
|
merged.__spectro_frame_delta = Math.max(1, previousSpectroCount);
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePhoenixPacketPump(env, CONFIG) {
|
||||||
|
if (phoenixPacketRaf || phoenixPacketBusy || !pendingPhoenixFrame) return;
|
||||||
|
phoenixPacketRaf = requestAnimationFrame(async () => {
|
||||||
|
phoenixPacketRaf = 0;
|
||||||
|
const frame = pendingPhoenixFrame;
|
||||||
|
pendingPhoenixFrame = null;
|
||||||
|
if (!frame) return;
|
||||||
|
phoenixPacketBusy = true;
|
||||||
|
try {
|
||||||
|
const packet = buildPhoenixMeterPacket(frame);
|
||||||
|
await applyIncomingAudioPacket(env, packet, CONFIG, performance.now());
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Phoenix packet error:', err);
|
||||||
|
} finally {
|
||||||
|
phoenixPacketBusy = false;
|
||||||
|
if (pendingPhoenixFrame) schedulePhoenixPacketPump(env, CONFIG);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueuePhoenixFrame(env, CONFIG, frame) {
|
||||||
|
pendingPhoenixFrame = mergePendingPhoenixFrame(pendingPhoenixFrame, frame);
|
||||||
|
schedulePhoenixPacketPump(env, CONFIG);
|
||||||
|
}
|
||||||
|
|
||||||
function bindLifecycleHandlers() {
|
function bindLifecycleHandlers() {
|
||||||
if (lifecycleHandlersBound) return;
|
if (lifecycleHandlersBound) return;
|
||||||
|
|
||||||
@@ -625,33 +862,39 @@ function updateWaveformEnvelopeStore(audioState, payload) {
|
|||||||
appendWaveformEnvelope(audioState.waveEnvStore, payload);
|
appendWaveformEnvelope(audioState.waveEnvStore, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRtaRuntimeConfig(CONFIG = {}) {
|
export function buildRtaRuntimeConfig(CONFIG = {}) {
|
||||||
const bpoMode = CONFIG.RTA_BPO_MODE || '1_6';
|
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 runtimeBpoMode = layout === 'rtw' ? '1_12' : bpoMode;
|
const rtwProfile = layout === 'rtw';
|
||||||
|
const runtimeBpoMode = rtwProfile ? '1_3' : bpoMode;
|
||||||
return {
|
return {
|
||||||
engine: CONFIG.RTA_ENGINE || 'fft',
|
engine: rtwProfile ? 'iir' : (CONFIG.RTA_ENGINE || 'iir'),
|
||||||
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,
|
||||||
lrFractionalDelaySamples: Number.isFinite(CONFIG.LR_FRACTIONAL_DELAY_SAMPLES) ? CONFIG.LR_FRACTIONAL_DELAY_SAMPLES : 0,
|
lrFractionalDelaySamples: Number.isFinite(CONFIG.LR_FRACTIONAL_DELAY_SAMPLES) ? CONFIG.LR_FRACTIONAL_DELAY_SAMPLES : 0,
|
||||||
bpo: runtimeBpoMode,
|
bpo: runtimeBpoMode,
|
||||||
freqRange: CONFIG.RTA_FREQ_RANGE || 'norm',
|
freqRange: rtwProfile ? 'norm' : (CONFIG.RTA_FREQ_RANGE || 'norm'),
|
||||||
weighting: CONFIG.RTA_WEIGHTING || 'z',
|
weighting: CONFIG.RTA_WEIGHTING || 'z',
|
||||||
order: CONFIG.RTA_IIR_ORDER || 4,
|
order: rtwProfile ? 6 : (CONFIG.RTA_IIR_ORDER || 6),
|
||||||
tauFast: CONFIG.RTA_IIR_TAU_FAST || 0.12,
|
tauFast: CONFIG.RTA_IIR_TAU_FAST || 0.12,
|
||||||
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,
|
||||||
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: Number.isFinite(CONFIG.PPM_DIN_ATTACK_MS) ? CONFIG.PPM_DIN_ATTACK_MS : 5,
|
ppmDinAttackMs: 10,
|
||||||
ppmDinDecayDbPerS: Number.isFinite(CONFIG.PPM_DIN_DECAY_DB_PER_S) ? CONFIG.PPM_DIN_DECAY_DB_PER_S : (20 / 1.7),
|
ppmDinDecayDbPerS: 20 / 1.5,
|
||||||
ppmDinFastAttack: !!CONFIG.PPM_DIN_FAST_ATTACK,
|
ppmDinFastAttack: !!CONFIG.PPM_DIN_FAST_ATTACK,
|
||||||
ppmEbuAttackMs: Number.isFinite(CONFIG.PPM_EBU_ATTACK_MS) ? CONFIG.PPM_EBU_ATTACK_MS : 10,
|
ppmEbuAttackMs: 10,
|
||||||
ppmEbuDecayDbPerS: Number.isFinite(CONFIG.PPM_EBU_DECAY_DB_PER_S) ? CONFIG.PPM_EBU_DECAY_DB_PER_S : (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,
|
||||||
|
correlationResetToken: Math.max(0, Math.floor(Number(CONFIG.CORR_RESET_TOKEN) || 0)),
|
||||||
|
xyPoints: [128, 256, 512, 1024, 2048].includes(Number(CONFIG.XY_POINTS))
|
||||||
|
? Number(CONFIG.XY_POINTS)
|
||||||
|
: 1024,
|
||||||
rtwCenters: layout === 'rtw' ? getRtwCenters(runtimeBpoMode) : null,
|
rtwCenters: layout === 'rtw' ? getRtwCenters(runtimeBpoMode) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -700,6 +943,9 @@ async function initPhoenixAudio(env) {
|
|||||||
env.audio.phoenixSpectroScratch = null;
|
env.audio.phoenixSpectroScratch = null;
|
||||||
env.audio.phoenixSpectroMeta = null;
|
env.audio.phoenixSpectroMeta = null;
|
||||||
env.audio.phoenixSpectroSeq = 0;
|
env.audio.phoenixSpectroSeq = 0;
|
||||||
|
env.audio.phoenixSpectroSourceSeq = 0;
|
||||||
|
env.audio.phoenixVisualsSeq = 0;
|
||||||
|
env.audio.phoenixMetricsSeq = 0;
|
||||||
env.audio.rmsDb = { L: -120, R: -120, mono: -120 };
|
env.audio.rmsDb = { L: -120, R: -120, mono: -120 };
|
||||||
|
|
||||||
const phoenixAnalyser = {
|
const phoenixAnalyser = {
|
||||||
@@ -852,16 +1098,15 @@ async function initPhoenixAudio(env) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
|
openPhoenixSpectroSocket(baseUrl, env);
|
||||||
|
openPhoenixVisualsSocket(baseUrl, env);
|
||||||
finish(true);
|
finish(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.onmessage = (event) => {
|
socket.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const frame = JSON.parse(event.data);
|
const frame = JSON.parse(event.data);
|
||||||
const packet = buildPhoenixMeterPacket(frame);
|
enqueuePhoenixFrame(env, CONFIG, frame);
|
||||||
applyIncomingAudioPacket(env, packet, CONFIG, performance.now()).catch((err) => {
|
|
||||||
console.warn('Phoenix packet error:', err);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Phoenix metrics parse error:', err);
|
console.warn('Phoenix metrics parse error:', err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Phoenix binary WebSocket packet decoders. Keep this module dependency-free so
|
||||||
|
// protocol compatibility can be tested without a browser.
|
||||||
|
|
||||||
|
export function decodePhoenixSpectroBuffer(buffer) {
|
||||||
|
if (!(buffer instanceof ArrayBuffer) || buffer.byteLength < 20) return null;
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
if (view.getUint32(0, true) !== 0x50585350) return null;
|
||||||
|
const seq = view.getUint32(4, true);
|
||||||
|
const sampleRate = view.getUint32(8, true);
|
||||||
|
const fftSize = view.getUint32(12, true);
|
||||||
|
const count = view.getUint32(16, true);
|
||||||
|
if (count < 1 || buffer.byteLength !== 20 + count * 4) return null;
|
||||||
|
return {
|
||||||
|
seq,
|
||||||
|
sampleRate,
|
||||||
|
fftSize,
|
||||||
|
bins: new Float32Array(buffer, 20, count),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodePhoenixVisualsBuffer(buffer) {
|
||||||
|
if (!(buffer instanceof ArrayBuffer) || buffer.byteLength < 32) return null;
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
if (view.getUint32(0, true) !== 0x50585653 || view.getUint16(4, true) !== 1) return null;
|
||||||
|
const flags = view.getUint16(6, true);
|
||||||
|
const seq = view.getUint32(8, true);
|
||||||
|
const xyCount = view.getUint32(12, true);
|
||||||
|
const waveColumns = view.getUint32(16, true);
|
||||||
|
const waveChannels = view.getUint16(20, true);
|
||||||
|
const waveColumnSamples = view.getUint32(24, true);
|
||||||
|
const waveSampleRate = view.getUint32(28, true);
|
||||||
|
if (waveChannels > 2) return null;
|
||||||
|
const xyBytes = xyCount * 8;
|
||||||
|
const waveValues = waveColumns * waveChannels * 2;
|
||||||
|
const expectedBytes = 32 + xyBytes + waveValues * 4;
|
||||||
|
if (!Number.isSafeInteger(expectedBytes) || buffer.byteLength !== expectedBytes) return null;
|
||||||
|
|
||||||
|
let offset = 32;
|
||||||
|
let left = null;
|
||||||
|
let right = null;
|
||||||
|
if ((flags & 1) && xyCount > 0) {
|
||||||
|
left = new Float32Array(xyCount);
|
||||||
|
right = new Float32Array(xyCount);
|
||||||
|
for (let index = 0; index < xyCount; index++) {
|
||||||
|
left[index] = view.getFloat32(offset, true);
|
||||||
|
right[index] = view.getFloat32(offset + 4, true);
|
||||||
|
offset += 8;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
offset += xyBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
let wave = null;
|
||||||
|
if ((flags & 2) && waveValues > 0 && (waveChannels === 1 || waveChannels === 2)) {
|
||||||
|
const data = new Float32Array(waveValues);
|
||||||
|
for (let index = 0; index < waveValues; index++) {
|
||||||
|
data[index] = view.getFloat32(offset + index * 4, true);
|
||||||
|
}
|
||||||
|
wave = {
|
||||||
|
data,
|
||||||
|
columns: waveColumns,
|
||||||
|
channels: waveChannels,
|
||||||
|
columnSamples: waveColumnSamples,
|
||||||
|
sampleRate: waveSampleRate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { seq, left, right, wave };
|
||||||
|
}
|
||||||
+60
-25
@@ -51,20 +51,20 @@ const CONFIG = {
|
|||||||
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_FREQ_RANGE: 'norm', // 'norm' | 'lf'
|
RTA_FREQ_RANGE: 'norm', // 'norm' | 'lf'
|
||||||
RTA_BPO_MODE: '1_6',
|
RTA_BPO_MODE: '1_3',
|
||||||
RTA_WEIGHTING: 'z', // 'z' | 'a' | 'c'
|
RTA_WEIGHTING: 'z', // 'z' | 'a' | 'c'
|
||||||
RTA_DISPLAY_GAIN_FFT_DB: 20,
|
RTA_DISPLAY_GAIN_FFT_DB: 20,
|
||||||
RTA_DISPLAY_GAIN_IIR_DB: 20,
|
RTA_DISPLAY_GAIN_IIR_DB: 20,
|
||||||
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' | 'slow' | 'peak'
|
RTA_INTEGRATION: 'fast', // 'impulse' | 'fast' | 'medium' | 'slow' | 'average' | 'peak'
|
||||||
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: 'auto', // 'off' | 'auto' | 'manual'
|
||||||
RTA_PEAK_HOLD_SEC: 2,
|
RTA_PEAK_HOLD_SEC: 2.5,
|
||||||
RTA_PEAK_DECAY_DB_PER_S: 20,
|
RTA_PEAK_DECAY_DB_PER_S: 20,
|
||||||
RTA_DISPLAY_HOLD_SEC: 0,
|
RTA_DISPLAY_HOLD_SEC: 0,
|
||||||
RTA_IIR_ORDER: 4,
|
RTA_IIR_ORDER: 6,
|
||||||
RTA_IIR_TAU_FAST: 0.12,
|
RTA_IIR_TAU_FAST: 0.12,
|
||||||
RTA_IIR_TAU_SLOW: 1.0,
|
RTA_IIR_TAU_SLOW: 1.0,
|
||||||
SPECTRO_GAMMA: 0.4,
|
SPECTRO_GAMMA: 0.4,
|
||||||
@@ -156,20 +156,19 @@ const CONFIG = {
|
|||||||
PPM_DIN_TOP: +5,
|
PPM_DIN_TOP: +5,
|
||||||
PPM_DIN_BOTTOM: -50,
|
PPM_DIN_BOTTOM: -50,
|
||||||
PPM_DIN_RED_START: 0,
|
PPM_DIN_RED_START: 0,
|
||||||
// DIN PPM attack and decay times: 5 ms integration and 20 dB in 1.7 s
|
// Fixed RTW/DIN profile: 10 ms integration and 20 dB return in 1.5 s.
|
||||||
// (≈11.8 dB/s) return, per IEC 60268‑10 Type I.
|
PPM_DIN_ATTACK_MS: 10,
|
||||||
PPM_DIN_ATTACK_MS: 5,
|
|
||||||
// Non-normative: when enabled, PPM DIN attack becomes instant (no integration).
|
// Non-normative: when enabled, PPM DIN attack becomes instant (no integration).
|
||||||
// Useful to compensate perceived meter lag caused by device / pipeline latency.
|
// Useful to compensate perceived meter lag caused by device / pipeline latency.
|
||||||
PPM_DIN_FAST_ATTACK: false,
|
PPM_DIN_FAST_ATTACK: false,
|
||||||
PPM_DIN_DECAY_DB_PER_S: 11.8,
|
PPM_DIN_DECAY_DB_PER_S: 20 / 1.5,
|
||||||
// Hold time for DIN PPM: verlängert auf 1000 ms (1 s), damit der Peak‑Hold
|
// 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äß
|
||||||
// Rundfunkpraxis werden Peak‑Hold‑Striche häufig 1–2 Sekunden gehalten.
|
// Rundfunkpraxis werden Peak‑Hold‑Striche häufig 1–2 Sekunden gehalten.
|
||||||
PPM_DIN_HOLD_MS: 1000,
|
PPM_DIN_HOLD_MS: 1000,
|
||||||
// Decay rate for the DIN PPM hold. When the held peak decays, it uses
|
// Decay rate for the DIN PPM hold. When the held peak decays, it uses
|
||||||
// the same slope as the main meter (≈11.8 dB/s).
|
// the same slope as the main meter (≈13.33 dB/s).
|
||||||
PPM_DIN_HOLD_DECAY_DB_PER_S: 11.8,
|
PPM_DIN_HOLD_DECAY_DB_PER_S: 20 / 1.5,
|
||||||
|
|
||||||
// Offsets für die PPM-Meter (DIN/EBU-spezifisch). Die EBU/DIN Offsets
|
// Offsets für die PPM-Meter (DIN/EBU-spezifisch). Die EBU/DIN Offsets
|
||||||
// ersetzen den früheren globalen PPM_OFFSET.
|
// ersetzen den früheren globalen PPM_OFFSET.
|
||||||
@@ -178,16 +177,18 @@ const CONFIG = {
|
|||||||
PPM_REF_DBFS_PEAK_FOR_0_DBU: -15.0,
|
PPM_REF_DBFS_PEAK_FOR_0_DBU: -15.0,
|
||||||
|
|
||||||
// Ballistic parameters for the EBU PPM (Type IIb). Attack ~10 ms and
|
// Ballistic parameters for the EBU PPM (Type IIb). Attack ~10 ms and
|
||||||
// decay 24 dB in 2.8 s (≈8.6 dB/s), per IEC 60268‑10 Type II. A short
|
// decay 24 dB in 2.8 s (≈8.57 dB/s), per EBU Tech 3205-E Type IIb. A short
|
||||||
// peak-hold (~750 ms) helps visualise transients. Hold decay matches
|
// peak-hold (~750 ms) helps visualise transients. Hold decay matches
|
||||||
// the main decay rate.
|
// the main decay rate.
|
||||||
PPM_EBU_ATTACK_MS: 10,
|
PPM_EBU_ATTACK_MS: 10,
|
||||||
PPM_EBU_DECAY_DB_PER_S: 8.6,
|
PPM_EBU_DECAY_DB_PER_S: 24 / 2.8,
|
||||||
PPM_EBU_HOLD_MS: 750,
|
PPM_EBU_HOLD_MS: 750,
|
||||||
PPM_EBU_HOLD_DECAY_DB_PER_S: 8.6,
|
PPM_EBU_HOLD_DECAY_DB_PER_S: 24 / 2.8,
|
||||||
CORR_SMOOTH: 0.85,
|
CORR_SMOOTH: 0.85,
|
||||||
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_RESET_TOKEN: 0,
|
||||||
XY_POINTS: 1024,
|
XY_POINTS: 1024,
|
||||||
XY_STYLE: 'lines',
|
XY_STYLE: 'lines',
|
||||||
XY_SILENCE_GATE_ENABLED: true,
|
XY_SILENCE_GATE_ENABLED: true,
|
||||||
@@ -198,6 +199,7 @@ const CONFIG = {
|
|||||||
GONIO_MANUAL_GAIN_DB: -5,
|
GONIO_MANUAL_GAIN_DB: -5,
|
||||||
GONIO_AGC_ENABLED: false,
|
GONIO_AGC_ENABLED: false,
|
||||||
GONIO_LINE_FADE_MS: 50,
|
GONIO_LINE_FADE_MS: 50,
|
||||||
|
GONIO_PERSISTENCE_MODE: 'fast',
|
||||||
PHASE_DISPLAY_GAIN_DB: 0,
|
PHASE_DISPLAY_GAIN_DB: 0,
|
||||||
PHASE_AGC_ENABLED: false,
|
PHASE_AGC_ENABLED: false,
|
||||||
PHASE_TRAIL_ENABLED: true,
|
PHASE_TRAIL_ENABLED: true,
|
||||||
@@ -599,6 +601,8 @@ const PHOENIX_GLOBAL_OPTION_KEYS = Object.freeze([
|
|||||||
'CLOCK_LED_COLOR',
|
'CLOCK_LED_COLOR',
|
||||||
'HEADER_TEXT_COLOR',
|
'HEADER_TEXT_COLOR',
|
||||||
'RTA_BAR_BASE_COLOR',
|
'RTA_BAR_BASE_COLOR',
|
||||||
|
'SPECTRO_GAMMA',
|
||||||
|
'SPECTRO_SCROLL_MODE',
|
||||||
'PEAK_HISTORY_SCROLL_MODE',
|
'PEAK_HISTORY_SCROLL_MODE',
|
||||||
'PEAK_HISTORY_FILL_ENABLED',
|
'PEAK_HISTORY_FILL_ENABLED',
|
||||||
'PEAK_HISTORY_FILL_INVERT',
|
'PEAK_HISTORY_FILL_INVERT',
|
||||||
@@ -774,6 +778,14 @@ function loadConfig(opts = {}) {
|
|||||||
if (typeof saved.PPM_DIN_DECAY_DB_PER_S === 'number') CONFIG.PPM_DIN_DECAY_DB_PER_S = saved.PPM_DIN_DECAY_DB_PER_S;
|
if (typeof saved.PPM_DIN_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_ENGINE = 'iir';
|
||||||
|
CONFIG.RTA_BPO_MODE = '1_3';
|
||||||
|
CONFIG.RTA_IIR_ORDER = 6;
|
||||||
|
CONFIG.RTA_FREQ_RANGE = 'norm';
|
||||||
|
const hold = Number(CONFIG.RTA_PEAK_HOLD_SEC);
|
||||||
|
CONFIG.RTA_PEAK_HOLD_SEC = hold >= 3.25 ? 4.0 : 2.5;
|
||||||
|
}
|
||||||
if (!Number.isFinite(CONFIG.DBFS_TOP)) CONFIG.DBFS_TOP = CONFIG_DEFAULTS.DBFS_TOP;
|
if (!Number.isFinite(CONFIG.DBFS_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.
|
||||||
@@ -827,6 +839,14 @@ function loadConfig(opts = {}) {
|
|||||||
CONFIG.LR_FRACTIONAL_DELAY_SAMPLES = Math.max(-1.5, Math.min(1.5, Number(CONFIG.LR_FRACTIONAL_DELAY_SAMPLES)));
|
CONFIG.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;
|
CONFIG.PPM_DIN_FAST_ATTACK = !!CONFIG.PPM_DIN_FAST_ATTACK;
|
||||||
|
// Migrate stale profiles: detector ballistics are standards profiles,
|
||||||
|
// not user-adjustable display preferences.
|
||||||
|
CONFIG.PPM_DIN_ATTACK_MS = 10;
|
||||||
|
CONFIG.PPM_DIN_DECAY_DB_PER_S = 20 / 1.5;
|
||||||
|
CONFIG.PPM_DIN_HOLD_DECAY_DB_PER_S = 20 / 1.5;
|
||||||
|
CONFIG.PPM_EBU_ATTACK_MS = 10;
|
||||||
|
CONFIG.PPM_EBU_DECAY_DB_PER_S = 24 / 2.8;
|
||||||
|
CONFIG.PPM_EBU_HOLD_DECAY_DB_PER_S = 24 / 2.8;
|
||||||
CONFIG.HIFI_PEAK_ALIGNMENT = (CONFIG.HIFI_PEAK_ALIGNMENT === 'ppm_din_zero' || CONFIG.HIFI_PEAK_ALIGNMENT === 'dbfs_zero')
|
CONFIG.HIFI_PEAK_ALIGNMENT = (CONFIG.HIFI_PEAK_ALIGNMENT === 'ppm_din_zero' || CONFIG.HIFI_PEAK_ALIGNMENT === 'dbfs_zero')
|
||||||
? 'ppm_din_zero'
|
? 'ppm_din_zero'
|
||||||
: 'ppm_din_minus5';
|
: 'ppm_din_minus5';
|
||||||
@@ -969,6 +989,10 @@ function buildPhoenixGlobalConfigPayload() {
|
|||||||
if (raw === 'stopwatch') return 'stopwatch';
|
if (raw === 'stopwatch') return 'stopwatch';
|
||||||
return fallback;
|
return fallback;
|
||||||
};
|
};
|
||||||
|
const normalizeSpectroScroll = (value) => {
|
||||||
|
const number = Number(value);
|
||||||
|
return [0.5, 1, 2, 4, 6].includes(number) ? number : 1;
|
||||||
|
};
|
||||||
const normalizePeakHistoryScroll = (value) => {
|
const normalizePeakHistoryScroll = (value) => {
|
||||||
const allowed = new Set([0.5, 1, 2, 4, 6]);
|
const allowed = new Set([0.5, 1, 2, 4, 6]);
|
||||||
const num = Number(value);
|
const num = Number(value);
|
||||||
@@ -990,11 +1014,11 @@ function buildPhoenixGlobalConfigPayload() {
|
|||||||
monoInput: !!CONFIG.MONO_INPUT,
|
monoInput: !!CONFIG.MONO_INPUT,
|
||||||
lrFractionalDelayEnabled: !!CONFIG.LR_FRACTIONAL_DELAY_ENABLED,
|
lrFractionalDelayEnabled: !!CONFIG.LR_FRACTIONAL_DELAY_ENABLED,
|
||||||
lrFractionalDelaySamples: Number.isFinite(CONFIG.LR_FRACTIONAL_DELAY_SAMPLES) ? CONFIG.LR_FRACTIONAL_DELAY_SAMPLES : (CONFIG_DEFAULTS.LR_FRACTIONAL_DELAY_SAMPLES ?? 0.996),
|
lrFractionalDelaySamples: Number.isFinite(CONFIG.LR_FRACTIONAL_DELAY_SAMPLES) ? CONFIG.LR_FRACTIONAL_DELAY_SAMPLES : (CONFIG_DEFAULTS.LR_FRACTIONAL_DELAY_SAMPLES ?? 0.996),
|
||||||
ppmDinAttackMs: Number.isFinite(CONFIG.PPM_DIN_ATTACK_MS) ? CONFIG.PPM_DIN_ATTACK_MS : (CONFIG_DEFAULTS.PPM_DIN_ATTACK_MS ?? 5),
|
ppmDinAttackMs: 10,
|
||||||
ppmDinDecayDbPerS: Number.isFinite(CONFIG.PPM_DIN_DECAY_DB_PER_S) ? CONFIG.PPM_DIN_DECAY_DB_PER_S : (CONFIG_DEFAULTS.PPM_DIN_DECAY_DB_PER_S ?? 11.8),
|
ppmDinDecayDbPerS: 20 / 1.5,
|
||||||
ppmDinFastAttack: !!CONFIG.PPM_DIN_FAST_ATTACK,
|
ppmDinFastAttack: !!CONFIG.PPM_DIN_FAST_ATTACK,
|
||||||
ppmEbuAttackMs: Number.isFinite(CONFIG.PPM_EBU_ATTACK_MS) ? CONFIG.PPM_EBU_ATTACK_MS : (CONFIG_DEFAULTS.PPM_EBU_ATTACK_MS ?? 10),
|
ppmEbuAttackMs: 10,
|
||||||
ppmEbuDecayDbPerS: Number.isFinite(CONFIG.PPM_EBU_DECAY_DB_PER_S) ? CONFIG.PPM_EBU_DECAY_DB_PER_S : (CONFIG_DEFAULTS.PPM_EBU_DECAY_DB_PER_S ?? 8.6),
|
ppmEbuDecayDbPerS: 24 / 2.8,
|
||||||
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,
|
||||||
@@ -1016,6 +1040,8 @@ function buildPhoenixGlobalConfigPayload() {
|
|||||||
clockLedColor: normalizeColor(CONFIG.CLOCK_LED_COLOR, CONFIG_DEFAULTS.CLOCK_LED_COLOR || '#ff0000'),
|
clockLedColor: normalizeColor(CONFIG.CLOCK_LED_COLOR, CONFIG_DEFAULTS.CLOCK_LED_COLOR || '#ff0000'),
|
||||||
headerTextColor: normalizeColor(CONFIG.HEADER_TEXT_COLOR, CONFIG_DEFAULTS.HEADER_TEXT_COLOR || '#ffe066'),
|
headerTextColor: normalizeColor(CONFIG.HEADER_TEXT_COLOR, CONFIG_DEFAULTS.HEADER_TEXT_COLOR || '#ffe066'),
|
||||||
rtaBarBaseColor: normalizeColor(CONFIG.RTA_BAR_BASE_COLOR, CONFIG_DEFAULTS.RTA_BAR_BASE_COLOR || '#ffe066'),
|
rtaBarBaseColor: normalizeColor(CONFIG.RTA_BAR_BASE_COLOR, CONFIG_DEFAULTS.RTA_BAR_BASE_COLOR || '#ffe066'),
|
||||||
|
spectroGamma: Number.isFinite(CONFIG.SPECTRO_GAMMA) ? Math.max(0.3, Math.min(1.2, CONFIG.SPECTRO_GAMMA)) : 0.9,
|
||||||
|
spectroScrollMode: normalizeSpectroScroll(CONFIG.SPECTRO_SCROLL_MODE),
|
||||||
peakHistoryScrollMode: normalizePeakHistoryScroll(CONFIG.PEAK_HISTORY_SCROLL_MODE),
|
peakHistoryScrollMode: normalizePeakHistoryScroll(CONFIG.PEAK_HISTORY_SCROLL_MODE),
|
||||||
peakHistoryFillEnabled: !!CONFIG.PEAK_HISTORY_FILL_ENABLED,
|
peakHistoryFillEnabled: !!CONFIG.PEAK_HISTORY_FILL_ENABLED,
|
||||||
peakHistoryFillInvert: !!CONFIG.PEAK_HISTORY_FILL_INVERT,
|
peakHistoryFillInvert: !!CONFIG.PEAK_HISTORY_FILL_INVERT,
|
||||||
@@ -1063,6 +1089,10 @@ function applyPhoenixGlobalConfig(payload = {}) {
|
|||||||
if (raw === 'stopwatch') return 'stopwatch';
|
if (raw === 'stopwatch') return 'stopwatch';
|
||||||
return fallback;
|
return fallback;
|
||||||
};
|
};
|
||||||
|
const normalizeSpectroScroll = (value) => {
|
||||||
|
const number = Number(value);
|
||||||
|
return [0.5, 1, 2, 4, 6].includes(number) ? number : 1;
|
||||||
|
};
|
||||||
const fft = Number(payload.fftSize);
|
const fft = Number(payload.fftSize);
|
||||||
if (Number.isFinite(fft)) {
|
if (Number.isFinite(fft)) {
|
||||||
CONFIG.FFT_SIZE = (fft === 2048 || fft === 4096 || fft === 8192 || fft === 16384)
|
CONFIG.FFT_SIZE = (fft === 2048 || fft === 4096 || fft === 8192 || fft === 16384)
|
||||||
@@ -1073,15 +1103,20 @@ function applyPhoenixGlobalConfig(payload = {}) {
|
|||||||
const mode = String(payload.rtaBpoMode || '').trim();
|
const mode = String(payload.rtaBpoMode || '').trim();
|
||||||
CONFIG.RTA_BPO_MODE = (mode === '1_3' || mode === '1_6' || mode === '1_12') ? mode : '1_6';
|
CONFIG.RTA_BPO_MODE = (mode === '1_3' || mode === '1_6' || mode === '1_12') ? mode : '1_6';
|
||||||
}
|
}
|
||||||
const dinAttack = Number(payload.ppmDinAttackMs);
|
if (CONFIG.RTA_BAR_LAYOUT === 'rtw') {
|
||||||
if (Number.isFinite(dinAttack)) CONFIG.PPM_DIN_ATTACK_MS = Math.max(0.1, Math.min(100, dinAttack));
|
CONFIG.RTA_ENGINE = 'iir';
|
||||||
const dinDecay = Number(payload.ppmDinDecayDbPerS);
|
CONFIG.RTA_BPO_MODE = '1_3';
|
||||||
if (Number.isFinite(dinDecay)) CONFIG.PPM_DIN_DECAY_DB_PER_S = Math.max(0.1, Math.min(120, dinDecay));
|
CONFIG.RTA_IIR_ORDER = 6;
|
||||||
|
CONFIG.RTA_FREQ_RANGE = 'norm';
|
||||||
|
}
|
||||||
|
const spectroGamma = Number(payload.spectroGamma);
|
||||||
|
if (Number.isFinite(spectroGamma)) CONFIG.SPECTRO_GAMMA = Math.max(0.3, Math.min(1.2, spectroGamma));
|
||||||
|
if ('spectroScrollMode' in payload) CONFIG.SPECTRO_SCROLL_MODE = normalizeSpectroScroll(payload.spectroScrollMode);
|
||||||
|
CONFIG.PPM_DIN_ATTACK_MS = 10;
|
||||||
|
CONFIG.PPM_DIN_DECAY_DB_PER_S = 20 / 1.5;
|
||||||
if ('ppmDinFastAttack' in payload) CONFIG.PPM_DIN_FAST_ATTACK = !!payload.ppmDinFastAttack;
|
if ('ppmDinFastAttack' in payload) CONFIG.PPM_DIN_FAST_ATTACK = !!payload.ppmDinFastAttack;
|
||||||
const ebuAttack = Number(payload.ppmEbuAttackMs);
|
CONFIG.PPM_EBU_ATTACK_MS = 10;
|
||||||
if (Number.isFinite(ebuAttack)) CONFIG.PPM_EBU_ATTACK_MS = Math.max(0.1, Math.min(100, ebuAttack));
|
CONFIG.PPM_EBU_DECAY_DB_PER_S = 24 / 2.8;
|
||||||
const ebuDecay = Number(payload.ppmEbuDecayDbPerS);
|
|
||||||
if (Number.isFinite(ebuDecay)) CONFIG.PPM_EBU_DECAY_DB_PER_S = Math.max(0.1, Math.min(120, ebuDecay));
|
|
||||||
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;
|
||||||
|
|||||||
+5
-3
@@ -307,7 +307,7 @@
|
|||||||
<label>Bänder pro Oktave</label>
|
<label>Bänder pro Oktave</label>
|
||||||
<select id="opt_rtaBpo" style="width:150px">
|
<select id="opt_rtaBpo" style="width:150px">
|
||||||
<option value="1_3">1/3 Oktave</option>
|
<option value="1_3">1/3 Oktave</option>
|
||||||
<option value="1_6" selected>1/6 Oktave</option>
|
<option value="1_6">1/6 Oktave</option>
|
||||||
<option value="1_12">1/12 Oktave</option>
|
<option value="1_12">1/12 Oktave</option>
|
||||||
</select>
|
</select>
|
||||||
<small>Filterauflösung – Anzahl Bänder</small>
|
<small>Filterauflösung – Anzahl Bänder</small>
|
||||||
@@ -355,7 +355,9 @@
|
|||||||
<select id="opt_rtaIntegration" style="width:150px">
|
<select id="opt_rtaIntegration" style="width:150px">
|
||||||
<option value="impulse">Impulse</option>
|
<option value="impulse">Impulse</option>
|
||||||
<option value="fast" selected>Fast</option>
|
<option value="fast" selected>Fast</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
<option value="slow">Slow</option>
|
<option value="slow">Slow</option>
|
||||||
|
<option value="average">Average</option>
|
||||||
<option value="peak">Peak (10 ms)</option>
|
<option value="peak">Peak (10 ms)</option>
|
||||||
</select>
|
</select>
|
||||||
<small>Reaktionsmodus</small>
|
<small>Reaktionsmodus</small>
|
||||||
@@ -1024,7 +1026,7 @@
|
|||||||
<input id="opt_ppmDinFastAttack" type="checkbox">
|
<input id="opt_ppmDinFastAttack" type="checkbox">
|
||||||
PPM DIN: Fast‑Attack (sofort, nicht normgerecht)
|
PPM DIN: Fast‑Attack (sofort, nicht normgerecht)
|
||||||
</label>
|
</label>
|
||||||
<small>Umgeht die 5‑ms‑Integration und lässt die Anzeige sofort ansteigen (kann gefühltes Anzeige‑Lag durch Latenz reduzieren).</small>
|
<small>Umgeht die feste 10-ms-Normintegration und zeigt Sample-Peaks sofort an (kann gefühltes Anzeige-Lag reduzieren).</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="opt">
|
<div class="opt">
|
||||||
<label class="row" style="align-items:center;gap:8px">
|
<label class="row" style="align-items:center;gap:8px">
|
||||||
@@ -1841,7 +1843,7 @@
|
|||||||
<div id="ppmDinFastWarn" class="rec-warning" style="display:none">
|
<div id="ppmDinFastWarn" class="rec-warning" style="display:none">
|
||||||
<div class="rec-warning-box">
|
<div class="rec-warning-box">
|
||||||
<h3>PPM DIN – Fast‑Attack</h3>
|
<h3>PPM DIN – Fast‑Attack</h3>
|
||||||
<p>Dieser Modus lässt das PPM DIN sofort ansteigen (ohne 5‑ms‑Integration).</p>
|
<p>Dieser Sample-Peak-Sondermodus lässt das PPM DIN sofort ansteigen (ohne 10-ms-Normintegration).</p>
|
||||||
<p>Dadurch kann die Anzeige „schneller“ wirken, ist aber <b>nicht mehr normgerecht</b> (DIN 45406 / IEC 60268‑10).</p>
|
<p>Dadurch kann die Anzeige „schneller“ wirken, ist aber <b>nicht mehr normgerecht</b> (DIN 45406 / IEC 60268‑10).</p>
|
||||||
<p>Hinweis: Das ändert nur die Anzeige‑Ballistik und kompensiert keine echte Audio‑Latenz.</p>
|
<p>Hinweis: Das ändert nur die Anzeige‑Ballistik und kompensiert keine echte Audio‑Latenz.</p>
|
||||||
<button id="ppmDinFastWarnBtn" class="btn btn-lg" type="button">Verstanden</button>
|
<button id="ppmDinFastWarnBtn" class="btn btn-lg" type="button">Verstanden</button>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { HEADER_BG, LABEL_COLOR, MID_COLOR, WARN_COLOR } from '../core/theme.js'
|
|||||||
|
|
||||||
export const id = 'ppm-din';
|
export const id = 'ppm-din';
|
||||||
|
|
||||||
const DEFAULT_DECAY_DB_PER_S = 20 / 1.7; // 20 dB in ca. 1.7 s → ~11.76 dB/s
|
const DEFAULT_DECAY_DB_PER_S = 20 / 1.5; // RTW/DIN: 20 dB in 1.5 s
|
||||||
const DEFAULT_HOLD_MS = 1000;
|
const DEFAULT_HOLD_MS = 1000;
|
||||||
|
|
||||||
const DIN_SCALE = [
|
const DIN_SCALE = [
|
||||||
@@ -79,7 +79,7 @@ export function initShared(CONFIG = {}) {
|
|||||||
},
|
},
|
||||||
// Offset for DIN uses PPM_DIN_OFFSET so that 0 dB on the meter aligns
|
// Offset for DIN uses PPM_DIN_OFFSET so that 0 dB on the meter aligns
|
||||||
// properly with the Permitted Maximum Level (PML). According to the
|
// properly with the Permitted Maximum Level (PML). According to the
|
||||||
// DIN Type I specification, 0 dBu (≈−15 dBFS peak) should read −9 dB.
|
// selected alignment profile maps 0 dBu to either -6 dB or legacy -9 dB.
|
||||||
offset: Number(CONFIG.PPM_DIN_OFFSET) || 0,
|
offset: Number(CONFIG.PPM_DIN_OFFSET) || 0,
|
||||||
refDbfsFor0: Number.isFinite(CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU)
|
refDbfsFor0: Number.isFinite(CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU)
|
||||||
? CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU
|
? CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { drawCachedStaticLayer } from './static_layer.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';
|
||||||
|
|
||||||
// meters/ppm_ebu.js — PPM (EBU) mit linearer Skala
|
// meters/ppm_ebu.js — PPM (EBU) mit linearer Skala
|
||||||
// - Messbereich: -14 … +14 dB (Clamping/Anzeigegrenzen)
|
// - Messbereich: -12 … +12 dB
|
||||||
// - Rote Zone ab +9 dB
|
// - Rote Zone ab +9 dB
|
||||||
// - Ticks: große Marken bei -12/-8/-4/0/+4/+8/+12, kleine u. a. bei -10/-6/…/+10
|
// - Ticks: große Marken bei -12/-8/-4/0/+4/+8/+12, kleine u. a. bei -10/-6/…/+10
|
||||||
// - Labels: nur für die großen Ticks; bei 0 statt "0" → "TEST"
|
// - Labels: nur für die großen Ticks; bei 0 statt "0" → "TEST"
|
||||||
@@ -12,7 +12,7 @@ import { HEADER_BG, LABEL_COLOR, MID_COLOR, WARN_COLOR } from '../core/theme.js'
|
|||||||
export const id = 'ppm-ebu';
|
export const id = 'ppm-ebu';
|
||||||
|
|
||||||
const DEFAULT_ATTACK_MS = 10; // Type IIb typisch ~10 ms
|
const DEFAULT_ATTACK_MS = 10; // Type IIb typisch ~10 ms
|
||||||
const DEFAULT_DECAY_DB_PER_S = 8.6; // ~24 dB in 2.8 s
|
const DEFAULT_DECAY_DB_PER_S = 24 / 2.8;
|
||||||
const DEFAULT_HOLD_MS = 750;
|
const DEFAULT_HOLD_MS = 750;
|
||||||
|
|
||||||
const EBU_MAJOR_TICKS = [-12, -8, -4, 0, +4, +8, +12];
|
const EBU_MAJOR_TICKS = [-12, -8, -4, 0, +4, +8, +12];
|
||||||
@@ -21,10 +21,10 @@ const EBU_MINOR_TICKS = [-10, -6, -2, +2, +6, +9, +10];
|
|||||||
export function initShared(CONFIG = {}) {
|
export function initShared(CONFIG = {}) {
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
// Use EBU-specific configuration values. PPM_EBU_BOTTOM defines the
|
// Use EBU-specific configuration values. PPM_EBU_BOTTOM defines the
|
||||||
// bottom of the EBU scale (default -14 dB) and PPM_EBU_HOLD_MS defines
|
// bottom of the EBU scale (default -12 dB) and PPM_EBU_HOLD_MS defines
|
||||||
// the peak-hold duration. This ensures the EBU meter aligns with
|
// the peak-hold duration. This ensures the EBU meter aligns with
|
||||||
// the Type IIb specification (0 dB at AL, +9 dB red start).
|
// the Type IIb specification (0 dB at AL, +9 dB red start).
|
||||||
const bottom = Number.isFinite(CONFIG.PPM_EBU_BOTTOM) ? CONFIG.PPM_EBU_BOTTOM : -14;
|
const bottom = Number.isFinite(CONFIG.PPM_EBU_BOTTOM) ? CONFIG.PPM_EBU_BOTTOM : -12;
|
||||||
const holdMs = Number.isFinite(CONFIG.PPM_EBU_HOLD_MS) ? CONFIG.PPM_EBU_HOLD_MS : DEFAULT_HOLD_MS;
|
const holdMs = Number.isFinite(CONFIG.PPM_EBU_HOLD_MS) ? CONFIG.PPM_EBU_HOLD_MS : DEFAULT_HOLD_MS;
|
||||||
const decayCfg = Number.isFinite(CONFIG.PPM_EBU_DECAY_DB_PER_S)
|
const decayCfg = Number.isFinite(CONFIG.PPM_EBU_DECAY_DB_PER_S)
|
||||||
? CONFIG.PPM_EBU_DECAY_DB_PER_S
|
? CONFIG.PPM_EBU_DECAY_DB_PER_S
|
||||||
@@ -40,7 +40,7 @@ export function initShared(CONFIG = {}) {
|
|||||||
},
|
},
|
||||||
_ballistics: {
|
_ballistics: {
|
||||||
// The EBU (Type IIb) PPM uses a ~10 ms attack and a decay of 24 dB
|
// The EBU (Type IIb) PPM uses a ~10 ms attack and a decay of 24 dB
|
||||||
// in 2.8 s (≈8.6 dB/s).
|
// in 2.8 s (≈8.57 dB/s).
|
||||||
attackMs: Number.isFinite(CONFIG.PPM_EBU_ATTACK_MS) ? CONFIG.PPM_EBU_ATTACK_MS : DEFAULT_ATTACK_MS,
|
attackMs: Number.isFinite(CONFIG.PPM_EBU_ATTACK_MS) ? CONFIG.PPM_EBU_ATTACK_MS : DEFAULT_ATTACK_MS,
|
||||||
decayDbPerS: decayCfg,
|
decayDbPerS: decayCfg,
|
||||||
},
|
},
|
||||||
@@ -83,8 +83,8 @@ export function draw(g, rect, CONFIG = {}, shared) {
|
|||||||
const __effOff = Number(CONFIG.PPM_EBU_OFFSET) || 0;
|
const __effOff = Number(CONFIG.PPM_EBU_OFFSET) || 0;
|
||||||
const __offCorr = __effOff - (shared.offset || 0);
|
const __offCorr = __effOff - (shared.offset || 0);
|
||||||
|
|
||||||
const PPM_TOP = Number.isFinite(CONFIG.PPM_EBU_TOP) ? CONFIG.PPM_EBU_TOP : +14;
|
const PPM_TOP = Number.isFinite(CONFIG.PPM_EBU_TOP) ? CONFIG.PPM_EBU_TOP : +12;
|
||||||
const PPM_BOTTOM = Number.isFinite(CONFIG.PPM_EBU_BOTTOM) ? CONFIG.PPM_EBU_BOTTOM : -14;
|
const PPM_BOTTOM = Number.isFinite(CONFIG.PPM_EBU_BOTTOM) ? CONFIG.PPM_EBU_BOTTOM : -12;
|
||||||
const RED_START = Number.isFinite(CONFIG.PPM_EBU_RED_START) ? CONFIG.PPM_EBU_RED_START : +9;
|
const RED_START = Number.isFinite(CONFIG.PPM_EBU_RED_START) ? CONFIG.PPM_EBU_RED_START : +9;
|
||||||
|
|
||||||
const redOnly = CONFIG.PPM_RED_BAR_ONLY !== false;
|
const redOnly = CONFIG.PPM_RED_BAR_ONLY !== false;
|
||||||
@@ -271,7 +271,7 @@ function drawPpmEbuStaticOverlay(g, shared, rect, CONFIG, geom, mapY, PPM_TOP, P
|
|||||||
topPad,
|
topPad,
|
||||||
CONFIG.METER_BAR_THIN || 0.55,
|
CONFIG.METER_BAR_THIN || 0.55,
|
||||||
CONFIG.PPM_EBU_TOP ?? 14,
|
CONFIG.PPM_EBU_TOP ?? 14,
|
||||||
CONFIG.PPM_EBU_BOTTOM ?? -14,
|
CONFIG.PPM_EBU_BOTTOM ?? -12,
|
||||||
CONFIG.PPM_EBU_RED_START ?? 9,
|
CONFIG.PPM_EBU_RED_START ?? 9,
|
||||||
CONFIG.AL_MARKERS_ENABLED === false ? 0 : 1,
|
CONFIG.AL_MARKERS_ENABLED === false ? 0 : 1,
|
||||||
METER_HEADER_FONT,
|
METER_HEADER_FONT,
|
||||||
|
|||||||
+46
-6
@@ -290,7 +290,7 @@ function syncUI() {
|
|||||||
['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'],
|
||||||
['opt_rtaHoldTime', CONFIG.RTA_PEAK_HOLD_SEC ?? 2],
|
['opt_rtaHoldTime', CONFIG.RTA_PEAK_HOLD_SEC ?? 2.5],
|
||||||
['opt_rtaDecay', CONFIG.RTA_PEAK_DECAY_DB_PER_S ?? 20],
|
['opt_rtaDecay', CONFIG.RTA_PEAK_DECAY_DB_PER_S ?? 20],
|
||||||
['opt_rtaDisplayHold', CONFIG.RTA_DISPLAY_HOLD_SEC ?? 0],
|
['opt_rtaDisplayHold', CONFIG.RTA_DISPLAY_HOLD_SEC ?? 0],
|
||||||
['opt_spectroGamma', CONFIG.SPECTRO_GAMMA ?? 0.9, 'val_spectroGamma', (v)=>Number(v).toFixed(2)],
|
['opt_spectroGamma', CONFIG.SPECTRO_GAMMA ?? 0.9, 'val_spectroGamma', (v)=>Number(v).toFixed(2)],
|
||||||
@@ -1054,13 +1054,23 @@ function wireHandlers(env) {
|
|||||||
}, null, null, true);
|
}, null, null, true);
|
||||||
|
|
||||||
// Hold-/Decay-Parameter
|
// Hold-/Decay-Parameter
|
||||||
h('opt_rtaEngine', v => { CONFIG.RTA_ENGINE = (v === 'iir') ? 'iir' : 'fft'; });
|
h('opt_rtaEngine', v => {
|
||||||
|
CONFIG.RTA_ENGINE = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'iir' : ((v === 'iir') ? 'iir' : 'fft');
|
||||||
|
return CONFIG.RTA_ENGINE;
|
||||||
|
});
|
||||||
h('opt_rtaBpo', v => {
|
h('opt_rtaBpo', v => {
|
||||||
const allowed = new Set(['1_3', '1_6', '1_12']);
|
const allowed = new Set(['1_3', '1_6', '1_12']);
|
||||||
CONFIG.RTA_BPO_MODE = allowed.has(v) ? v : '1_6';
|
CONFIG.RTA_BPO_MODE = CONFIG.RTA_BAR_LAYOUT === 'rtw'
|
||||||
|
? '1_3'
|
||||||
|
: (allowed.has(v) ? v : '1_6');
|
||||||
notifyPhoenixGlobalConfig();
|
notifyPhoenixGlobalConfig();
|
||||||
|
return CONFIG.RTA_BPO_MODE;
|
||||||
});
|
});
|
||||||
h('opt_rtaOrder', v => {
|
h('opt_rtaOrder', v => {
|
||||||
|
if (CONFIG.RTA_BAR_LAYOUT === 'rtw') {
|
||||||
|
CONFIG.RTA_IIR_ORDER = 6;
|
||||||
|
return 6;
|
||||||
|
}
|
||||||
let val = Math.round(+v);
|
let val = Math.round(+v);
|
||||||
if (!Number.isFinite(val)) val = 4;
|
if (!Number.isFinite(val)) val = 4;
|
||||||
val = Math.max(2, Math.min(8, val));
|
val = Math.max(2, Math.min(8, val));
|
||||||
@@ -1068,12 +1078,33 @@ function wireHandlers(env) {
|
|||||||
CONFIG.RTA_IIR_ORDER = val;
|
CONFIG.RTA_IIR_ORDER = val;
|
||||||
return val;
|
return val;
|
||||||
});
|
});
|
||||||
h('opt_rtaFreq', v => { CONFIG.RTA_FREQ_RANGE = (v === 'lf') ? 'lf' : 'norm'; });
|
h('opt_rtaFreq', v => {
|
||||||
|
CONFIG.RTA_FREQ_RANGE = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'norm' : ((v === 'lf') ? 'lf' : 'norm');
|
||||||
|
return CONFIG.RTA_FREQ_RANGE;
|
||||||
|
});
|
||||||
h('opt_rtaDisplayGainFft', v => { CONFIG.RTA_DISPLAY_GAIN_FFT_DB = clamp(+v, -25, 25); }, 'val_rtaDisplayGainFft', v=>`${v} dB`);
|
h('opt_rtaDisplayGainFft', v => { CONFIG.RTA_DISPLAY_GAIN_FFT_DB = clamp(+v, -25, 25); }, 'val_rtaDisplayGainFft', v=>`${v} dB`);
|
||||||
h('opt_rtaDisplayGainIir', v => { CONFIG.RTA_DISPLAY_GAIN_IIR_DB = clamp(+v, -25, 25); }, 'val_rtaDisplayGainIir', v=>`${v} dB`);
|
h('opt_rtaDisplayGainIir', v => { CONFIG.RTA_DISPLAY_GAIN_IIR_DB = clamp(+v, -25, 25); }, 'val_rtaDisplayGainIir', v=>`${v} dB`);
|
||||||
h('opt_rtaLayout', v => {
|
h('opt_rtaLayout', v => {
|
||||||
const val = (v === 'rtw') ? 'rtw' : 'iec';
|
const val = (v === 'rtw') ? 'rtw' : 'iec';
|
||||||
CONFIG.RTA_BAR_LAYOUT = val;
|
CONFIG.RTA_BAR_LAYOUT = val;
|
||||||
|
if (val === 'rtw') {
|
||||||
|
CONFIG.RTA_ENGINE = 'iir';
|
||||||
|
CONFIG.RTA_BPO_MODE = '1_3';
|
||||||
|
CONFIG.RTA_IIR_ORDER = 6;
|
||||||
|
CONFIG.RTA_FREQ_RANGE = 'norm';
|
||||||
|
const forced = {
|
||||||
|
opt_rtaEngine: 'iir',
|
||||||
|
opt_rtaBpo: '1_3',
|
||||||
|
opt_rtaOrder: '6',
|
||||||
|
opt_rtaFreq: 'norm',
|
||||||
|
};
|
||||||
|
for (const [id, value] of Object.entries(forced)) {
|
||||||
|
const control = document.getElementById(id);
|
||||||
|
if (control) control.value = value;
|
||||||
|
}
|
||||||
|
notifyPhoenixGlobalConfig();
|
||||||
|
}
|
||||||
|
return val;
|
||||||
});
|
});
|
||||||
h('opt_rtaBarBaseColor', v => {
|
h('opt_rtaBarBaseColor', v => {
|
||||||
CONFIG.RTA_BAR_BASE_COLOR = v || '#ffe066';
|
CONFIG.RTA_BAR_BASE_COLOR = v || '#ffe066';
|
||||||
@@ -1085,8 +1116,9 @@ function wireHandlers(env) {
|
|||||||
CONFIG.RTA_WEIGHTING = allowed.has(v) ? v : 'z';
|
CONFIG.RTA_WEIGHTING = allowed.has(v) ? v : 'z';
|
||||||
});
|
});
|
||||||
h('opt_rtaIntegration', v => {
|
h('opt_rtaIntegration', v => {
|
||||||
const allowed = new Set(['impulse','fast','slow','peak']);
|
const allowed = new Set(['impulse','fast','medium','slow','average','peak']);
|
||||||
CONFIG.RTA_INTEGRATION = allowed.has(v) ? v : 'fast';
|
CONFIG.RTA_INTEGRATION = allowed.has(v) ? v : 'fast';
|
||||||
|
return CONFIG.RTA_INTEGRATION;
|
||||||
});
|
});
|
||||||
h('opt_rtaBallistics', v => {
|
h('opt_rtaBallistics', v => {
|
||||||
const val = (v === 'peak' || v === 'both' || v === 'average') ? v : 'average';
|
const val = (v === 'peak' || v === 'both' || v === 'average') ? v : 'average';
|
||||||
@@ -1097,12 +1129,19 @@ function wireHandlers(env) {
|
|||||||
const allowed = new Set(['off','auto','manual']);
|
const allowed = new Set(['off','auto','manual']);
|
||||||
CONFIG.RTA_PEAK_HOLD_MODE = allowed.has(v) ? v : 'auto';
|
CONFIG.RTA_PEAK_HOLD_MODE = allowed.has(v) ? v : 'auto';
|
||||||
});
|
});
|
||||||
h('opt_rtaHoldTime', v => { CONFIG.RTA_PEAK_HOLD_SEC = clamp(+v, 0, 30); });
|
h('opt_rtaHoldTime', v => {
|
||||||
|
const requested = clamp(+v, 0, 30);
|
||||||
|
CONFIG.RTA_PEAK_HOLD_SEC = CONFIG.RTA_BAR_LAYOUT === 'rtw'
|
||||||
|
? (requested < 3.25 ? 2.5 : 4.0)
|
||||||
|
: requested;
|
||||||
|
return CONFIG.RTA_PEAK_HOLD_SEC;
|
||||||
|
});
|
||||||
h('opt_rtaDecay', v => { CONFIG.RTA_PEAK_DECAY_DB_PER_S = clamp(+v, 1, 60); });
|
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);
|
||||||
CONFIG.SPECTRO_GAMMA = num;
|
CONFIG.SPECTRO_GAMMA = num;
|
||||||
|
notifyPhoenixGlobalConfig();
|
||||||
return num;
|
return num;
|
||||||
}, 'val_spectroGamma', (v)=>Number(v).toFixed(2));
|
}, 'val_spectroGamma', (v)=>Number(v).toFixed(2));
|
||||||
h('opt_spectroScroll', v => {
|
h('opt_spectroScroll', v => {
|
||||||
@@ -1110,6 +1149,7 @@ function wireHandlers(env) {
|
|||||||
const num = Number(v);
|
const num = Number(v);
|
||||||
const val = allowed.has(num) ? num : 1;
|
const val = allowed.has(num) ? num : 1;
|
||||||
CONFIG.SPECTRO_SCROLL_MODE = val;
|
CONFIG.SPECTRO_SCROLL_MODE = val;
|
||||||
|
notifyPhoenixGlobalConfig();
|
||||||
return val;
|
return val;
|
||||||
});
|
});
|
||||||
h('opt_peakHistScroll', v => {
|
h('opt_peakHistScroll', v => {
|
||||||
|
|||||||
+21
-112
@@ -16,8 +16,6 @@ const METER_SLOT_SHRINK = 24;
|
|||||||
const METER_PAD_TOP = 1;
|
const METER_PAD_TOP = 1;
|
||||||
const METER_PAD_BOTTOM = -7;
|
const METER_PAD_BOTTOM = -7;
|
||||||
const METER_EXTRA_BOTTOM_PAD = 6;
|
const METER_EXTRA_BOTTOM_PAD = 6;
|
||||||
const CORR_ATTACK_S = 1.5;
|
|
||||||
const CORR_RELEASE_S = 2.5;
|
|
||||||
const GONIO_GAIN_MIN_DB = -35;
|
const GONIO_GAIN_MIN_DB = -35;
|
||||||
const GONIO_GAIN_MAX_DB = 35;
|
const GONIO_GAIN_MAX_DB = 35;
|
||||||
const BASE_TARGET = 1.0;
|
const BASE_TARGET = 1.0;
|
||||||
@@ -26,8 +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_SILENCE_HOLD_MS = 30000;
|
const MAX_TRAIL_FRAMES = 48;
|
||||||
const CORR_SILENCE_DRIFT_MS = 60000;
|
|
||||||
|
|
||||||
function computePanelSlotWidth(canvasWidth, slotCount = METER_SLOTS, slotGap = 12) {
|
function computePanelSlotWidth(canvasWidth, slotCount = METER_SLOTS, slotGap = 12) {
|
||||||
const n = Math.max(1, Math.min(METER_SLOTS, slotCount | 0));
|
const n = Math.max(1, Math.min(METER_SLOTS, slotCount | 0));
|
||||||
@@ -39,11 +36,6 @@ function computePanelSlotWidth(canvasWidth, slotCount = METER_SLOTS, slotGap = 1
|
|||||||
|
|
||||||
export function init() {
|
export function init() {
|
||||||
return {
|
return {
|
||||||
corrDisplayed: 0,
|
|
||||||
corrMeter: 0,
|
|
||||||
corrLastTs: 0,
|
|
||||||
corrLastValid: 0,
|
|
||||||
corrSilenceSince: 0,
|
|
||||||
gateLevel: 1,
|
gateLevel: 1,
|
||||||
gateLastTs: 0,
|
gateLastTs: 0,
|
||||||
agcEnv: 1e-3,
|
agcEnv: 1e-3,
|
||||||
@@ -51,6 +43,7 @@ export function init() {
|
|||||||
agcLastTs: 0,
|
agcLastTs: 0,
|
||||||
traceBuffer: new Float32Array(0),
|
traceBuffer: new Float32Array(0),
|
||||||
lineTrails: [],
|
lineTrails: [],
|
||||||
|
trailPool: [],
|
||||||
staticLayerCanvas: null,
|
staticLayerCanvas: null,
|
||||||
staticLayerCtx: null,
|
staticLayerCtx: null,
|
||||||
staticLayerKey: '',
|
staticLayerKey: '',
|
||||||
@@ -66,13 +59,7 @@ export async function render(env, state) {
|
|||||||
|
|
||||||
const wakeTs = Number.isFinite(env?.screensaverWakeTs) ? Number(env.screensaverWakeTs) : 0;
|
const wakeTs = Number.isFinite(env?.screensaverWakeTs) ? Number(env.screensaverWakeTs) : 0;
|
||||||
if (wakeTs && state?._lastWakeTs !== wakeTs) {
|
if (wakeTs && state?._lastWakeTs !== wakeTs) {
|
||||||
// Nach Screensaver-Wake: Korrelation sauber zurücksetzen, damit kein "alter" Wert (z.B. +1) stehen bleibt.
|
|
||||||
state._lastWakeTs = wakeTs;
|
state._lastWakeTs = wakeTs;
|
||||||
state.corrDisplayed = 0;
|
|
||||||
state.corrMeter = 0;
|
|
||||||
state.corrLastTs = 0;
|
|
||||||
state.corrLastValid = 0;
|
|
||||||
state.corrSilenceSince = frameNow;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const slotsRaw = env.slots?.(id) || ['vu', 'ppm-ebu', 'tp'];
|
const slotsRaw = env.slots?.(id) || ['vu', 'ppm-ebu', 'tp'];
|
||||||
@@ -101,62 +88,20 @@ export async function render(env, state) {
|
|||||||
: null;
|
: null;
|
||||||
renderTrace(g, state, trace, layout.scope, style, xyReady && gate.active, settings);
|
renderTrace(g, state, trace, layout.scope, style, xyReady && gate.active, settings);
|
||||||
|
|
||||||
const silenceGateEnabled = CONFIG?.XY_SILENCE_GATE_ENABLED !== false;
|
// Correlation is measured continuously in the audio DSP. Do not add a
|
||||||
const corrThreshold = resolveCorrThreshold(CONFIG);
|
// second, frame-rate-dependent browser integration here.
|
||||||
const rmsL = Number.isFinite(env.audio?.rmsDb?.L) ? env.audio.rmsDb.L : -120;
|
const corrVisual = Number.isFinite(audio?.correlation) ? clamp1(audio.correlation) : 0;
|
||||||
const rmsR = Number.isFinite(env.audio?.rmsDb?.R) ? env.audio.rmsDb.R : -120;
|
const corrNegativePeak = Number.isFinite(audio?.correlationNegativePeak)
|
||||||
const activeL = audioFresh && (!silenceGateEnabled || (rmsL >= corrThreshold));
|
? clamp1(audio.correlationNegativePeak)
|
||||||
const activeR = audioFresh && (!silenceGateEnabled || (rmsR >= corrThreshold));
|
: 0;
|
||||||
|
|
||||||
let corrTarget = 0;
|
|
||||||
const canCorr = xyReady && utils?.correlation;
|
|
||||||
const zeroOnSilence = !!CONFIG?.CORR_ZERO_ON_SILENCE;
|
|
||||||
const bothSilent = !activeL && !activeR;
|
|
||||||
|
|
||||||
if (activeL && activeR && canCorr) {
|
|
||||||
// Beide Kanäle aktiv → echte Korrelation aus L/R-Samples
|
|
||||||
const corrRaw = utils.correlation(
|
|
||||||
xyData.xyL,
|
|
||||||
xyData.xyR,
|
|
||||||
Number.isFinite(state.corrDisplayed) ? state.corrDisplayed : 0,
|
|
||||||
CONFIG.CORR_SMOOTH,
|
|
||||||
);
|
|
||||||
if (Number.isFinite(corrRaw)) {
|
|
||||||
state.corrDisplayed = corrRaw;
|
|
||||||
state.corrLastValid = corrRaw;
|
|
||||||
state.corrSilenceSince = 0;
|
|
||||||
corrTarget = corrRaw;
|
|
||||||
}
|
|
||||||
} else if (activeL !== activeR) {
|
|
||||||
// Nur ein Kanal aktiv → hart auf 0, keine Hold-/Decay-Logik
|
|
||||||
state.corrSilenceSince = 0;
|
|
||||||
corrTarget = 0;
|
|
||||||
} else {
|
|
||||||
if (zeroOnSilence && bothSilent) {
|
|
||||||
// Beide still → zügig Richtung 0 (ohne Hold/Drift)
|
|
||||||
state.corrLastValid = 0;
|
|
||||||
state.corrSilenceSince = frameNow;
|
|
||||||
corrTarget = 0;
|
|
||||||
} else {
|
|
||||||
// Beide still → Hold + Drift zur Mitte
|
|
||||||
if (!state.corrSilenceSince) state.corrSilenceSince = frameNow;
|
|
||||||
corrTarget = resolveCorrTarget(state, frameNow);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fastZero = zeroOnSilence && bothSilent;
|
|
||||||
const corrVisual = updateCorrelationDisplay(state, corrTarget, frameNow, fastZero ? true : gate.active);
|
|
||||||
if (fastZero) {
|
|
||||||
// Damit die nächste echte Korrelation nicht noch an einem alten Glättungswert "hängt".
|
|
||||||
state.corrDisplayed = corrVisual;
|
|
||||||
}
|
|
||||||
drawCorrelationBar(
|
drawCorrelationBar(
|
||||||
g,
|
g,
|
||||||
layout.plot.x + layout.plot.w / 2,
|
layout.plot.x + layout.plot.w / 2,
|
||||||
layout.plot.y + layout.plot.h - 28,
|
layout.plot.y + layout.plot.h - 28,
|
||||||
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,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (slots.length) {
|
if (slots.length) {
|
||||||
@@ -755,52 +700,6 @@ function getNow() {
|
|||||||
return Date.now();
|
return Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCorrelationDisplay(state, target, nowTs, gateActive = true) {
|
|
||||||
const prev = Number.isFinite(state.corrMeter) ? state.corrMeter : target;
|
|
||||||
const lastTs = Number.isFinite(state.corrLastTs) ? state.corrLastTs : nowTs;
|
|
||||||
const dt = Math.max(0, (nowTs - lastTs) / 1000);
|
|
||||||
if (!dt) {
|
|
||||||
state.corrMeter = clamp1(target);
|
|
||||||
state.corrLastTs = nowTs;
|
|
||||||
return state.corrMeter;
|
|
||||||
}
|
|
||||||
const rising = Math.abs(target) > Math.abs(prev);
|
|
||||||
// Schneller einrasten, wenn Gate aktiv ist; etwas zäher auf Null auslaufen, wenn Stille.
|
|
||||||
const tauAttack = gateActive ? 0.08 : CORR_ATTACK_S;
|
|
||||||
const tauRelease = gateActive ? 0.35 : CORR_RELEASE_S;
|
|
||||||
const tau = rising ? tauAttack : tauRelease;
|
|
||||||
const alpha = 1 - Math.exp(-dt / Math.max(0.001, tau));
|
|
||||||
const next = clamp1(prev + (target - prev) * alpha);
|
|
||||||
state.corrMeter = next;
|
|
||||||
state.corrLastTs = nowTs;
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveCorrThreshold(CONFIG = {}) {
|
|
||||||
if (Number.isFinite(CONFIG.XY_SILENCE_THRESHOLD_RMS_DBFS)) {
|
|
||||||
return CONFIG.XY_SILENCE_THRESHOLD_RMS_DBFS;
|
|
||||||
}
|
|
||||||
if (Number.isFinite(CONFIG.CORR_SILENCE_THRESHOLD_RMS_DBFS)) {
|
|
||||||
return CONFIG.CORR_SILENCE_THRESHOLD_RMS_DBFS;
|
|
||||||
}
|
|
||||||
return CORR_SILENCE_THRESHOLD_DEFAULT;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveCorrTarget(state, nowTs) {
|
|
||||||
const lastValid = Number.isFinite(state.corrLastValid) ? state.corrLastValid : 0;
|
|
||||||
if (!state.corrSilenceSince) {
|
|
||||||
return lastValid;
|
|
||||||
}
|
|
||||||
const holdMs = CORR_SILENCE_HOLD_MS;
|
|
||||||
const driftMs = CORR_SILENCE_DRIFT_MS;
|
|
||||||
const elapsed = Math.max(0, nowTs - state.corrSilenceSince);
|
|
||||||
if (elapsed <= holdMs) {
|
|
||||||
return lastValid;
|
|
||||||
}
|
|
||||||
const t = Math.min(1, (elapsed - holdMs) / Math.max(1, driftMs));
|
|
||||||
return lastValid * (1 - t);
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeAgcGain(state, xyData, nowTs) {
|
function computeAgcGain(state, xyData, nowTs) {
|
||||||
const attackTau = 0.001; // 1 ms
|
const attackTau = 0.001; // 1 ms
|
||||||
const releaseDbPerS = 10;
|
const releaseDbPerS = 10;
|
||||||
@@ -857,7 +756,7 @@ function dbToLinear(db) {
|
|||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Correlation bar helper
|
// Correlation bar helper
|
||||||
|
|
||||||
function drawCorrelationBar(g, centerX, y, w, h, val) {
|
function drawCorrelationBar(g, centerX, y, w, h, val, negativePeak) {
|
||||||
const x = Math.floor(centerX - w / 2);
|
const x = Math.floor(centerX - w / 2);
|
||||||
g.save();
|
g.save();
|
||||||
g.strokeStyle = FRAME_COLOR; g.lineWidth = 2; g.strokeRect(x, y, w, h);
|
g.strokeStyle = FRAME_COLOR; g.lineWidth = 2; g.strokeRect(x, y, w, h);
|
||||||
@@ -886,6 +785,16 @@ function drawCorrelationBar(g, centerX, y, w, h, val) {
|
|||||||
g.lineWidth = 1;
|
g.lineWidth = 1;
|
||||||
g.strokeRect(cubeX, cubeY, cubeSize, cubeSize);
|
g.strokeRect(cubeX, cubeY, cubeSize, cubeSize);
|
||||||
g.beginPath(); g.moveTo(mid, y); g.lineTo(mid, y + h); g.stroke();
|
g.beginPath(); g.moveTo(mid, y); g.lineTo(mid, y + h); g.stroke();
|
||||||
|
if (Number.isFinite(negativePeak) && negativePeak < 0.999) {
|
||||||
|
const markerX = clampCenter(mid + (clamp1(negativePeak) * 0.5) * w);
|
||||||
|
g.fillStyle = WARN_COLOR;
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(markerX, y - 1);
|
||||||
|
g.lineTo(markerX - 5, y - 7);
|
||||||
|
g.lineTo(markerX + 5, y - 7);
|
||||||
|
g.closePath();
|
||||||
|
g.fill();
|
||||||
|
}
|
||||||
g.restore();
|
g.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-8
@@ -71,14 +71,15 @@ export async function render(env, state) {
|
|||||||
const nyq = audio.nyq || 24000;
|
const nyq = audio.nyq || 24000;
|
||||||
const analyser = audio.getAnalyser?.();
|
const analyser = audio.getAnalyser?.();
|
||||||
const buf = audio.getFreqBuffer?.();
|
const buf = audio.getFreqBuffer?.();
|
||||||
const useIirEngine = (CONFIG.RTA_ENGINE || 'fft') === 'iir';
|
|
||||||
const rtaData = typeof audio.getRtaData === 'function'
|
const rtaData = typeof audio.getRtaData === 'function'
|
||||||
? audio.getRtaData()
|
? audio.getRtaData()
|
||||||
: null;
|
: null;
|
||||||
|
const useIirEngine = rtaData?.engine === 'iir'
|
||||||
|
|| ((CONFIG.RTA_ENGINE || 'fft') === 'iir');
|
||||||
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, CONFIG.RTA_BPO_MODE || '1_6')
|
? selectLocalRtwPacket(nativeRtaPacket, 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;
|
||||||
@@ -177,9 +178,9 @@ export async function render(env, state) {
|
|||||||
} else {
|
} else {
|
||||||
const integrated = baseLevels;
|
const integrated = baseLevels;
|
||||||
const displayBase = applyDisplayHold(state, integrated, CONFIG, range);
|
const displayBase = applyDisplayHold(state, integrated, CONFIG, range);
|
||||||
const display = (CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'bars'
|
// Native IIR values already contain the selected power-domain
|
||||||
? applyRealtimeBarBallistics(state, displayBase, CONFIG, range)
|
// integration. A second browser attack/hold stage would falsify it.
|
||||||
: displayBase;
|
const display = displayBase;
|
||||||
applyPeakHold(state, integrated, CONFIG, range);
|
applyPeakHold(state, integrated, CONFIG, range);
|
||||||
state.displayLevels = display;
|
state.displayLevels = display;
|
||||||
state.currentRange = range;
|
state.currentRange = range;
|
||||||
@@ -320,10 +321,11 @@ function resetState(state) {
|
|||||||
|
|
||||||
function ensureBands(state, utils, CONFIG, nyq, binCount, freqBounds, range, rtaPacket) {
|
function ensureBands(state, utils, CONFIG, nyq, binCount, freqBounds, range, rtaPacket) {
|
||||||
const freqRange = CONFIG.RTA_FREQ_RANGE === 'lf' ? 'lf' : 'norm';
|
const freqRange = CONFIG.RTA_FREQ_RANGE === 'lf' ? 'lf' : 'norm';
|
||||||
const bpo = CONFIG.RTA_BPO_MODE || '1_6';
|
|
||||||
const engine = CONFIG.RTA_ENGINE || 'fft';
|
|
||||||
const layoutMode = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'rtw' : 'iec';
|
const layoutMode = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'rtw' : 'iec';
|
||||||
const rtaCenters = (layoutMode === 'rtw') ? getRtwCenters(bpo) : null;
|
const bpo = layoutMode === 'rtw' ? (rtaPacket?.bpo || '1_3') : (CONFIG.RTA_BPO_MODE || '1_6');
|
||||||
|
const engine = rtaPacket?.engine || CONFIG.RTA_ENGINE || 'fft';
|
||||||
|
const packetCenters = isVectorLike(rtaPacket?.centers) ? Array.from(rtaPacket.centers) : null;
|
||||||
|
const rtaCenters = (layoutMode === 'rtw') ? (packetCenters || getRtwCenters(bpo)) : null;
|
||||||
const centerKey = rtaCenters
|
const centerKey = rtaCenters
|
||||||
? `${rtaCenters.length}:${rtaCenters[0]}:${rtaCenters[rtaCenters.length - 1]}`
|
? `${rtaCenters.length}:${rtaCenters[0]}:${rtaCenters[rtaCenters.length - 1]}`
|
||||||
: 'static';
|
: 'static';
|
||||||
|
|||||||
+124
-33
@@ -15,6 +15,9 @@ const SMOOTHING_MAX = 0.2;
|
|||||||
const TIME_GRID_SPACING = 100;
|
const TIME_GRID_SPACING = 100;
|
||||||
const SPECTROGRAM_ID = 'spectrogram';
|
const SPECTROGRAM_ID = 'spectrogram';
|
||||||
const SPECTRO_RENDER_SCALE = 0.85;
|
const SPECTRO_RENDER_SCALE = 0.85;
|
||||||
|
const BASE_SCROLL_CSS_PX_PER_SECOND = 60;
|
||||||
|
const WORKER_WATCHDOG_MS = 750;
|
||||||
|
const MAX_SCROLL_COLUMNS_PER_DRAW = 64;
|
||||||
const SUPPORTS_WORKER = typeof window !== 'undefined'
|
const SUPPORTS_WORKER = typeof window !== 'undefined'
|
||||||
&& typeof Worker !== 'undefined'
|
&& typeof Worker !== 'undefined'
|
||||||
&& typeof HTMLCanvasElement !== 'undefined'
|
&& typeof HTMLCanvasElement !== 'undefined'
|
||||||
@@ -38,6 +41,13 @@ export function init() {
|
|||||||
useWorker: false, // wird im Render je nach Config gesetzt
|
useWorker: false, // wird im Render je nach Config gesetzt
|
||||||
worker: null,
|
worker: null,
|
||||||
workerReady: false,
|
workerReady: false,
|
||||||
|
workerBusy: false,
|
||||||
|
workerMessageId: 0,
|
||||||
|
workerWatchdog: null,
|
||||||
|
pendingWorkerColumn: null,
|
||||||
|
pendingWorkerRepeat: 0,
|
||||||
|
pendingWorkerDropped: 0,
|
||||||
|
workerStats: { sent: 0, drawn: 0, dropped: 0, restarts: 0, lastDrawMs: 0 },
|
||||||
offscreenWidth: 0,
|
offscreenWidth: 0,
|
||||||
offscreenHeight: 0,
|
offscreenHeight: 0,
|
||||||
spectroCanvasEl: null,
|
spectroCanvasEl: null,
|
||||||
@@ -93,6 +103,10 @@ export async function render(env, state) {
|
|||||||
}
|
}
|
||||||
// Config-Flag schaltet Worker ein (default true), wenn Offscreen verfügbar
|
// Config-Flag schaltet Worker ein (default true), wenn Offscreen verfügbar
|
||||||
state.useWorker = SUPPORTS_WORKER && !!env.config?.SPECTRO_USE_WORKER;
|
state.useWorker = SUPPORTS_WORKER && !!env.config?.SPECTRO_USE_WORKER;
|
||||||
|
if (!state.useWorker && state.worker) {
|
||||||
|
teardownWorker(state);
|
||||||
|
removeSpectroCanvasElement(state);
|
||||||
|
}
|
||||||
state._dbgNextLog = state._dbgNextLog || 0;
|
state._dbgNextLog = state._dbgNextLog || 0;
|
||||||
|
|
||||||
const { ctx: g, rect, config: CONFIG, audio, meters } = env;
|
const { ctx: g, rect, config: CONFIG, audio, meters } = env;
|
||||||
@@ -165,6 +179,7 @@ export async function render(env, state) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scrollRate = resolveScrollRate(CONFIG);
|
const scrollRate = resolveScrollRate(CONFIG);
|
||||||
|
const outputPixelScale = state.useWorker ? (plotWpx / Math.max(1, plotW)) : 1;
|
||||||
let spectroDirty = true;
|
let spectroDirty = true;
|
||||||
if (hasPhoenixSpectro) {
|
if (hasPhoenixSpectro) {
|
||||||
spectroDirty = phoenixSpectroSeq !== (state.lastPhoenixSpectroSeq || 0);
|
spectroDirty = phoenixSpectroSeq !== (state.lastPhoenixSpectroSeq || 0);
|
||||||
@@ -173,15 +188,19 @@ export async function render(env, state) {
|
|||||||
let columnsToEmit = 0;
|
let columnsToEmit = 0;
|
||||||
if (hasPhoenixSpectro) {
|
if (hasPhoenixSpectro) {
|
||||||
if (spectroDirty) {
|
if (spectroDirty) {
|
||||||
state.scrollAccumulator = (state.scrollAccumulator || 0) + scrollRate;
|
const previousSeq = Number(state.lastPhoenixSpectroSeq || 0);
|
||||||
while (state.scrollAccumulator >= 1) {
|
const sequenceDelta = previousSeq > 0
|
||||||
columnsToEmit += 1;
|
? Math.max(1, phoenixSpectroSeq - previousSeq)
|
||||||
state.scrollAccumulator -= 1;
|
: 1;
|
||||||
}
|
const fftSize = Number(env.audio?.phoenixSpectroMeta?.fftSize) || freqBuf.length * 2;
|
||||||
if (columnsToEmit < 1) {
|
const pixelsPerSourceFrame = spectrogramPixelsPerSourceFrame(scrollRate, fs, fftSize);
|
||||||
columnsToEmit = 1;
|
const scrollStep = stepScrollAccumulator(
|
||||||
state.scrollAccumulator = 0;
|
state.scrollAccumulator,
|
||||||
}
|
pixelsPerSourceFrame * outputPixelScale,
|
||||||
|
sequenceDelta,
|
||||||
|
);
|
||||||
|
state.scrollAccumulator = scrollStep.accumulator;
|
||||||
|
columnsToEmit = scrollStep.columns;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const nowTs = performance.now();
|
const nowTs = performance.now();
|
||||||
@@ -206,15 +225,11 @@ export async function render(env, state) {
|
|||||||
|
|
||||||
if (columnsToEmit > 0 && spectroDirty) {
|
if (columnsToEmit > 0 && spectroDirty) {
|
||||||
const column = buildColumnData(state, freqBuf, range.bottom);
|
const column = buildColumnData(state, freqBuf, range.bottom);
|
||||||
const columns = [];
|
if (state.useWorker) {
|
||||||
for (let i = 0; i < columnsToEmit; i++) {
|
queueColumnForWorker(state, column, columnsToEmit);
|
||||||
columns.push(i === 0 ? column : new Float32Array(column));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.useWorker && state.workerReady) {
|
|
||||||
columns.forEach(col => sendColumnToWorker(state, col));
|
|
||||||
} else {
|
} else {
|
||||||
columns.forEach(col => writeColumnToHistory(state, col));
|
const repeat = Math.min(state.width, columnsToEmit);
|
||||||
|
for (let index = 0; index < repeat; index++) writeColumnToHistory(state, column);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,7 +264,7 @@ function setupWorker(state, widthPx, heightPx, range, gamma, freqBounds) {
|
|||||||
worker.onmessage = (event) => handleWorkerMessage(state, event);
|
worker.onmessage = (event) => handleWorkerMessage(state, event);
|
||||||
worker.onerror = () => {
|
worker.onerror = () => {
|
||||||
state.useWorker = false;
|
state.useWorker = false;
|
||||||
teardownWorker(state);
|
restartWorker(state);
|
||||||
};
|
};
|
||||||
const offscreen = canvasEl.transferControlToOffscreen();
|
const offscreen = canvasEl.transferControlToOffscreen();
|
||||||
state.spectroCanvasEl = canvasEl;
|
state.spectroCanvasEl = canvasEl;
|
||||||
@@ -310,6 +325,7 @@ function setupWorker(state, widthPx, heightPx, range, gamma, freqBounds) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function teardownWorker(state) {
|
function teardownWorker(state) {
|
||||||
|
clearWorkerWatchdog(state);
|
||||||
if (state.worker) {
|
if (state.worker) {
|
||||||
try {
|
try {
|
||||||
state.worker.postMessage({ type: 'dispose' });
|
state.worker.postMessage({ type: 'dispose' });
|
||||||
@@ -320,39 +336,97 @@ function teardownWorker(state) {
|
|||||||
}
|
}
|
||||||
state.worker = null;
|
state.worker = null;
|
||||||
state.workerReady = false;
|
state.workerReady = false;
|
||||||
state.spectroCanvasEl = null;
|
state.workerBusy = false;
|
||||||
state.spectroCanvasTransferred = false;
|
state.pendingWorkerColumn = null;
|
||||||
|
state.pendingWorkerRepeat = 0;
|
||||||
|
state.pendingWorkerDropped = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleWorkerMessage(state, event) {
|
function handleWorkerMessage(state, event) {
|
||||||
const data = event.data || {};
|
const data = event.data || {};
|
||||||
if (data.type === 'ready') {
|
if (data.type === 'ready') {
|
||||||
state.workerReady = true;
|
state.workerReady = true;
|
||||||
|
state.workerBusy = false;
|
||||||
|
pumpWorkerColumn(state);
|
||||||
|
} else if (data.type === 'drawn') {
|
||||||
|
if (Number(data.id) !== Number(state.workerMessageId)) return;
|
||||||
|
clearWorkerWatchdog(state);
|
||||||
|
state.workerBusy = false;
|
||||||
|
state.workerStats.drawn += Math.max(0, Number(data.repeat) || 0);
|
||||||
|
state.workerStats.lastDrawMs = Math.max(0, Number(data.drawMs) || 0);
|
||||||
|
pumpWorkerColumn(state);
|
||||||
|
} else if (data.type === 'error') {
|
||||||
|
restartWorker(state);
|
||||||
} else if (data.type === 'dbg' && state._dbgEnabled) {
|
} else if (data.type === 'dbg' && state._dbgEnabled) {
|
||||||
console.debug('[spectro worker]', data);
|
console.debug('[spectro worker]', data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendColumnToWorker(state, column) {
|
function queueColumnForWorker(state, column, repeat = 1) {
|
||||||
if (!state.worker || !column) return;
|
if (!column) return;
|
||||||
|
const requested = Math.max(1, Math.floor(Number(repeat) || 1));
|
||||||
// Prüfe ob Worker bereit ist und nicht überlastet
|
if (state.pendingWorkerColumn) {
|
||||||
if (!state.workerReady) return;
|
state.pendingWorkerDropped += 1;
|
||||||
|
state.workerStats.dropped += 1;
|
||||||
try {
|
|
||||||
state.worker.postMessage({ type: 'column', data: column }, [column.buffer]);
|
|
||||||
} catch (e) {
|
|
||||||
state.useWorker = false;
|
|
||||||
}
|
}
|
||||||
advanceWriteIndex(state);
|
state.pendingWorkerColumn = column;
|
||||||
|
state.pendingWorkerRepeat = Math.min(
|
||||||
|
Math.max(1, state.offscreenWidth || state.width || 1),
|
||||||
|
Math.max(0, state.pendingWorkerRepeat) + requested,
|
||||||
|
);
|
||||||
|
pumpWorkerColumn(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pumpWorkerColumn(state) {
|
||||||
|
if (!state.worker || !state.workerReady || state.workerBusy || !state.pendingWorkerColumn) return;
|
||||||
|
const column = state.pendingWorkerColumn;
|
||||||
|
const repeat = Math.min(MAX_SCROLL_COLUMNS_PER_DRAW, Math.max(1, state.pendingWorkerRepeat || 1));
|
||||||
|
const dropped = Math.max(0, state.pendingWorkerDropped || 0);
|
||||||
|
state.pendingWorkerColumn = null;
|
||||||
|
// A large value means the display was suspended or overloaded. Never catch
|
||||||
|
// up for seconds; jump by a bounded amount and continue with live data.
|
||||||
|
state.pendingWorkerRepeat = 0;
|
||||||
|
state.pendingWorkerDropped = 0;
|
||||||
|
state.workerBusy = true;
|
||||||
|
state.workerMessageId += 1;
|
||||||
|
const id = state.workerMessageId;
|
||||||
|
try {
|
||||||
|
state.worker.postMessage({ type: 'column', id, data: column, repeat, dropped }, [column.buffer]);
|
||||||
|
state.workerStats.sent += repeat;
|
||||||
|
} catch (e) {
|
||||||
|
restartWorker(state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearWorkerWatchdog(state);
|
||||||
|
state.workerWatchdog = setTimeout(() => {
|
||||||
|
if (!state.workerBusy || id !== state.workerMessageId) return;
|
||||||
|
restartWorker(state);
|
||||||
|
}, WORKER_WATCHDOG_MS);
|
||||||
|
|
||||||
// Debug: alle ~1s senden wir ein Status-Log
|
|
||||||
if (state._dbgEnabled && performance.now() >= state._dbgNextLog) {
|
if (state._dbgEnabled && performance.now() >= state._dbgNextLog) {
|
||||||
state._dbgNextLog = performance.now() + 1000;
|
state._dbgNextLog = performance.now() + 1000;
|
||||||
console.debug('[spectro main] sent column', { writeIndex: state.writeIndex });
|
console.debug('[spectro]', { ...state.workerStats, busy: state.workerBusy, queuedRepeat: state.pendingWorkerRepeat });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearWorkerWatchdog(state) {
|
||||||
|
if (!state.workerWatchdog) return;
|
||||||
|
clearTimeout(state.workerWatchdog);
|
||||||
|
state.workerWatchdog = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function restartWorker(state) {
|
||||||
|
clearWorkerWatchdog(state);
|
||||||
|
if (state.worker) {
|
||||||
|
try { state.worker.terminate(); } catch (_) {}
|
||||||
|
}
|
||||||
|
state.worker = null;
|
||||||
|
state.workerReady = false;
|
||||||
|
state.workerBusy = false;
|
||||||
|
state.workerStats.restarts += 1;
|
||||||
|
removeSpectroCanvasElement(state);
|
||||||
|
}
|
||||||
|
|
||||||
function ensureFallbackBuffers(state, width, height, fillDb, g) {
|
function ensureFallbackBuffers(state, width, height, fillDb, g) {
|
||||||
const needsInit = !state.history ||
|
const needsInit = !state.history ||
|
||||||
state.width !== width ||
|
state.width !== width ||
|
||||||
@@ -527,6 +601,23 @@ function resolveScrollRate(CONFIG) {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function stepScrollAccumulator(accumulator, rate, frameDelta = 1) {
|
||||||
|
const safeAccumulator = Number.isFinite(Number(accumulator)) ? Math.max(0, Number(accumulator)) : 0;
|
||||||
|
const safeRate = Number.isFinite(Number(rate)) ? Math.max(0.01, Math.min(64, Number(rate))) : 1;
|
||||||
|
const safeDelta = Number.isFinite(Number(frameDelta)) ? Math.max(1, Math.floor(Number(frameDelta))) : 1;
|
||||||
|
const total = safeAccumulator + safeRate * safeDelta;
|
||||||
|
const columns = Math.floor(total + 1e-9);
|
||||||
|
return { columns, accumulator: Math.max(0, total - columns) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spectrogramPixelsPerSourceFrame(rate, sampleRate, fftSize) {
|
||||||
|
const safeRate = [0.5, 1, 2, 4, 6].includes(Number(rate)) ? Number(rate) : 1;
|
||||||
|
const safeSampleRate = Math.max(8000, Number(sampleRate) || 48000);
|
||||||
|
const safeFftSize = Math.max(1024, Math.floor(Number(fftSize) || 4096));
|
||||||
|
const hopSamples = Math.max(128, Math.floor(safeFftSize / 8));
|
||||||
|
return BASE_SCROLL_CSS_PX_PER_SECOND * safeRate * hopSamples / safeSampleRate;
|
||||||
|
}
|
||||||
|
|
||||||
function advanceWriteIndex(state) {
|
function advanceWriteIndex(state) {
|
||||||
const width = Math.max(1, state.width || 1);
|
const width = Math.max(1, state.width || 1);
|
||||||
state.writeIndex = (state.writeIndex + 1) % width;
|
state.writeIndex = (state.writeIndex + 1) % width;
|
||||||
|
|||||||
+145
-236
@@ -1,9 +1,7 @@
|
|||||||
// workers/spectrogram.worker.js
|
// Incremental OffscreenCanvas renderer for the spectrogram.
|
||||||
// Handles Spectrogram rendering off the main thread using OffscreenCanvas.
|
// One message is processed at a time and acknowledged. The sender therefore
|
||||||
|
// never needs to build an unbounded postMessage queue.
|
||||||
|
|
||||||
const FRAME_INTERVAL_MS = 1000 / 24;
|
|
||||||
const MAX_QUEUE_AGE_MS = 2000; // wenn länger nichts geflossen ist, trotzdem zeichnen
|
|
||||||
const MAX_PENDING_COLUMNS = 64;
|
|
||||||
const LUT_SIZE = 256;
|
const LUT_SIZE = 256;
|
||||||
const COLOR_STOPS = [
|
const COLOR_STOPS = [
|
||||||
{ t: 0.0, color: [0, 0, 0] },
|
{ t: 0.0, color: [0, 0, 0] },
|
||||||
@@ -13,9 +11,6 @@ const COLOR_STOPS = [
|
|||||||
{ t: 1.0, color: [255, 255, 255] },
|
{ t: 1.0, color: [255, 255, 255] },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Pre-calculated constants for performance
|
|
||||||
const LUT_SIZE_MINUS_ONE = LUT_SIZE - 1;
|
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
canvas: null,
|
canvas: null,
|
||||||
ctx: null,
|
ctx: null,
|
||||||
@@ -24,33 +19,27 @@ const state = {
|
|||||||
topDb: -9,
|
topDb: -9,
|
||||||
bottomDb: -90,
|
bottomDb: -90,
|
||||||
gamma: 0.9,
|
gamma: 0.9,
|
||||||
fMin: 20,
|
|
||||||
fMax: 20000,
|
|
||||||
history: null,
|
|
||||||
writeIndex: 0,
|
|
||||||
imgData: null,
|
|
||||||
pixels: null,
|
|
||||||
lut: null,
|
lut: null,
|
||||||
lastDraw: 0,
|
stripe: null,
|
||||||
drawTimer: null,
|
stripePixels: null,
|
||||||
pendingQueue: [],
|
drawn: 0,
|
||||||
lastColumnTs: 0,
|
dropped: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
self.onmessage = (event) => {
|
self.onmessage = (event) => {
|
||||||
const data = event.data || {};
|
const message = event.data || {};
|
||||||
switch (data.type) {
|
switch (message.type) {
|
||||||
case 'init':
|
case 'init':
|
||||||
handleInit(data);
|
init(message);
|
||||||
break;
|
break;
|
||||||
case 'resize':
|
case 'resize':
|
||||||
handleResize(data);
|
resize(message);
|
||||||
break;
|
break;
|
||||||
case 'config':
|
case 'config':
|
||||||
handleConfig(data);
|
configure(message);
|
||||||
break;
|
break;
|
||||||
case 'column':
|
case 'column':
|
||||||
handleColumn(data);
|
drawColumn(message);
|
||||||
break;
|
break;
|
||||||
case 'dispose':
|
case 'dispose':
|
||||||
dispose();
|
dispose();
|
||||||
@@ -60,221 +49,155 @@ self.onmessage = (event) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function handleInit({ canvas, width, height, topDb, bottomDb, gamma, fMin, fMax }) {
|
function init({ canvas, width, height, topDb, bottomDb, gamma }) {
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
|
||||||
state.canvas = canvas;
|
state.canvas = canvas;
|
||||||
state.ctx = canvas.getContext('2d', { alpha: false, desynchronized: true });
|
state.ctx = canvas.getContext('2d', { alpha: false, desynchronized: true });
|
||||||
|
|
||||||
if (!state.ctx) {
|
if (!state.ctx) {
|
||||||
postMessage({ type: 'error', error: 'ctx' });
|
postMessage({ type: 'error', error: '2d-context' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.ctx.imageSmoothingEnabled = false;
|
state.ctx.imageSmoothingEnabled = false;
|
||||||
state.topDb = Number.isFinite(topDb) ? topDb : state.topDb;
|
applyConfig(topDb, bottomDb, gamma);
|
||||||
state.bottomDb = Number.isFinite(bottomDb) ? bottomDb : state.bottomDb;
|
allocate(width, height);
|
||||||
state.fMin = Number.isFinite(fMin) ? fMin : state.fMin;
|
clearCanvas();
|
||||||
state.fMax = Number.isFinite(fMax) ? fMax : state.fMax;
|
|
||||||
state.gamma = clampGamma(gamma);
|
|
||||||
|
|
||||||
allocateBuffers(width, height);
|
|
||||||
rebuildLut();
|
|
||||||
|
|
||||||
postMessage({ type: 'ready' });
|
postMessage({ type: 'ready' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleResize({ width, height, fMin, fMax }) {
|
function resize({ width, height }) {
|
||||||
if (!state.canvas || !state.ctx) return;
|
if (!state.ctx) return;
|
||||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return;
|
const nextWidth = positiveInt(width, state.width || 1);
|
||||||
|
const nextHeight = positiveInt(height, state.height || 1);
|
||||||
allocateBuffers(width, height);
|
if (nextWidth === state.width && nextHeight === state.height) return;
|
||||||
|
allocate(nextWidth, nextHeight);
|
||||||
if (Number.isFinite(fMin)) state.fMin = fMin;
|
clearCanvas();
|
||||||
if (Number.isFinite(fMax)) state.fMax = fMax;
|
postMessage({ type: 'resized', width: state.width, height: state.height });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleConfig({ topDb, bottomDb, gamma }) {
|
function configure({ topDb, bottomDb, gamma }) {
|
||||||
if (Number.isFinite(topDb)) state.topDb = topDb;
|
const before = `${state.topDb}|${state.bottomDb}|${state.gamma}`;
|
||||||
if (Number.isFinite(bottomDb)) state.bottomDb = bottomDb;
|
applyConfig(topDb, bottomDb, gamma);
|
||||||
if (Number.isFinite(gamma)) state.gamma = clampGamma(gamma);
|
const after = `${state.topDb}|${state.bottomDb}|${state.gamma}`;
|
||||||
rebuildLut();
|
if (before !== after) {
|
||||||
}
|
// Old pixels use the previous colour transfer function. Clearing avoids a
|
||||||
|
// misleading mixed scale after a range/gamma change.
|
||||||
function handleColumn({ data }) {
|
clearCanvas();
|
||||||
if (!data || !(data instanceof Float32Array)) return;
|
|
||||||
|
|
||||||
if (!state.history || state.height !== data.length) {
|
|
||||||
allocateBuffers(state.width, data.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ringpuffer: halte nur die letzten wenige Columns, neueste gewinnt
|
|
||||||
state.pendingQueue.push(data);
|
|
||||||
while (state.pendingQueue.length > MAX_PENDING_COLUMNS) state.pendingQueue.shift();
|
|
||||||
state.lastColumnTs = performance.now();
|
|
||||||
maybeDraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
function allocateBuffers(width, height) {
|
|
||||||
const clampedWidth = Math.max(1, Math.floor(width));
|
|
||||||
const clampedHeight = Math.max(1, Math.floor(height));
|
|
||||||
|
|
||||||
// Vermeide unnötige Re-allokation
|
|
||||||
if (state.history &&
|
|
||||||
state.width === clampedWidth &&
|
|
||||||
state.height === clampedHeight) {
|
|
||||||
return; // Keine Größenänderung, behalte bestehende Buffers
|
|
||||||
}
|
|
||||||
|
|
||||||
state.width = clampedWidth;
|
|
||||||
state.height = clampedHeight;
|
|
||||||
|
|
||||||
if (state.canvas) {
|
|
||||||
state.canvas.width = clampedWidth;
|
|
||||||
state.canvas.height = clampedHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allokiere neuen Buffer nur wenn nötig
|
|
||||||
const neededSize = clampedWidth * clampedHeight;
|
|
||||||
if (!state.history || state.history.length !== neededSize) {
|
|
||||||
state.history = new Float32Array(neededSize);
|
|
||||||
}
|
|
||||||
state.history.fill(state.bottomDb);
|
|
||||||
|
|
||||||
state.writeIndex = 0;
|
|
||||||
|
|
||||||
if (state.ctx) {
|
|
||||||
state.imgData = state.ctx.createImageData(clampedWidth, clampedHeight);
|
|
||||||
state.pixels = state.imgData.data;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function rebuildLut() {
|
function applyConfig(topDb, bottomDb, gamma) {
|
||||||
state.lut = buildLut(LUT_SIZE);
|
if (Number.isFinite(topDb)) state.topDb = Number(topDb);
|
||||||
|
if (Number.isFinite(bottomDb)) state.bottomDb = Number(bottomDb);
|
||||||
|
if (state.topDb <= state.bottomDb) state.topDb = state.bottomDb + 1;
|
||||||
|
if (Number.isFinite(gamma)) state.gamma = clamp(Number(gamma), 0.3, 1.2);
|
||||||
|
state.lut = buildLut();
|
||||||
}
|
}
|
||||||
|
|
||||||
function maybeDraw() {
|
function allocate(width, height) {
|
||||||
if (!state.ctx || !state.imgData || !state.history) return;
|
state.width = positiveInt(width, 1);
|
||||||
|
state.height = positiveInt(height, 1);
|
||||||
const now = performance.now();
|
state.canvas.width = state.width;
|
||||||
const delta = now - state.lastDraw;
|
state.canvas.height = state.height;
|
||||||
|
state.stripe = null;
|
||||||
|
state.stripePixels = null;
|
||||||
|
}
|
||||||
|
|
||||||
const tooLongNoColumn = state.lastColumnTs && (now - state.lastColumnTs > MAX_QUEUE_AGE_MS);
|
function clearCanvas() {
|
||||||
const readyToDraw = delta >= FRAME_INTERVAL_MS || tooLongNoColumn;
|
if (!state.ctx) return;
|
||||||
|
state.ctx.save();
|
||||||
|
state.ctx.globalCompositeOperation = 'copy';
|
||||||
|
state.ctx.fillStyle = '#000';
|
||||||
|
state.ctx.fillRect(0, 0, state.width, state.height);
|
||||||
|
state.ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
if (readyToDraw) {
|
function drawColumn({ id, data, repeat = 1, dropped = 0 }) {
|
||||||
if (drawFrame()) {
|
const started = performance.now();
|
||||||
state.lastDraw = now;
|
if (!state.ctx || !(data instanceof Float32Array) || !data.length) {
|
||||||
}
|
postMessage({ type: 'drawn', id, drawMs: 0, repeat: 0, error: 'invalid-column' });
|
||||||
} else if (!state.drawTimer) {
|
return;
|
||||||
state.drawTimer = setTimeout(() => {
|
|
||||||
state.drawTimer = null;
|
|
||||||
if (drawFrame()) {
|
|
||||||
state.lastDraw = performance.now();
|
|
||||||
}
|
|
||||||
}, FRAME_INTERVAL_MS - delta);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shift = Math.min(state.width, Math.max(1, Math.floor(Number(repeat) || 1)));
|
||||||
|
ensureStripe(shift);
|
||||||
|
colourStripe(data, shift);
|
||||||
|
|
||||||
|
if (shift < state.width) {
|
||||||
|
// Canvas-to-self copy is defined as if the source were captured before the
|
||||||
|
// draw, so overlapping left shifts do not smear the image.
|
||||||
|
state.ctx.drawImage(
|
||||||
|
state.canvas,
|
||||||
|
shift, 0, state.width - shift, state.height,
|
||||||
|
0, 0, state.width - shift, state.height,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
state.ctx.putImageData(state.stripe, state.width - shift, 0);
|
||||||
|
state.drawn += shift;
|
||||||
|
state.dropped += Math.max(0, Number(dropped) || 0);
|
||||||
|
|
||||||
|
postMessage({
|
||||||
|
type: 'drawn',
|
||||||
|
id,
|
||||||
|
repeat: shift,
|
||||||
|
drawMs: performance.now() - started,
|
||||||
|
drawn: state.drawn,
|
||||||
|
dropped: state.dropped,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawFrame() {
|
function ensureStripe(width) {
|
||||||
if (!state.ctx || !state.imgData || !state.history || !state.lut) return false;
|
if (state.stripe && state.stripe.width === width && state.stripe.height === state.height) return;
|
||||||
if (!flushPendingColumn()) return false;
|
state.stripe = state.ctx.createImageData(width, state.height);
|
||||||
|
state.stripePixels = state.stripe.data;
|
||||||
const { width, height, history, pixels, imgData } = state;
|
}
|
||||||
const range = Math.max(1e-3, state.topDb - state.bottomDb);
|
|
||||||
const lut = state.lut;
|
function colourStripe(column, stripeWidth) {
|
||||||
const base = state.writeIndex;
|
const pixels = state.stripePixels;
|
||||||
|
const range = Math.max(1e-6, state.topDb - state.bottomDb);
|
||||||
const invertedHeight = height - 1;
|
const sourceLast = Math.max(0, column.length - 1);
|
||||||
|
const targetLast = Math.max(1, state.height - 1);
|
||||||
for (let x = 0; x < width; x++) {
|
|
||||||
const srcX = (base + x) % width;
|
for (let targetY = 0; targetY < state.height; targetY++) {
|
||||||
const srcXOffset = srcX;
|
// Source columns are low-to-high frequency; canvas rows are top-to-bottom.
|
||||||
|
const sourcePos = (state.height - 1 - targetY) * sourceLast / targetLast;
|
||||||
for (let y = 0; y < height; y++) {
|
const lo = Math.floor(sourcePos);
|
||||||
const dB = history[y * width + srcXOffset];
|
const hi = Math.min(sourceLast, lo + 1);
|
||||||
const clamped = dB < state.bottomDb ? state.bottomDb :
|
const frac = sourcePos - lo;
|
||||||
dB > state.topDb ? state.topDb : dB;
|
const a = finiteOr(column[lo], state.bottomDb);
|
||||||
|
const b = finiteOr(column[hi], a);
|
||||||
let norm = (clamped - state.bottomDb) / range;
|
const db = a + (b - a) * frac;
|
||||||
norm = norm < 0 ? 0 : norm > 1 ? 1 : norm;
|
const norm = Math.pow(clamp((db - state.bottomDb) / range, 0, 1), state.gamma);
|
||||||
norm = Math.pow(norm, state.gamma);
|
const lutIndex = Math.min(LUT_SIZE - 1, Math.round(norm * (LUT_SIZE - 1))) * 3;
|
||||||
|
|
||||||
const lutIdx = Math.min(LUT_SIZE_MINUS_ONE, Math.round(norm * LUT_SIZE_MINUS_ONE));
|
for (let x = 0; x < stripeWidth; x++) {
|
||||||
const destY = invertedHeight - y;
|
const pixel = (targetY * stripeWidth + x) * 4;
|
||||||
const pixelIndex = (destY * width + x) * 4;
|
pixels[pixel] = state.lut[lutIndex];
|
||||||
const lutOffset = lutIdx * 3;
|
pixels[pixel + 1] = state.lut[lutIndex + 1];
|
||||||
|
pixels[pixel + 2] = state.lut[lutIndex + 2];
|
||||||
pixels[pixelIndex + 0] = lut[lutOffset + 0];
|
pixels[pixel + 3] = 255;
|
||||||
pixels[pixelIndex + 1] = lut[lutOffset + 1];
|
|
||||||
pixels[pixelIndex + 2] = lut[lutOffset + 2];
|
|
||||||
pixels[pixelIndex + 3] = 255;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
state.ctx.putImageData(imgData, 0, 0);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function flushPendingColumn() {
|
function buildLut() {
|
||||||
if (!state.pendingQueue.length || !state.history) return false;
|
const lut = new Uint8ClampedArray(LUT_SIZE * 3);
|
||||||
|
for (let index = 0; index < LUT_SIZE; index++) {
|
||||||
const width = state.width;
|
const [r, g, b] = colorFromStops(index / (LUT_SIZE - 1));
|
||||||
const height = state.height;
|
const offset = index * 3;
|
||||||
let writeIdx = state.writeIndex;
|
lut[offset] = r;
|
||||||
const hist = state.history;
|
|
||||||
|
|
||||||
while (state.pendingQueue.length) {
|
|
||||||
const column = state.pendingQueue.shift();
|
|
||||||
if (!column) continue;
|
|
||||||
const col = (column.length === height)
|
|
||||||
? column
|
|
||||||
: normalizeColumn(column, height, state.bottomDb);
|
|
||||||
for (let y = 0; y < height; y++) {
|
|
||||||
const value = col[y];
|
|
||||||
hist[y * width + writeIdx] = Number.isFinite(value) ? value : state.bottomDb;
|
|
||||||
}
|
|
||||||
writeIdx = (writeIdx + 1) % width;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.writeIndex = writeIdx;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeColumn(column, targetHeight, fillDb) {
|
|
||||||
const adjusted = new Float32Array(targetHeight);
|
|
||||||
const copyLength = Math.min(column.length, targetHeight);
|
|
||||||
adjusted.set(column.subarray(0, copyLength));
|
|
||||||
adjusted.fill(fillDb, copyLength);
|
|
||||||
return adjusted;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildLut(size) {
|
|
||||||
const lut = new Uint8ClampedArray(size * 3);
|
|
||||||
const sizeMinusOne = size - 1;
|
|
||||||
|
|
||||||
for (let i = 0; i < size; i++) {
|
|
||||||
const t = i / sizeMinusOne;
|
|
||||||
const [r, g, b] = colorFromStops(t);
|
|
||||||
const offset = i * 3;
|
|
||||||
lut[offset + 0] = r;
|
|
||||||
lut[offset + 1] = g;
|
lut[offset + 1] = g;
|
||||||
lut[offset + 2] = b;
|
lut[offset + 2] = b;
|
||||||
}
|
}
|
||||||
|
|
||||||
return lut;
|
return lut;
|
||||||
}
|
}
|
||||||
|
|
||||||
function colorFromStops(tValue) {
|
function colorFromStops(value) {
|
||||||
const t = tValue < 0 ? 0 : tValue > 1 ? 1 : tValue;
|
const t = clamp(value, 0, 1);
|
||||||
|
for (let index = 1; index < COLOR_STOPS.length; index++) {
|
||||||
for (let i = 1; i < COLOR_STOPS.length; i++) {
|
const left = COLOR_STOPS[index - 1];
|
||||||
const left = COLOR_STOPS[i - 1];
|
const right = COLOR_STOPS[index];
|
||||||
const right = COLOR_STOPS[i];
|
|
||||||
|
|
||||||
if (t <= right.t) {
|
if (t <= right.t) {
|
||||||
const span = right.t - left.t || 1;
|
const rel = (t - left.t) / Math.max(1e-9, right.t - left.t);
|
||||||
const rel = (t - left.t) / span;
|
|
||||||
return [
|
return [
|
||||||
Math.round(left.color[0] + rel * (right.color[0] - left.color[0])),
|
Math.round(left.color[0] + rel * (right.color[0] - left.color[0])),
|
||||||
Math.round(left.color[1] + rel * (right.color[1] - left.color[1])),
|
Math.round(left.color[1] + rel * (right.color[1] - left.color[1])),
|
||||||
@@ -282,41 +205,27 @@ function colorFromStops(tValue) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return COLOR_STOPS[COLOR_STOPS.length - 1].color.slice();
|
||||||
const last = COLOR_STOPS[COLOR_STOPS.length - 1].color;
|
|
||||||
return [last[0], last[1], last[2]];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampGamma(value) {
|
function positiveInt(value, fallback) {
|
||||||
if (!Number.isFinite(value)) return 0.9;
|
const number = Math.floor(Number(value));
|
||||||
if (value < 0.3) return 0.3;
|
return Number.isFinite(number) && number > 0 ? number : fallback;
|
||||||
if (value > 1.2) return 1.2;
|
}
|
||||||
return value;
|
|
||||||
|
function finiteOr(value, fallback) {
|
||||||
|
return Number.isFinite(value) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return value < min ? min : value > max ? max : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dispose() {
|
function dispose() {
|
||||||
if (state.drawTimer) {
|
state.stripe = null;
|
||||||
clearTimeout(state.drawTimer);
|
state.stripePixels = null;
|
||||||
state.drawTimer = null;
|
state.lut = null;
|
||||||
}
|
|
||||||
|
|
||||||
if (state.history) {
|
|
||||||
state.history = null;
|
|
||||||
}
|
|
||||||
if (state.imgData) {
|
|
||||||
state.imgData = null;
|
|
||||||
}
|
|
||||||
if (state.pixels) {
|
|
||||||
state.pixels = null;
|
|
||||||
}
|
|
||||||
if (state.lut) {
|
|
||||||
state.lut = null;
|
|
||||||
}
|
|
||||||
state.pendingColumn = null;
|
|
||||||
state.canvas = null;
|
state.canvas = null;
|
||||||
state.ctx = null;
|
state.ctx = null;
|
||||||
|
if (typeof self.close === 'function') self.close();
|
||||||
if (typeof self.close === 'function') {
|
|
||||||
try { self.close(); } catch (_) {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user