diff --git a/scripts/test_rta_profile.mjs b/scripts/test_rta_profile.mjs index 53c9e84..ef63a63 100644 --- a/scripts/test_rta_profile.mjs +++ b/scripts/test_rta_profile.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import vm from 'node:vm'; import { getRtwCenters } from '../www/core/rtw_centers.js'; -import { selectLocalRtwPacket } from '../www/views/realtime.js'; +import { buildRtwTickPositions, selectLocalRtwPacket } from '../www/views/realtime.js'; const source = fs.readFileSync(new URL('../www/core/audio.js', import.meta.url), 'utf8'); const functionSource = source.match(/export function buildRtaRuntimeConfig[\s\S]*?\n\}/)?.[0]; @@ -87,4 +87,15 @@ assert.equal(selectedSixth.centers.length, getRtwCenters('1_6').length); assert.equal(selectedSixth.bands_avg.length, selectedSixth.centers.length); assert.equal(selectedSixth.bands_peak.length, selectedSixth.centers.length); +for (const mode of ['1_3', '1_6', '1_12']) { + const centers = getRtwCenters(mode); + const ticks = [20, 31.5, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]; + const positions = buildRtwTickPositions(centers, ticks); + for (const tick of ticks) { + const index = centers.indexOf(tick); + assert.ok(index >= 0, `${mode} must contain the ${tick} Hz display band`); + assert.equal(positions[String(tick)], (index + 0.5) / centers.length); + } +} + console.log('RTA profile regression tests passed'); diff --git a/www/changelog.html b/www/changelog.html new file mode 100644 index 0000000..6a6aa01 --- /dev/null +++ b/www/changelog.html @@ -0,0 +1,588 @@ +
+
+ 3.8 (Phoenix 1.0) +
+
+

Veröffentlichung: 21.07.2026

+

Änderungen:

+
    +
  • Analysegrundlage: Die tiefgehende Code- und DSP-Prüfung, durch die die nachfolgenden Fehler und Verbesserungsmöglichkeiten gefunden werden konnten, wurde durch den Umstieg auf das LLM-Modell GPT-5.6 SOL ermöglicht.
  • +
  • Echtzeit-Datenweg: WebSocket-Verarbeitung auf „latest value wins“ umgestellt. Messwerte, Spektrogramm sowie Goniometer/Waveform verwenden getrennte, begrenzte Datenwege; alte Frames können keine anwachsende Anzeigeverzögerung mehr bilden.
  • +
  • Browserlast: Single-Slot-Puffer und Sequenzprüfung für Mess-, Spektrogramm- und Visualisierungsdaten ergänzt. Große Rohsamplefelder werden nicht mehr in jedem JSON-Messpaket wiederholt.
  • +
  • Spektrogramm: Langzeitstillstand und stotterndes Nachholen beseitigt. Inkrementelles Spaltenzeichnen, Worker-ACK, Watchdog/Neustart und begrenztes Überspringen veralteter Spalten ergänzt. 0,5×, 1×, 2×, 4× und 6× besitzen nun eine feste, FFT- und DPI-unabhängige Zeitbasis.
  • +
  • DIN-/EBU-PPM: Blockunabhängige Quasi-Peak-Detektoren mit bandbegrenzter Interpolation, korrekter Attack- und Rücklaufballistik sowie automatischen Tonburst-, Frequenzgang- und Polaritätstests implementiert. Eine zweite Browser-Anstiegsballistik und der alte nicht normgerechte DIN-Fast-Attack-Sonderweg wurden vollständig entfernt.
  • +
  • RTW-RTA: RTW-Profil verbindlich auf die IIR-Oktavteilband-Filterbank festgelegt. Bis zur Vergleichsmessung am realen RTW-Gerät sind die bisherige Phoenix/RTW-Charakteristik und vollständige Butterworth-Bandpässe sechster Ordnung getrennt auswählbar.
  • +
  • RTA-Detektor und Reaktionszeit: Average (RMS) und Peak (gleitendes 10-ms-Maximum) sind nun getrennt von Fast, Medium, Slow und Impulse wählbar. Fast wurde auf 125 ms korrigiert; Impulse verwendet 35 ms Anstieg und 1,5 s Rücklauf. Die IIR-Detektoren arbeiten samplegenau und unabhängig von der ALSA-Periodengröße. Alte Einstellungen werden automatisch migriert.
  • +
  • RTA-Auflösung: Umschaltung zwischen 1/3, 1/6 und 1/12 repariert. Alle drei Auflösungen bleiben im RTW-Profil aktiv und verwenden dessen IIR-Filterbank, Darstellung und Ballistik.
  • +
  • Hardwareparameter: Tatsächlich von ALSA ausgehandelte Samplerate, Perioden- und Puffergröße werden nun durch DSP, Aufnahme, Status und Browser verwendet; die feste 48-kHz-Annahme wurde entfernt.
  • +
  • Goniometer: Nur neue XY-Samplepaare werden mit einer periodengrößenunabhängigen mittleren Rate von 60 Hz übertragen. Punktbegrenzung wirkt bereits auf den Transport; begrenzte und wiederverwendbare Spurpuffer reduzieren Speicher- und Zeichenlast.
  • +
  • Goniometer-Persistenz: Reproduzierbare Fast-, Medium- und Slow-Profile sowie ein freies Phoenix-Fade ergänzt. Künstliche Bézier-Verformung der Messspur entfernt; AGC und Silence-Gate arbeiten zeit- beziehungsweise fensterbasiert.
  • +
  • Korrelation: Kontinuierliche DSP-Messung aus L², R² und L·R mit wählbaren 1,0/2,5 Sekunden ergänzt. Bildratenabhängige Doppelglättung entfernt; Negative-Peak-Memory, Marker und manueller Reset hinzugefügt.
  • +
  • Phasenrad: Bandpass, Hilbert-Transformation und energiegewichtete L/R-Phasenmittelung laufen nun samplekontinuierlich im Audiokern statt auf ausgedünnten Browser-XY-Punkten. Paketgrenzen beeinflussen den Winkel nicht mehr; Glättung und AGC arbeiten zeitbasiert, und die doppelte AGC-Verstärkung wurde entfernt.
  • +
  • True Peak, RMS und VU: True Peak verwendet den vierphasigen Referenz-FIR aus ITU-R BS.1770, besteht die EBU-Testfälle 15 bis 19 und zeigt Übersteuerungen in allen Ansichten bis +6 dBTP an. True RMS integriert samplekontinuierlich im Leistungsbereich mit Fast (125 ms), Slow (1 s) oder einem gleitenden 300-ms-Fenster; Browser-Doppelglättung und der RMS-fremde Impulse-Modus wurden entfernt. VU verwendet ein Moving-Coil-Modell mit 300-ms-Sprungantwort und 1 bis 1,5 % Überschwingen; der fälschliche Standardoffset von +1,92 dB wurde auf 0 dB korrigiert.
  • +
  • Qualitätssicherung: Automatische Regressionstests für PPM, RTA, A/C/Z, mehrere Sampleraten, Korrelationssignale, Goniometertaktung, Binärprotokolle sowie Spektrogramm-Zeitbasis und Langlauf ergänzt.
  • +
+
+
+
+
+ 3.3.5 (Phoenix 1.0) +
+
+

Veröffentlichung: 28.04.2026

+

Änderungen:

+
    +
  • Stoppuhr: Löschfunktion verbessert. Die X-Buttons neben den einzelnen Zeiten lassen sich jetzt über einen separaten X-Button ein- und ausblenden; Reset und Löschmodus sind optisch getrennt.
  • +
  • Online Update: Hinweis ergänzt, dass die Funktion nur für die reine Analyzer-Installation gedacht ist; Volumio-Systeme sollen per Snapshot bzw. manuell aktualisiert werden.
  • +
+
+
+
+
+ 3.3.4 (Phoenix 1.0) +
+
+

Veröffentlichung: 24.04.2026

+

Änderungen:

+
    +
  • Studio-Clock und Clock-Screensaver beim Sekundenblinken angepasst: Der Doppelpunkt läuft jetzt mit exakt 50 % Duty Cycle und schaltet auf der Sekunde an sowie in der zweiten Halbsekunde wieder aus.
  • +
  • Online Update robuster gemacht: Update-Skripte laufen jetzt mit sicherer Fallback-Locale, damit fehlendes de_DE.UTF-8 auf Volumio keine bash-setlocale-Warnung mehr als Fehler anzeigt.
  • +
+
+
+
+
+ 3.3.2 (Phoenix 1.0) +
+
+

Veröffentlichung: 21.04.2026

+

Änderungen:

+
    +
  • INFO: Ein extern beobachtetes Start-/WebSocket-Problem in älteren Safari-Versionen lag nicht an Phoenix selbst, sondern an einem Safari-/WebKit-Bug; nach Safari-Update funktionierte der Analyzer wieder normal.
  • +
  • Recorder-Liste auf große Browserfenster verbessert: Die sichtbare Listenhöhe passt sich jetzt dynamisch an die tatsächlich verfügbare Fläche an, statt früh auf eine starre Anzahl sichtbarer Einträge zu begrenzen.
  • +
  • Recorder-Layout unten entschärft: Vor Timer und Buttons bleibt nun mehr Reserve, damit der letzte sichtbare Eintrag nicht in den Button-Bereich hineinragt, bevor gescrollt werden muss.
  • +
  • Stoppuhr-Liste ebenfalls dynamisiert: Die Anzahl sichtbarer Zeiten wird jetzt direkt aus der verfügbaren Meter-Höhe berechnet und reagiert sauberer auf unterschiedlich große Browserfenster.
  • +
  • Stoppuhr-Layout bei kleiner Höhe geglättet: Die unteren Start-/Reset-/Auto-Buttons schrumpfen bei knapper Höhe mit, damit die Liste beim Verkleinern nicht mehr schlagartig verschwindet.
  • +
  • Phoenix-Metrics-Stream robuster gemacht: Bei kurzzeitigem Rückstau werden verpasste Frames jetzt verworfen, ohne den WebSocket sofort zu beenden; zusätzlich wurde der interne Metrics-Puffer vergrößert.
  • +
+
+
+
+
+ 3.3.1 (Phoenix 1.0) +
+
+

Veröffentlichung: 20.04.2026

+

Änderungen:

+
    +
  • Layout- und Preset-System weiter ausgebaut: Presets wurden im Optionen-Menü auf eine eigene Ebene gehoben und die Layout-Slots lassen sich zusätzlich direkt aktivieren.
  • +
  • Layout-Boot per URL stabilisiert: Toolbar, aktiver View und geladene Ansicht bleiben bei ?layout=1 und den übrigen Layout-Slots jetzt konsistenter synchron.
  • +
  • Layout-Speicherung korrigiert: Die Slot-Belegungen der Views werden beim Speichern eines Layout-Slots jetzt zuverlässig mitgesichert und beim Laden wiederhergestellt.
  • +
  • INFO: Ein zwischenzeitlich beobachtetes externes Start-/WebSocket-Problem in älteren Safari-Versionen lag nicht an Phoenix selbst, sondern an einem Safari-/WebKit-Bug; nach einem Safari-Update funktionierte der Analyzer wieder normal.
  • +
  • Weitere Anzeigeoptionen globalisiert: RTA-Oktaven, Peak-History-Optionen, Phase-Wheel-Optionen, Classic-Needles-Quelle, historische RTW-Loudness sowie die konfigurierbaren Farben laufen nun browserübergreifend über die globale Phoenix-Konfiguration.
  • +
  • Quad-/Registry-Pfad korrigiert: Peak History bleibt auch im Quad-View mit gültigen Live-Daten versorgt und fällt dort nicht mehr scheinbar auf 0 zurück.
  • +
  • Online-Update ergänzt: Phoenix kann eine feste Update-ZIP laden, in einen Stage-Ordner vorbereiten und beim Neustart mit Backup, Health-Check und automatischem Rollback auf den alten Stand umschalten.
  • +
  • Online-Update weiter gehärtet: Vor dem Neustart der Kiosk-Browser werden nur die Asset-Caches geleert, damit nach einem Update zuverlässig der neue Web-Stand geladen wird, ohne die eigentlichen Einstellungen zu verlieren.
  • +
  • Online-Update-Logging ergänzt: Download-, Entpack-, Stage-, Neustart- und Rollback-Schritte werden jetzt in eine gemeinsame Update-Logdatei geschrieben und können zusätzlich über einen Phoenix-Endpunkt direkt abgefragt werden.
  • +
+
+
+
+
+ 3.3 (Phoenix 1.0) +
+
+

Veröffentlichung: 20.04.2026

+

Änderungen:

+
    +
  • Software-Presets weiter ausgebaut: Default, Claus und Michael lassen sich jetzt robuster speichern, laden, exportieren und importieren.
  • +
  • Preset-Speicherung bleibt aus Sicherheitsgründen auf den Analyzer direkt beschränkt; externe Browser dürfen Presets laden, aber nicht überschreiben.
  • +
  • Phoenix-URL-/Host-Pfad für Presets gehärtet: Wenn eine gespeicherte Loopback-URL für den aktuellen Browser nicht passt, wird sauber auf den aktuellen Analyzer-Host zurückgefallen.
  • +
  • Preset-Ladepfad stabilisiert: Benutzer-Presets übernehmen jetzt auch den getrennt gehaltenen UI-/View-Zustand zuverlässiger und laden nach Profil-/Cache-Reset wieder nachvollziehbar.
  • +
  • Neue Layout-Slots ergänzt: Vier separate Layout-Presets speichern reine Ansichts-/Fensterbelegungen, unabhängig von globalen Software-Presets.
  • +
  • Layout-Slots werden außerhalb des Browser-Profils serverseitig gespeichert und können per URL-Parameter wie ?layout=1 direkt in einem Fenster geladen werden.
  • +
  • Options-Menü neu geordnet: Unter Meters → Presets liegen jetzt sowohl die Software-Presets als auch die neuen Layout-Slots gebündelt an einer Stelle.
  • +
  • Erststart-Dialog überarbeitet: statt des alten Kalibrierhinweises erscheint nun eine neutrale Willkommensmeldung zum Phoenix Analyzer.
  • +
  • Header-Farbpfad vereinheitlicht: Header-Text/-Zahlen entkoppelt von Balkenfarben und zusätzlich über eine globale Header-Textfarbe einstellbar.
  • +
  • Waveform-Farben reagieren jetzt wieder korrekt auf Änderungen in den Optionen, auch im Worker-Pfad.
  • +
  • Split-/Quad-Views erweitert: Waveform und Spectrogram können dort als Plot verwendet werden; eingebettete Geometrie und Darstellung wurden dafür nachgezogen.
  • +
  • Weitere UI-/Detailkorrekturen: Peak-History-Geometrie, RTA-/Meter-Beschriftungen, PPM-DIN-%-Skala, Recorder-/Stoppuhr-Header sowie diverse kleinere Konsistenzfehler wurden bereinigt.
  • +
+
+
+
+
+ 3.1 (Phoenix 1.0) +
+
+

Veröffentlichung: 18.04.2026

+

Änderungen:

+
    +
  • GUI-Performance systematisch überarbeitet: statische Layer werden projektweit gecacht statt pro Frame neu gezeichnet.
  • +
  • Renderloop bereinigt: Hintergrund-Layer gecacht und Zeichenpfad stärker auf echte Audio-/UI-Änderungen ausgerichtet.
  • +
  • Spectrogram deutlich entlastet: Phoenix liefert Spectro-Spalten nur noch bei echten neuen FFT-Schritten, Worker-Takt reduziert und interne Renderauflösung angepasst.
  • +
  • Waveform deutlich entlastet: Worker-Takt reduziert, interne Renderauflösung angepasst und Grid-Layer gecacht.
  • +
  • Peak History deutlich entlastet: Grid, Rahmen und Zeit-/Werteskalen werden gecacht; Verlauf und Live-Meter bleiben dynamisch.
  • +
  • Peak History in der Geometrie nachkorrigiert: `now` rechts sichtbar, linke Endzeit wird explizit gesetzt, Füllung läuft bis zum unteren Rahmen, Verlauf kann unter den letzten Tick weiterlaufen und der Plot beginnt links ohne künstliche Rahmenlücke.
  • +
  • Meter-Views weiter entschlackt: feste Skalen, Ticks, Referenzlinien und Labels von VU, PPM DIN, PPM EBU, RMS, TP und LUFS werden nicht mehr jedes Frame neu gerastert.
  • +
  • Phase Wheel entlastet: tote Trace-Berechnung entfernt und Phase-Trail intern ausgedünnt.
  • +
  • Goniometer/XY weiter bereinigt: RTW-Look beibehalten, Alpha-/Trail-Dichte entschlackt und echte XY-History in Phoenix so angepasst, dass `XY Punkte` wieder sinnvoll wirkt.
  • +
  • Split- und Quad-View bereinigt: statische Containerteile, leere Plots und Meterhüllen werden gecacht statt pro Frame neu aufgebaut.
  • +
  • Recorder- und Classic-Needles-View weiter entschlackt: statische Bühnen, Rahmen und Instrumentenkörper gecacht; beim Recorder zusätzlich unnötige HUD-Layout-Schreiberei reduziert.
  • +
  • RTA/Realtime entlastet: Plot-Grid, Rahmen und rechte Meterhülle werden gecacht, während Balken/Line und Hold/Peak live bleiben.
  • +
  • Projektweit weiterer Cleanup: tote Frontend-Restlogik entfernt, Referenz-Configs für Analyzer/Volumio ergänzt und Doku zu Analyzer-vs.-Volumio-Pfaden/Ports nachgezogen.
  • +
+
+
+
+
+ 3.0.1 (Phoenix 1.0) +
+
+

Veröffentlichung: 18.04.2026

+

Änderungen:

+
    +
  • PPM/VU/RMS-Alignment korrigiert: PPM DIN, PPM EBU, VU und RMS liegen bei den vorgesehenen Referenzpegeln wieder konsistent zueinander.
  • +
  • RTW-Loudness-Boxen auf dokumentierbaren Momentary-Pfad umgestellt: 400 ms Integration, K-gewichtete Signalbasis, pro Kanal; keine freie ungefilterte Hauskurve mehr.
  • +
  • RTW-Loudness-Offset global gemacht: Der Offset wird jetzt in Phoenix gespeichert und zwischen verbundenen Browsern synchron gehalten.
  • +
  • RTW-Oktavwahl lokalisiert: Die sichtbare RTW-Auflösung 1/3, 1/6 und 1/12 ist jetzt browserlokal und schaltet andere Sessions nicht mehr um.
  • +
  • Fehler in der lokalen 1/6-Oktavdarstellung behoben: Center-Frequenzen und Bandwerte laufen wieder sauber synchron.
  • +
  • Goniometer weiter auf klassische RTW-Anmutung geschärft: dichtere Spur, dickere Linien, geglättete Kurven; dieser Stil ist jetzt fest der Standard.
  • +
  • Volumio-/Raspberry-Pi-Startpfad robuster gemacht: Toolchain-, Netzwerk-, Build- und Session-Handling wurden für frische Geräte und getrennte Volumio-Services nachgezogen.
  • +
  • Build-Kompatibilität für ältere Zielsysteme nachgezogen: Lockfile-/Toolchain-Themen, ALSA-/pkg-config-Abhängigkeiten und plattformspezifische ALSA-Frame-Typen abgefangen.
  • +
  • Volumio-Pfad weiter bereinigt: Analyzer-GUI läuft dort getrennt auf eigenem Port, `toggle-ext` liegt im Phoenix-Baum und der Tab-Umschalter zeigt wieder auf die richtigen Ziele.
  • +
  • Projektlayout weiter bereinigt: `toggle-ext` und die Volumio-spezifischen Startdateien liegen jetzt konsistent im Phoenix-Baum.
  • +
+
+
+
+
+ 3.0 (Phoenix 1.0) +
+
+

Veröffentlichung: 18.04.2026

+

Änderungen:

+
    +
  • Kompletter Architektur-Umbau: Der Analyzer läuft jetzt nativ auf Phoenix statt auf WebAudio/AudioWorklet im Browser.
  • +
  • Phoenix ist neue Single Source of Truth für Audio, Analyse und globale Gerätekonfiguration; die Web-GUI dient nur noch als Anzeige- und Bedienoberfläche.
  • +
  • Audio-Pfad vollständig in Rust/ALSA verlagert: Browser greift nicht mehr selbst auf Audio-Hardware zu.
  • +
  • Analysepfade nach Phoenix übertragen: Meter, RMS, VU, PPM DIN/EBU, True Peak, LUFS/LRA, RTW-Loudness-Boxen, RTA, Spectrogram, Waveform, XY und Phase Wheel werden jetzt nativ im Backend erzeugt.
  • +
  • Recorder komplett auf Phoenix umgestellt: native Aufnahme in WAV, MP3 und WebM/Opus ohne Browser-Chunking oder nachträgliches Zusammensetzen im Frontend.
  • +
  • Direktes Speichern über Phoenix ergänzt: Aufnahmen können ohne Chrome-Download direkt auf dem Gerät im Dateisystem abgelegt werden.
  • +
  • Globale Optionen in Phoenix verankert: geräterelevante Parameter wie Kalibrierung, LUFS-/PPM-Verhalten, Recorder-Defaults und Screensaver-Verhalten werden zentral gespeichert und an verbundene Browser synchronisiert.
  • +
  • Waveform- und XY-Datenpfad auf native Phoenix-Frames umgestellt; Browser verarbeitet keine eigenen Audio-Samples mehr.
  • +
  • Normnähe der Messpfade überarbeitet: True Peak, LUFS, VU sowie mehrere Ballistik- und Bewertungswege rechnerisch an die relevanten Standards angenähert.
  • +
  • Komplette Bereinigung des alten WebAudio-Stacks: Browser-Recorder, MediaRecorder-/Chunk-Logik, AudioWorklet-Altpfade und nicht mehr benötigte Umschaltlogik wurden entfernt.
  • +
  • Deployment/Boot für Raspberry Pi neu strukturiert: separater Webservice, Kiosk-Startskripte, automatischer Phoenix-Build bei Quellcode-Änderungen sowie Build-Logging beim Start.
  • +
  • Projektstruktur auf Phoenix konsolidiert: Runtime-Skripte, `www` und systemd-Units liegen jetzt gemeinsam unter dem Phoenix-Baum.
  • +
+
+
+
+
+ 2.9 +
+
+

Veröffentlichung: 09.04.2026

+

Änderungen:

+
    +
  • Recorder: Auto-Split-Threshold jetzt als gekoppelte Zahlenfelder in dBFS und PPM DIN einstellbar.
  • +
  • Stoppuhr: Auto-Threshold jetzt ebenfalls als gekoppelte Zahlenfelder in dBFS und PPM DIN einstellbar.
  • +
  • Optionen: Zusätzlichen Scroll-Puffer im Optionen-Menü ergänzt, damit untere Eingabefelder trotz On-Screen-Tastatur weiter nach oben gezogen werden können.
  • +
+
+
+
+
+ 2.8 (HOTFIX) +
+
+

Veröffentlichung: 25.03.2026

+

Änderungen:

+
    +
  • Audio/Timing: Zusätzlichen Display-/Interpolationspuffer entfernt; Anzeigen werden wieder direkt aus dem AudioWorklet gespeist.
  • +
  • Low Latency: Worklet-UI-Intervall auf 8 ms reduziert und Renderpfad auf 60 FPS gestellt.
  • +
  • Performance: Audio- und Analysepfade berechnen nur noch, was im aktuellen View und in den sichtbaren Slots wirklich benötigt wird.
  • +
  • Cold Start: XY-, RTA-, Waveform- und Meter-Zustände werden beim Umschalten sauber zurückgesetzt, damit keine alten Daten kurz wieder auftauchen.
  • +
  • Performance: FFT- sowie LUFS-/Box-Analyser laufen nur noch bei tatsächlichem Sichtbarkeitsbedarf.
  • +
  • RTA: Neue Option für die Balken-Grundfarbe; Peak-Bereiche bleiben weiterhin rot.
  • +
  • RTA: „Real Time Hold“ und „Real Time Decay“ wirken jetzt tatsächlich auf die Balkenanzeige; höherer Decay lässt die Balken schneller zurückfallen.
  • +
  • UI: Zahlenfelder committen jetzt sauber per Change/Blur/Enter; Eingaben über On-Screen-Keyboard und VNC springen nicht mehr vorzeitig auf Min-/Max-Werte.
  • +
+
+
+
+
+ 2.5 +
+
+

Veröffentlichung: 22.03.2026

+

Änderungen:

+
    +
  • Fix: Classic Needles im Quad-View werden nun korrekt angezeigt.
  • +
  • Audio/Timing: Umstellung auf Audio-Zeitstempel und frame-synchron berechnete Display-Pakete zur Glättung der Anzeige und Reduktion von Drift/Jitter.
  • +
+
+
+
+
+ 2.3.7 +
+
+

Veröffentlichung: 04.02.2026

+

Änderungen:

+
    +
  • Fix: Audio-Pfad bleibt zuverlässig aktiv und verhindert „Stall“ nach langem Idle/Screensaver.
  • +
  • Fix: Auto-Recovery bei „Audio lost“ nach Screensaver-Wake und User-Activity.
  • +
+
+
+
+
+ 2.3.6 +
+
+

Veröffentlichung: 04.02.2026

+

Änderungen:

+
    +
  • Fix: Loudness/LUFS – rechter Kanal korrekt normalisiert.
  • +
  • Optimierung: LRA/Loudness ohne wiederholte Array-Allokationen (Ringpuffer + Scratch-Buffer) für weniger GC/CPU-Spikes.
  • +
  • Fix: Config-Load ist jetzt idempotent (verhindert doppeltes Laden beim Start/Options-Init).
  • +
  • PPM DIN: Peak-Hold implementiert (rein visuell), steuerbar über PPM_DIN_HOLD_MS und PPM_DIN_HOLD_DECAY_DB_PER_S.
  • +
  • Peak History: Historie als Ringpuffer statt copyWithin-Shift (weniger CPU, stabilere Darstellung).
  • +
+
+
+
+
+ 2.3.4 +
+
+

Veröffentlichung: 04.03.2026

+

Änderungen:

+
    +
+
+
+
+
+ 2.3.2 +
+
+

Veröffentlichung: 02.02.2026

+

Änderungen:

+
    +
  • Peak History: Nach dem Beenden des Bildschirmschoners wird die History nicht mehr in einem Frame mit einem Wert „aufgefüllt“ (kein Backfill nach Pause); nutzt nur frische Audiodaten.
  • +
  • XY / Korrelation: Neue Option „Sofortige Nullstellung wenn kein Pegel“ (setzt den Würfel bei Stille sofort auf 0, ohne Hold/Drift).
  • +
  • Stoppuhr (Slot‑Meter): Fix für Canvas‑State/Font‑Leak, der nach Aktivierung die Skalen‑Schrift in anderen Views riesig gemacht hat.
  • +
  • Audio/Timing: Anzeige-Update-Intervall reduziert und an die effektive Render‑FPS gekoppelt (verringert Jitter/Drift, verhindert Message‑Backlogs).
  • +
  • Default: FFT_SIZE auf 4096 reduziert (weniger CPU; betrifft v.a. Spectrogram/LUFS/FFT‑RTA).
  • +
  • Performance: Keine FFT‑Berechnung mehr, wenn kein Spectrogram aktiv ist und der Real Time Analyzer auf IIR läuft.
  • +
+
+
+
+
+ 2.3.1 +
+
+

Veröffentlichung: 30.01.2026

+

Änderungen:

+
    +
  • XY / Korrelation: Nach dem Beenden des Bildschirmschoners wird die Anzeige sauber zurückgesetzt und nur mit frischen Audiodaten berechnet (kein Sprung auf +1 ohne Signal).
  • +
+
+
+
+
+ 2.3 +
+
+

Veröffentlichung: 26.01.2026

+

Änderungen:

+
    +
  • Quad‑View: Klick‑Verhalten wählbar (direkt wechseln oder Popup‑Ansicht).
  • +
  • Quad‑View: Popup‑Ansicht rendert exakt wie die Einzel‑View (Layout/Skalen identisch); Tap/ESC schließt.
  • +
  • Split‑View: Klick‑Verhalten wählbar (direkt wechseln oder Popup‑Ansicht).
  • +
  • Split‑View: Popup‑Ansicht rendert exakt wie die Einzel‑View; Tap/ESC schließt.
  • +
+
+
+
+
+ 2.1 +
+
+

Veröffentlichung: 26.01.2026

+

Änderungen:

+
    +
  • Split‑View: kompaktere HUD‑Bedienung (schmalere Plot‑Dropdowns, Trennzeichen „|“).
  • +
  • Split‑View: Slot‑Zuordnung über Button + Popup (zeigt nur aktive Slots).
  • +
  • Split‑View: Popup schließt per Klick außerhalb oder ESC.
  • +
  • Split‑View: „Slot:“ wird nur angezeigt, wenn mindestens ein Slot aktiv ist.
  • +
  • Neu: Quad‑View (4 unabhängige Plots im 2×2‑Raster).
  • +
  • Quad‑View: Plot‑Auswahl über Popup; eigene Slot‑Konfiguration (unabhängig vom Split‑View).
  • +
  • Phase Wheel: Phase‑Box im Split‑View korrekt positioniert (stabil am Plot‑Panel, mit Abstand; Box kleiner).
  • +
  • Bildschirmschoner: Recorder‑UI/Modals sowie Slot‑Popup werden zuverlässig ausgeblendet.
  • +
+
+
+
+
+ 2.0 +
+
+

Veröffentlichung: 22.01.2026

+

Änderungen:

+
    +
  • Neu: Split‑View (zwei Plots nebeneinander, Links/Rechts wählbar).
  • +
  • Split‑View: optionale Meter‑Slots (0–3) mit frei wählbarer Position (links/mitte/rechts).
  • +
+
+
+
+
+ 1.8 +
+
+

Veröffentlichung: 17.01.2026

+

Änderungen:

+
    +
  • LUFS (Integrated): I‑Zeitfenster wird korrekt gespeichert und angewendet.
  • +
  • LUFS (Integrated): I bleibt bei Stille (unter −70 LUFS Gate) stehen und fällt nicht weiter ab.
  • +
  • Phasenrad: Zahlenanzeige folgt dem Zeiger besser (ruhiger, ohne Nachkommastelle).
  • +
  • Neu: Stoppuhr als auswählbarer Slot (Start/Stop/Reset/Auto, optional 7‑Segment‑Anzeige).
  • +
+
+
+
+
+ 1.7.8 +
+
+

Veröffentlichung: 16.01.2026

+

Änderungen:

+
    +
  • LUFS (Integrated): Zeitfenster per Slider (1–10 min) einstellbar.
  • +
+
+
+
+
+ 1.7.7 +
+
+

Veröffentlichung: 09.01.2026

+

Änderungen:

+
    +
  • Input-Offset: kanalgetrennte Einstellung (L/R).
  • +
  • LUFS: Lesbarkeit der Skala angepasst.
  • +
+
+
+
+
+ 1.7.5 +
+
+

Veröffentlichung: 31.12.2025

+

Änderungen:

+
    +
  • Phase Wheel: Phasenrad gespiegelt (z. B. +90° liegt rechts).
  • +
  • PPM DIN: Ballistik im eigenen AudioWorklet; Anzeige/Ballistik nach IEC 60268-10 Type I (DIN) korrigiert.
  • +
+
+
+
+
+ 1.7.3 +
+
+

Veröffentlichung: 29.12.2025

+

Neuerungen:

+
    +
  • LUFS: Skalen-Zahlen & Skalen-Striche in eigener Farbe einstellbar.
  • +
  • Phase Wheel: Phasenanzeige (Box links oben) größer und weiter links; Zahl zusätzlich geglättet und Update-Rate begrenzt (max. 10×/s) für bessere Lesbarkeit.
  • +
  • PPM DIN: RTW-Loudness-Offset als Schieberegler (−7 … +7 dB).
  • +
+
+
+
+
+ 1.7 (Auslieferungsversion) +
+
+

Veröffentlichung: 21.12.2025

+

Neuerungen:

+
    +
  • Slots können auf „(leer)“ gesetzt werden: der Slot wird ausgeblendet und die verbleibenden Anzeigen rücken nach (z. B. nutzt der Real Time Analyzer dann die volle Breite).
  • +
  • Peak History und Classic Needles benötigen einen belegten Slot; dort ist „(leer)“ deaktiviert.
  • +
+
+
+
+
+ 1.6 +
+
+

Veröffentlichung: 19.12.2025

+

Neuerungen:

+
    +
  • Neuer View: „Classic Needles“.
  • +
+
+
+
+
+ 1.5.5 +
+
+

Veröffentlichung: 08.12.2025

+

Neuerungen:

+
    +
  • Silence-Gate für Goniometer & Korrelation (RMS-basiert, Threshold einstellbar im XY-Menü).
  • +
  • Header-Werte pro Kanal: sauber zentriert über den Balken, Farbumschlag pro Kanal erst ab Rot-Schwelle.
  • +
  • Anzeige der vollen Pegel (auch unterhalb der Skala) mit sanfter Glättung der Zahlen.
  • +
  • RMS-Meter: hält jetzt den letzten gültigen Wert, kein kurzer Drop mehr auf den Floor bei sporadisch fehlenden RMS-Paketen.
  • +
+
+
+
+
+ 1.5 +
+
+

Veröffentlichung: 05.12.2025

+

Peak History – Neu hinzugefügt:

+
    +
  • Neuer „Peak History“-View: verlaufende Pegelanzeige mit Meter rechts daneben.
  • +
  • Konfigurierbare Scroll-Geschwindigkeit (½×, 1×, 2×, 4×, 6×) wie beim Spectrogram.
  • +
  • Farbgenaue Linie: Rot erst ab Warnschwelle (z. B. PPM_DIN_RED_START), Segment-Splitting genau auf der Schwelle.
  • +
  • Optionale Flächenfüllung unter/über der Linie in Linienfarbe; invertierbar.
  • +
  • Zeitskala passt sich der Scroll-Geschwindigkeit an (länger bei ½×, kürzer bei 2×/4×/6×).
  • +
  • Peak-History-Optionen im Menü: Scroll-Speed, Fläche füllen, Fläche invertieren.
  • +
+
+
+
+
+ 1.2 +
+
+

Veröffentlichung: 28.11.2025

+

Änderungen:

+
    +
  • Studio Clock: neue Stile „Analog + PPM“ und „Digital + PPM“; optional LED-Band für PPM.
  • +
  • PPM in der Clock nutzt DIN-Skala, Offsets und Ballistik identisch zum PPM-DIN-Meter.
  • +
  • PPM-Skalenfarben vereinheitlicht (DIN-Orange), Warnfarbe nur oberhalb 0 dB.
  • +
  • Neue Uhr-Funktion: Studio Clock mit Stilen „Analog“, „Digital“, „Analog + PPM“, „Digital + PPM“, optional LED-Band.
  • +
  • Neuer Bildschirmschoner: DVD/Starfield/Clock, Vorschau ignoriert Pegel (endet nur per Klick/Touch), Optionen gebündelt.
  • +
  • Clock- und Screensaver-Optionen sind unabhängig im Menü steuerbar.
  • +
+
+
+
+
+ 1.1.4.1 +
+
+

Veröffentlichung: 26.11.2025

+

Änderungen:

+
    +
  • Goniometer-Korrelationswürfel: Clamping angepasst (bleibt sauber innerhalb des Balkens)
  • +
  • PPM DIN: Skala renormiert (Bottom sitzt am Balkenfuß), %-Label korrigiert
  • +
  • PPM DIN: Loudness-Boxen verlängerte Skala (bis −70 dB), sichtbarer Teil nur oberhalb −50 dB
  • +
  • Input-Offset: Slider auf 0,5-dB-Schritte gestellt
  • +
  • Recorder: Einmaliger Hinweis/Bestätigung bei Erstaufruf (WAV-Limit, WebM/Opus-Hinweis, Beta)
  • +
  • LUFS: Farben pro I/M/S separat einstellbar
  • +
+
+
+
+
+ 1.1.4 +
+
+

Veröffentlichung: 25.11.2025

+

Änderungen:

+
    +
  • XY-Optionen wiederhergestellt und zugeordnet (Punkte/Stil/Korrelation/Gain/Fade/Gap)
  • +
  • XY Default-Punkte auf 1024 gesetzt; Hinweise ergänzt
  • +
  • Spectrogram/Waveform/Phase Wheel: Beschreibungen ergänzt
  • +
  • Spectrogram: Worker stabilisiert (Ringpuffer, Draw-Loop-Fix)
  • +
+
+
+
+
+ 1.1.3.2 +
+
+

Veröffentlichung: 25.11.2025

+

Änderungen:

+
    +
  • Fix: Spectrogram-Worker stabilisiert (kein Stoppen nach wenigen Sekunden)
  • +
+
+
+
+
+ 1.1.3.1 +
+
+

Veröffentlichung: 25.11.2025

+

Änderungen:

+
    +
  • Fix: RTW-Loudness-Boxen im PPM DIN Meter (fehlendes Timestamp-Init behoben)
  • +
+
+
+
+
+ 1.1.3 +
+
+

Veröffentlichung: 25.11.2025

+

Änderungen:

+
    +
  • Optionen-Überschrift umbenannt & unterstrichen
  • +
  • RMS: IEC-Zeitkonstanten (Impulse/Fast/Slow) auswählbar, EWMA entfernt
  • +
+
+
+
+
+ 1.1.2 +
+
+

Veröffentlichung: 25.11.2025

+

Änderungen:

+
    +
  • Einführung: Changelog im Optionen-Menü
  • +
  • Korrektur: PPM DIN & PPM EBU ohne doppelte Ballistik (UI-Glättung entfernt)
  • +
  • Korrektur: RMS-Anzeige ohne zusätzliche Glättung
  • +
+
+
+
+
+ 1.1 +
+
+

Veröffentlichung: 25.11.2025

+

Änderungen:

+
    +
  • Hinzugefügt: RTW-Style Loudnessanzeige auf PPM DIN Meter
  • +
+
+
+
+
+ 1.0 +
+
+

Erste Veröffentlichung: 21.11.2025

+
+
+
+
diff --git a/www/core/canvas_surface.js b/www/core/canvas_surface.js new file mode 100644 index 0000000..a25fc11 --- /dev/null +++ b/www/core/canvas_surface.js @@ -0,0 +1,18 @@ +// Gemeinsame Erzeugung temporärer Canvas-Flächen für Views und Meter. +export function createCanvasSurface(width, height) { + const w = Math.max(1, Math.ceil(width)); + const h = Math.max(1, Math.ceil(height)); + if (typeof OffscreenCanvas === 'function') { + const canvas = new OffscreenCanvas(w, h); + const ctx = canvas.getContext('2d'); + if (ctx) return { canvas, ctx, width: w, height: h }; + } + if (typeof document !== 'undefined') { + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + if (ctx) return { canvas, ctx, width: w, height: h }; + } + return null; +} diff --git a/www/core/registry.js b/www/core/registry.js index ea68d84..7fee3a5 100644 --- a/www/core/registry.js +++ b/www/core/registry.js @@ -1,5 +1,6 @@ // core/registry.js — Lazy Loader + Safe Runner für Meter // Lädt Meter-Module on-demand (oder via registerMeter) und kapselt Fehler. +import { createCanvasSurface } from './canvas_surface.js'; const cache = { meters: new Map(), // id -> module @@ -7,26 +8,12 @@ const cache = { const meterRenderCache = new Map(); // key -> surface const METER_CACHE_MARGIN = 32; +const METER_RENDER_CACHE_LIMIT = 48; let activeFrameStamp = 0; -const CAN_USE_OFFSCREEN = typeof OffscreenCanvas === 'function'; - function createCacheSurface(width, height) { - const w = Math.max(1, Math.ceil(width)); - const h = Math.max(1, Math.ceil(height)); - if (CAN_USE_OFFSCREEN) { - const canvas = new OffscreenCanvas(w, h); - const ctx = canvas.getContext('2d'); - if (ctx) return { canvas, ctx, width: w, height: h, stamp: -1 }; - } - if (typeof document !== 'undefined') { - const canvas = document.createElement('canvas'); - canvas.width = w; - canvas.height = h; - const ctx = canvas.getContext('2d'); - if (ctx) return { canvas, ctx, width: w, height: h, stamp: -1 }; - } - return null; + const surface = createCanvasSurface(width, height); + return surface ? { ...surface, stamp: -1 } : null; } function cacheKeyForMeter(id, rect) { @@ -40,10 +27,17 @@ function cacheKeyForMeter(id, rect) { function ensureCacheSurface(id, rect) { const key = cacheKeyForMeter(id, rect); let surface = meterRenderCache.get(key); + if (surface) { + meterRenderCache.delete(key); + meterRenderCache.set(key, surface); + } if (!surface) { surface = createCacheSurface(rect.w + METER_CACHE_MARGIN * 2, rect.h + METER_CACHE_MARGIN * 2); if (!surface) return null; meterRenderCache.set(key, surface); + while (meterRenderCache.size > METER_RENDER_CACHE_LIMIT) { + meterRenderCache.delete(meterRenderCache.keys().next().value); + } } if (!surface.canvas || !surface.ctx) return null; const w = Math.max(1, Math.ceil(rect.w + METER_CACHE_MARGIN * 2)); diff --git a/www/core/utils.js b/www/core/utils.js index c3c305a..156434b 100644 --- a/www/core/utils.js +++ b/www/core/utils.js @@ -250,7 +250,9 @@ export function drawDbfsGridRect(ctx, x0, y0, w, h, CONFIG, nyq, opts = {}) { colorMinor = 'rgba(30,42,53,.55)', labelColor = '#8fd3d4', freqTicks = null, + freqTickPositions = null, freqMin = 20, + xLeftExtension = 0, refDb = null, refStyle = 'rgba(0,231,255,0.35)', refDash = [4, 3], @@ -261,6 +263,12 @@ export function drawDbfsGridRect(ctx, x0, y0, w, h, CONFIG, nyq, opts = {}) { const _g = Number(CONFIG && CONFIG.AXIS_GUTTER_LEFT); const gutterL = Number.isFinite(_g) ? Math.max(8, _g) : 14; + const requestedExtension = Number(xLeftExtension); + const leftExtension = Number.isFinite(requestedExtension) + ? Math.max(0, Math.min(gutterL, requestedExtension)) + : 0; + const contentX = x0 - leftExtension; + const contentW = w + leftExtension; ctx.save(); ctx.strokeStyle = '#00e7ff'; ctx.lineWidth = 2; ctx.strokeRect(x0 - gutterL, y0, w + gutterL, h); @@ -272,7 +280,7 @@ export function drawDbfsGridRect(ctx, x0, y0, w, h, CONFIG, nyq, opts = {}) { const y = yFromDbfs(v, y0, y0 + h, CONFIG); const labelY = Math.min(y0 + h - 4, Math.max(y0 + 12, y + 4)); ctx.strokeStyle = colorMajor; ctx.lineWidth = 1.2; - ctx.beginPath(); ctx.moveTo(x0, y); ctx.lineTo(x0 + w, y); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(contentX, y); ctx.lineTo(contentX + contentW, y); ctx.stroke(); ctx.textAlign = 'right'; ctx.fillText(String(v), (x0 - gutterL) - 6, labelY); } @@ -282,8 +290,8 @@ export function drawDbfsGridRect(ctx, x0, y0, w, h, CONFIG, nyq, opts = {}) { ctx.strokeStyle = refStyle; ctx.setLineDash(Array.isArray(refDash) ? refDash : [4, 3]); ctx.beginPath(); - ctx.moveTo(x0, yRef); - ctx.lineTo(x0 + w, yRef); + ctx.moveTo(contentX, yRef); + ctx.lineTo(contentX + contentW, yRef); ctx.stroke(); ctx.setLineDash([]); } @@ -299,9 +307,15 @@ export function drawDbfsGridRect(ctx, x0, y0, w, h, CONFIG, nyq, opts = {}) { const logMin = Math.log10(minFreq); const logSpan = Math.max(1e-6, Math.log10(nyq) - logMin); for (const f of ticks) { - const x = x0 + ((Math.log10(Math.max(f, minFreq)) - logMin) / logSpan) * w; + const requestedPosition = freqTickPositions && typeof freqTickPositions === 'object' + ? Number(freqTickPositions[String(f)]) + : NaN; + const fraction = Number.isFinite(requestedPosition) + ? Math.max(0, Math.min(1, requestedPosition)) + : ((Math.log10(Math.max(f, minFreq)) - logMin) / logSpan); + const x = contentX + fraction * contentW; // Linken Rand nicht doppeln: keine Vertikal-Linie auf dem linken Plot-Rand - if (x > x0 + 0.75) { + if (x > contentX + 0.75) { ctx.strokeStyle = '#131b24'; ctx.beginPath(); ctx.moveTo(x, y0); ctx.lineTo(x, y0 + h); ctx.stroke(); } diff --git a/www/index.html b/www/index.html index 8a7d709..71e7172 100644 --- a/www/index.html +++ b/www/index.html @@ -13,7 +13,7 @@
- @@ -34,14 +34,14 @@
- - - - - + + + + +
- +
- +
- +
@@ -131,19 +131,19 @@
- +
- +
- +
- +
@@ -177,7 +177,7 @@ Setzt alle Slot-Belegungen (alle Views) auf die Default-Werte zurück.
- Basis-FFT für alle Analyser @@ -195,7 +195,7 @@
- + Globale Farbe für Header-Text und Header-Zahlen der Meter.
@@ -217,13 +217,13 @@
- + Gemessene Korrektur in Samples; negative Werte verzögern den rechten, positive den linken Kanal.
- + Basis-URL des Phoenix-Dienstes. Nach Änderung wird Audio neu verbunden.
@@ -255,18 +255,18 @@
- + Farbe fuer Clock- und DVD-Screensaver.
- + Legt fest, wie lange der Pegel unter der Schwelle sein muss (0 = sofort bei Schalter-Aus)
- + Pegel über dieser Schwelle halten den Schoner wach
@@ -280,24 +280,24 @@
- Anzeigeform (Balken vs. Linie)
- + Peak hold Zeit
- + Decay pro Sekunde
- @@ -313,7 +313,7 @@
- @@ -321,26 +321,26 @@ Im RTW-Profil stehen 1/3, 1/6 und 1/12 Oktave mit der IIR-Filterbank zur Verfügung.
- + Filtergüte – Ordnung des IIR-Filters
- + Feste Standardreaktion: 125 ms
- + Feste Standardreaktion: 1 Sekunde
- Messband
- @@ -348,11 +348,11 @@
- + Grundfarbe der Balken; Peak-Bereiche bleiben rot
- @@ -360,14 +360,14 @@ Pegelgewichtung
- RTW-Detektortyp: RMS mit gewählter Response oder feste 10-ms-Peakintegration
- @@ -384,7 +384,7 @@ Auswahl der angezeigten Mess- und Hold-Kurven
- @@ -393,15 +393,15 @@ Hold gibt nach Ablauf direkt frei; Hold + Fall läuft danach weich zurück; Manuell hält bis zum Reset.
- + Dauer der Hold-Anzeige
- + Rücklaufgeschwindigkeit im Modus Hold + Fall
- + Zusatzhalt für Anzeige
@@ -425,7 +425,7 @@
- @@ -441,7 +441,7 @@
- @@ -471,7 +471,7 @@
- @@ -480,7 +480,7 @@
- @@ -525,7 +525,7 @@
- + Default: -75 dBFS RMS (Mono)
@@ -585,7 +585,7 @@
- @@ -605,14 +605,14 @@
-
- @@ -628,7 +628,7 @@
- @@ -645,7 +645,7 @@
- @@ -655,7 +655,7 @@
- @@ -668,7 +668,7 @@
- @@ -677,7 +677,7 @@
- @@ -690,7 +690,7 @@
- @@ -698,7 +698,7 @@
- @@ -711,7 +711,7 @@
- @@ -724,14 +724,14 @@
-
- @@ -746,7 +746,7 @@
- @@ -761,7 +761,7 @@
- @@ -776,7 +776,7 @@
- @@ -791,7 +791,7 @@
- @@ -800,7 +800,7 @@
- @@ -813,7 +813,7 @@
- @@ -821,7 +821,7 @@
- @@ -834,7 +834,7 @@
- @@ -842,7 +842,7 @@
- @@ -855,7 +855,7 @@
- @@ -868,12 +868,12 @@
- + Lädt lokal auf diesem Rechner herunter oder speichert direkt auf Phoenix/Pi.
- @@ -882,7 +882,7 @@
- @@ -913,12 +913,12 @@
- + Schwelle für Signal/Ruhe im Recorder-Auto-Modus in dBFS.
- + Alternative Eingabe in PPM DIN; beide Felder bleiben gekoppelt.
@@ -938,7 +938,7 @@
- + Farbe der Punkte und des Rings
@@ -963,7 +963,7 @@
- @@ -979,7 +979,7 @@
- @@ -1011,17 +1011,17 @@ Deaktiviert: 0 VU liegt direkt auf 0 dBu (Digital-Referenz).
- typ. -18 + typ. -18
- addiert auf VU + addiert auf VU
- + Peak-Hold Zeit
- + Abfall pro Sekunde
@@ -1032,7 +1032,7 @@ Blendet „Volume Units“ aus und zeigt oben den aktuellen Wert
- +
@@ -1098,7 +1098,7 @@ True Peak
- +
- +
@@ -1116,10 +1116,10 @@ Warn
- +
- +
@@ -1129,7 +1129,7 @@
- @@ -1148,13 +1148,13 @@
- +
- +
- +
- +
@@ -1186,15 +1186,15 @@ LUFS
- + typ. -1.0
- + typ. -2.0
- + typ. -5.0
@@ -1228,7 +1228,7 @@ Stoppuhr
- @@ -1236,12 +1236,12 @@
- + Schwelle für automatisches Start/Stop der Stoppuhr in dBFS.
- + Alternative Eingabe in PPM DIN; beide Felder bleiben gekoppelt.
@@ -1257,596 +1257,9 @@

Hinweis: Online Update ist nur für die reine Analyzer-Installation gedacht. In der Volumio-Variante bitte per Snapshot bzw. manuell aktualisieren.

-
- Version / Änderungen -
-
- 3.8 (Phoenix 1.0) -
-
-

Veröffentlichung: 21.07.2026

-

Änderungen:

-
    -
  • Analysegrundlage: Die tiefgehende Code- und DSP-Prüfung, durch die die nachfolgenden Fehler und Verbesserungsmöglichkeiten gefunden werden konnten, wurde durch den Umstieg auf das LLM-Modell GPT-5.6 SOL ermöglicht.
  • -
  • Echtzeit-Datenweg: WebSocket-Verarbeitung auf „latest value wins“ umgestellt. Messwerte, Spektrogramm sowie Goniometer/Waveform verwenden getrennte, begrenzte Datenwege; alte Frames können keine anwachsende Anzeigeverzögerung mehr bilden.
  • -
  • Browserlast: Single-Slot-Puffer und Sequenzprüfung für Mess-, Spektrogramm- und Visualisierungsdaten ergänzt. Große Rohsamplefelder werden nicht mehr in jedem JSON-Messpaket wiederholt.
  • -
  • Spektrogramm: Langzeitstillstand und stotterndes Nachholen beseitigt. Inkrementelles Spaltenzeichnen, Worker-ACK, Watchdog/Neustart und begrenztes Überspringen veralteter Spalten ergänzt. 0,5×, 1×, 2×, 4× und 6× besitzen nun eine feste, FFT- und DPI-unabhängige Zeitbasis.
  • -
  • DIN-/EBU-PPM: Blockunabhängige Quasi-Peak-Detektoren mit bandbegrenzter Interpolation, korrekter Attack- und Rücklaufballistik sowie automatischen Tonburst-, Frequenzgang- und Polaritätstests implementiert. Eine zweite Browser-Anstiegsballistik und der alte nicht normgerechte DIN-Fast-Attack-Sonderweg wurden vollständig entfernt.
  • -
  • RTW-RTA: RTW-Profil verbindlich auf die IIR-Oktavteilband-Filterbank festgelegt. Bis zur Vergleichsmessung am realen RTW-Gerät sind die bisherige Phoenix/RTW-Charakteristik und vollständige Butterworth-Bandpässe sechster Ordnung getrennt auswählbar.
  • -
  • RTA-Detektor und Reaktionszeit: Average (RMS) und Peak (gleitendes 10-ms-Maximum) sind nun getrennt von Fast, Medium, Slow und Impulse wählbar. Fast wurde auf 125 ms korrigiert; Impulse verwendet 35 ms Anstieg und 1,5 s Rücklauf. Die IIR-Detektoren arbeiten samplegenau und unabhängig von der ALSA-Periodengröße. Alte Einstellungen werden automatisch migriert.
  • -
  • RTA-Auflösung: Umschaltung zwischen 1/3, 1/6 und 1/12 repariert. Alle drei Auflösungen bleiben im RTW-Profil aktiv und verwenden dessen IIR-Filterbank, Darstellung und Ballistik.
  • -
  • Hardwareparameter: Tatsächlich von ALSA ausgehandelte Samplerate, Perioden- und Puffergröße werden nun durch DSP, Aufnahme, Status und Browser verwendet; die feste 48-kHz-Annahme wurde entfernt.
  • -
  • Goniometer: Nur neue XY-Samplepaare werden mit einer periodengrößenunabhängigen mittleren Rate von 60 Hz übertragen. Punktbegrenzung wirkt bereits auf den Transport; begrenzte und wiederverwendbare Spurpuffer reduzieren Speicher- und Zeichenlast.
  • -
  • Goniometer-Persistenz: Reproduzierbare Fast-, Medium- und Slow-Profile sowie ein freies Phoenix-Fade ergänzt. Künstliche Bézier-Verformung der Messspur entfernt; AGC und Silence-Gate arbeiten zeit- beziehungsweise fensterbasiert.
  • -
  • Korrelation: Kontinuierliche DSP-Messung aus L², R² und L·R mit wählbaren 1,0/2,5 Sekunden ergänzt. Bildratenabhängige Doppelglättung entfernt; Negative-Peak-Memory, Marker und manueller Reset hinzugefügt.
  • -
  • Phasenrad: Bandpass, Hilbert-Transformation und energiegewichtete L/R-Phasenmittelung laufen nun samplekontinuierlich im Audiokern statt auf ausgedünnten Browser-XY-Punkten. Paketgrenzen beeinflussen den Winkel nicht mehr; Glättung und AGC arbeiten zeitbasiert, und die doppelte AGC-Verstärkung wurde entfernt.
  • -
  • True Peak, RMS und VU: True Peak verwendet den vierphasigen Referenz-FIR aus ITU-R BS.1770, besteht die EBU-Testfälle 15 bis 19 und zeigt Übersteuerungen in allen Ansichten bis +6 dBTP an. True RMS integriert samplekontinuierlich im Leistungsbereich mit Fast (125 ms), Slow (1 s) oder einem gleitenden 300-ms-Fenster; Browser-Doppelglättung und der RMS-fremde Impulse-Modus wurden entfernt. VU verwendet ein Moving-Coil-Modell mit 300-ms-Sprungantwort und 1 bis 1,5 % Überschwingen; der fälschliche Standardoffset von +1,92 dB wurde auf 0 dB korrigiert.
  • -
  • Qualitätssicherung: Automatische Regressionstests für PPM, RTA, A/C/Z, mehrere Sampleraten, Korrelationssignale, Goniometertaktung, Binärprotokolle sowie Spektrogramm-Zeitbasis und Langlauf ergänzt.
  • -
-
-
-
-
- 3.3.5 (Phoenix 1.0) -
-
-

Veröffentlichung: 28.04.2026

-

Änderungen:

-
    -
  • Stoppuhr: Löschfunktion verbessert. Die X-Buttons neben den einzelnen Zeiten lassen sich jetzt über einen separaten X-Button ein- und ausblenden; Reset und Löschmodus sind optisch getrennt.
  • -
  • Online Update: Hinweis ergänzt, dass die Funktion nur für die reine Analyzer-Installation gedacht ist; Volumio-Systeme sollen per Snapshot bzw. manuell aktualisiert werden.
  • -
-
-
-
-
- 3.3.4 (Phoenix 1.0) -
-
-

Veröffentlichung: 24.04.2026

-

Änderungen:

-
    -
  • Studio-Clock und Clock-Screensaver beim Sekundenblinken angepasst: Der Doppelpunkt läuft jetzt mit exakt 50 % Duty Cycle und schaltet auf der Sekunde an sowie in der zweiten Halbsekunde wieder aus.
  • -
  • Online Update robuster gemacht: Update-Skripte laufen jetzt mit sicherer Fallback-Locale, damit fehlendes de_DE.UTF-8 auf Volumio keine bash-setlocale-Warnung mehr als Fehler anzeigt.
  • -
-
-
-
-
- 3.3.2 (Phoenix 1.0) -
-
-

Veröffentlichung: 21.04.2026

-

Änderungen:

-
    -
  • INFO: Ein extern beobachtetes Start-/WebSocket-Problem in älteren Safari-Versionen lag nicht an Phoenix selbst, sondern an einem Safari-/WebKit-Bug; nach Safari-Update funktionierte der Analyzer wieder normal.
  • -
  • Recorder-Liste auf große Browserfenster verbessert: Die sichtbare Listenhöhe passt sich jetzt dynamisch an die tatsächlich verfügbare Fläche an, statt früh auf eine starre Anzahl sichtbarer Einträge zu begrenzen.
  • -
  • Recorder-Layout unten entschärft: Vor Timer und Buttons bleibt nun mehr Reserve, damit der letzte sichtbare Eintrag nicht in den Button-Bereich hineinragt, bevor gescrollt werden muss.
  • -
  • Stoppuhr-Liste ebenfalls dynamisiert: Die Anzahl sichtbarer Zeiten wird jetzt direkt aus der verfügbaren Meter-Höhe berechnet und reagiert sauberer auf unterschiedlich große Browserfenster.
  • -
  • Stoppuhr-Layout bei kleiner Höhe geglättet: Die unteren Start-/Reset-/Auto-Buttons schrumpfen bei knapper Höhe mit, damit die Liste beim Verkleinern nicht mehr schlagartig verschwindet.
  • -
  • Phoenix-Metrics-Stream robuster gemacht: Bei kurzzeitigem Rückstau werden verpasste Frames jetzt verworfen, ohne den WebSocket sofort zu beenden; zusätzlich wurde der interne Metrics-Puffer vergrößert.
  • -
-
-
-
-
- 3.3.1 (Phoenix 1.0) -
-
-

Veröffentlichung: 20.04.2026

-

Änderungen:

-
    -
  • Layout- und Preset-System weiter ausgebaut: Presets wurden im Optionen-Menü auf eine eigene Ebene gehoben und die Layout-Slots lassen sich zusätzlich direkt aktivieren.
  • -
  • Layout-Boot per URL stabilisiert: Toolbar, aktiver View und geladene Ansicht bleiben bei ?layout=1 und den übrigen Layout-Slots jetzt konsistenter synchron.
  • -
  • Layout-Speicherung korrigiert: Die Slot-Belegungen der Views werden beim Speichern eines Layout-Slots jetzt zuverlässig mitgesichert und beim Laden wiederhergestellt.
  • -
  • INFO: Ein zwischenzeitlich beobachtetes externes Start-/WebSocket-Problem in älteren Safari-Versionen lag nicht an Phoenix selbst, sondern an einem Safari-/WebKit-Bug; nach einem Safari-Update funktionierte der Analyzer wieder normal.
  • -
  • Weitere Anzeigeoptionen globalisiert: RTA-Oktaven, Peak-History-Optionen, Phase-Wheel-Optionen, Classic-Needles-Quelle, historische RTW-Loudness sowie die konfigurierbaren Farben laufen nun browserübergreifend über die globale Phoenix-Konfiguration.
  • -
  • Quad-/Registry-Pfad korrigiert: Peak History bleibt auch im Quad-View mit gültigen Live-Daten versorgt und fällt dort nicht mehr scheinbar auf 0 zurück.
  • -
  • Online-Update ergänzt: Phoenix kann eine feste Update-ZIP laden, in einen Stage-Ordner vorbereiten und beim Neustart mit Backup, Health-Check und automatischem Rollback auf den alten Stand umschalten.
  • -
  • Online-Update weiter gehärtet: Vor dem Neustart der Kiosk-Browser werden nur die Asset-Caches geleert, damit nach einem Update zuverlässig der neue Web-Stand geladen wird, ohne die eigentlichen Einstellungen zu verlieren.
  • -
  • Online-Update-Logging ergänzt: Download-, Entpack-, Stage-, Neustart- und Rollback-Schritte werden jetzt in eine gemeinsame Update-Logdatei geschrieben und können zusätzlich über einen Phoenix-Endpunkt direkt abgefragt werden.
  • -
-
-
-
-
- 3.3 (Phoenix 1.0) -
-
-

Veröffentlichung: 20.04.2026

-

Änderungen:

-
    -
  • Software-Presets weiter ausgebaut: Default, Claus und Michael lassen sich jetzt robuster speichern, laden, exportieren und importieren.
  • -
  • Preset-Speicherung bleibt aus Sicherheitsgründen auf den Analyzer direkt beschränkt; externe Browser dürfen Presets laden, aber nicht überschreiben.
  • -
  • Phoenix-URL-/Host-Pfad für Presets gehärtet: Wenn eine gespeicherte Loopback-URL für den aktuellen Browser nicht passt, wird sauber auf den aktuellen Analyzer-Host zurückgefallen.
  • -
  • Preset-Ladepfad stabilisiert: Benutzer-Presets übernehmen jetzt auch den getrennt gehaltenen UI-/View-Zustand zuverlässiger und laden nach Profil-/Cache-Reset wieder nachvollziehbar.
  • -
  • Neue Layout-Slots ergänzt: Vier separate Layout-Presets speichern reine Ansichts-/Fensterbelegungen, unabhängig von globalen Software-Presets.
  • -
  • Layout-Slots werden außerhalb des Browser-Profils serverseitig gespeichert und können per URL-Parameter wie ?layout=1 direkt in einem Fenster geladen werden.
  • -
  • Options-Menü neu geordnet: Unter Meters → Presets liegen jetzt sowohl die Software-Presets als auch die neuen Layout-Slots gebündelt an einer Stelle.
  • -
  • Erststart-Dialog überarbeitet: statt des alten Kalibrierhinweises erscheint nun eine neutrale Willkommensmeldung zum Phoenix Analyzer.
  • -
  • Header-Farbpfad vereinheitlicht: Header-Text/-Zahlen entkoppelt von Balkenfarben und zusätzlich über eine globale Header-Textfarbe einstellbar.
  • -
  • Waveform-Farben reagieren jetzt wieder korrekt auf Änderungen in den Optionen, auch im Worker-Pfad.
  • -
  • Split-/Quad-Views erweitert: Waveform und Spectrogram können dort als Plot verwendet werden; eingebettete Geometrie und Darstellung wurden dafür nachgezogen.
  • -
  • Weitere UI-/Detailkorrekturen: Peak-History-Geometrie, RTA-/Meter-Beschriftungen, PPM-DIN-%-Skala, Recorder-/Stoppuhr-Header sowie diverse kleinere Konsistenzfehler wurden bereinigt.
  • -
-
-
-
-
- 3.1 (Phoenix 1.0) -
-
-

Veröffentlichung: 18.04.2026

-

Änderungen:

-
    -
  • GUI-Performance systematisch überarbeitet: statische Layer werden projektweit gecacht statt pro Frame neu gezeichnet.
  • -
  • Renderloop bereinigt: Hintergrund-Layer gecacht und Zeichenpfad stärker auf echte Audio-/UI-Änderungen ausgerichtet.
  • -
  • Spectrogram deutlich entlastet: Phoenix liefert Spectro-Spalten nur noch bei echten neuen FFT-Schritten, Worker-Takt reduziert und interne Renderauflösung angepasst.
  • -
  • Waveform deutlich entlastet: Worker-Takt reduziert, interne Renderauflösung angepasst und Grid-Layer gecacht.
  • -
  • Peak History deutlich entlastet: Grid, Rahmen und Zeit-/Werteskalen werden gecacht; Verlauf und Live-Meter bleiben dynamisch.
  • -
  • Peak History in der Geometrie nachkorrigiert: `now` rechts sichtbar, linke Endzeit wird explizit gesetzt, Füllung läuft bis zum unteren Rahmen, Verlauf kann unter den letzten Tick weiterlaufen und der Plot beginnt links ohne künstliche Rahmenlücke.
  • -
  • Meter-Views weiter entschlackt: feste Skalen, Ticks, Referenzlinien und Labels von VU, PPM DIN, PPM EBU, RMS, TP und LUFS werden nicht mehr jedes Frame neu gerastert.
  • -
  • Phase Wheel entlastet: tote Trace-Berechnung entfernt und Phase-Trail intern ausgedünnt.
  • -
  • Goniometer/XY weiter bereinigt: RTW-Look beibehalten, Alpha-/Trail-Dichte entschlackt und echte XY-History in Phoenix so angepasst, dass `XY Punkte` wieder sinnvoll wirkt.
  • -
  • Split- und Quad-View bereinigt: statische Containerteile, leere Plots und Meterhüllen werden gecacht statt pro Frame neu aufgebaut.
  • -
  • Recorder- und Classic-Needles-View weiter entschlackt: statische Bühnen, Rahmen und Instrumentenkörper gecacht; beim Recorder zusätzlich unnötige HUD-Layout-Schreiberei reduziert.
  • -
  • RTA/Realtime entlastet: Plot-Grid, Rahmen und rechte Meterhülle werden gecacht, während Balken/Line und Hold/Peak live bleiben.
  • -
  • Projektweit weiterer Cleanup: tote Frontend-Restlogik entfernt, Referenz-Configs für Analyzer/Volumio ergänzt und Doku zu Analyzer-vs.-Volumio-Pfaden/Ports nachgezogen.
  • -
-
-
-
-
- 3.0.1 (Phoenix 1.0) -
-
-

Veröffentlichung: 18.04.2026

-

Änderungen:

-
    -
  • PPM/VU/RMS-Alignment korrigiert: PPM DIN, PPM EBU, VU und RMS liegen bei den vorgesehenen Referenzpegeln wieder konsistent zueinander.
  • -
  • RTW-Loudness-Boxen auf dokumentierbaren Momentary-Pfad umgestellt: 400 ms Integration, K-gewichtete Signalbasis, pro Kanal; keine freie ungefilterte Hauskurve mehr.
  • -
  • RTW-Loudness-Offset global gemacht: Der Offset wird jetzt in Phoenix gespeichert und zwischen verbundenen Browsern synchron gehalten.
  • -
  • RTW-Oktavwahl lokalisiert: Die sichtbare RTW-Auflösung 1/3, 1/6 und 1/12 ist jetzt browserlokal und schaltet andere Sessions nicht mehr um.
  • -
  • Fehler in der lokalen 1/6-Oktavdarstellung behoben: Center-Frequenzen und Bandwerte laufen wieder sauber synchron.
  • -
  • Goniometer weiter auf klassische RTW-Anmutung geschärft: dichtere Spur, dickere Linien, geglättete Kurven; dieser Stil ist jetzt fest der Standard.
  • -
  • Volumio-/Raspberry-Pi-Startpfad robuster gemacht: Toolchain-, Netzwerk-, Build- und Session-Handling wurden für frische Geräte und getrennte Volumio-Services nachgezogen.
  • -
  • Build-Kompatibilität für ältere Zielsysteme nachgezogen: Lockfile-/Toolchain-Themen, ALSA-/pkg-config-Abhängigkeiten und plattformspezifische ALSA-Frame-Typen abgefangen.
  • -
  • Volumio-Pfad weiter bereinigt: Analyzer-GUI läuft dort getrennt auf eigenem Port, `toggle-ext` liegt im Phoenix-Baum und der Tab-Umschalter zeigt wieder auf die richtigen Ziele.
  • -
  • Projektlayout weiter bereinigt: `toggle-ext` und die Volumio-spezifischen Startdateien liegen jetzt konsistent im Phoenix-Baum.
  • -
-
-
-
-
- 3.0 (Phoenix 1.0) -
-
-

Veröffentlichung: 18.04.2026

-

Änderungen:

-
    -
  • Kompletter Architektur-Umbau: Der Analyzer läuft jetzt nativ auf Phoenix statt auf WebAudio/AudioWorklet im Browser.
  • -
  • Phoenix ist neue Single Source of Truth für Audio, Analyse und globale Gerätekonfiguration; die Web-GUI dient nur noch als Anzeige- und Bedienoberfläche.
  • -
  • Audio-Pfad vollständig in Rust/ALSA verlagert: Browser greift nicht mehr selbst auf Audio-Hardware zu.
  • -
  • Analysepfade nach Phoenix übertragen: Meter, RMS, VU, PPM DIN/EBU, True Peak, LUFS/LRA, RTW-Loudness-Boxen, RTA, Spectrogram, Waveform, XY und Phase Wheel werden jetzt nativ im Backend erzeugt.
  • -
  • Recorder komplett auf Phoenix umgestellt: native Aufnahme in WAV, MP3 und WebM/Opus ohne Browser-Chunking oder nachträgliches Zusammensetzen im Frontend.
  • -
  • Direktes Speichern über Phoenix ergänzt: Aufnahmen können ohne Chrome-Download direkt auf dem Gerät im Dateisystem abgelegt werden.
  • -
  • Globale Optionen in Phoenix verankert: geräterelevante Parameter wie Kalibrierung, LUFS-/PPM-Verhalten, Recorder-Defaults und Screensaver-Verhalten werden zentral gespeichert und an verbundene Browser synchronisiert.
  • -
  • Waveform- und XY-Datenpfad auf native Phoenix-Frames umgestellt; Browser verarbeitet keine eigenen Audio-Samples mehr.
  • -
  • Normnähe der Messpfade überarbeitet: True Peak, LUFS, VU sowie mehrere Ballistik- und Bewertungswege rechnerisch an die relevanten Standards angenähert.
  • -
  • Komplette Bereinigung des alten WebAudio-Stacks: Browser-Recorder, MediaRecorder-/Chunk-Logik, AudioWorklet-Altpfade und nicht mehr benötigte Umschaltlogik wurden entfernt.
  • -
  • Deployment/Boot für Raspberry Pi neu strukturiert: separater Webservice, Kiosk-Startskripte, automatischer Phoenix-Build bei Quellcode-Änderungen sowie Build-Logging beim Start.
  • -
  • Projektstruktur auf Phoenix konsolidiert: Runtime-Skripte, `www` und systemd-Units liegen jetzt gemeinsam unter dem Phoenix-Baum.
  • -
-
-
-
-
- 2.9 -
-
-

Veröffentlichung: 09.04.2026

-

Änderungen:

-
    -
  • Recorder: Auto-Split-Threshold jetzt als gekoppelte Zahlenfelder in dBFS und PPM DIN einstellbar.
  • -
  • Stoppuhr: Auto-Threshold jetzt ebenfalls als gekoppelte Zahlenfelder in dBFS und PPM DIN einstellbar.
  • -
  • Optionen: Zusätzlichen Scroll-Puffer im Optionen-Menü ergänzt, damit untere Eingabefelder trotz On-Screen-Tastatur weiter nach oben gezogen werden können.
  • -
-
-
-
-
- 2.8 (HOTFIX) -
-
-

Veröffentlichung: 25.03.2026

-

Änderungen:

-
    -
  • Audio/Timing: Zusätzlichen Display-/Interpolationspuffer entfernt; Anzeigen werden wieder direkt aus dem AudioWorklet gespeist.
  • -
  • Low Latency: Worklet-UI-Intervall auf 8 ms reduziert und Renderpfad auf 60 FPS gestellt.
  • -
  • Performance: Audio- und Analysepfade berechnen nur noch, was im aktuellen View und in den sichtbaren Slots wirklich benötigt wird.
  • -
  • Cold Start: XY-, RTA-, Waveform- und Meter-Zustände werden beim Umschalten sauber zurückgesetzt, damit keine alten Daten kurz wieder auftauchen.
  • -
  • Performance: FFT- sowie LUFS-/Box-Analyser laufen nur noch bei tatsächlichem Sichtbarkeitsbedarf.
  • -
  • RTA: Neue Option für die Balken-Grundfarbe; Peak-Bereiche bleiben weiterhin rot.
  • -
  • RTA: „Real Time Hold“ und „Real Time Decay“ wirken jetzt tatsächlich auf die Balkenanzeige; höherer Decay lässt die Balken schneller zurückfallen.
  • -
  • UI: Zahlenfelder committen jetzt sauber per Change/Blur/Enter; Eingaben über On-Screen-Keyboard und VNC springen nicht mehr vorzeitig auf Min-/Max-Werte.
  • -
-
-
-
-
- 2.5 -
-
-

Veröffentlichung: 22.03.2026

-

Änderungen:

-
    -
  • Fix: Classic Needles im Quad-View werden nun korrekt angezeigt.
  • -
  • Audio/Timing: Umstellung auf Audio-Zeitstempel und frame-synchron berechnete Display-Pakete zur Glättung der Anzeige und Reduktion von Drift/Jitter.
  • -
-
-
-
-
- 2.3.7 -
-
-

Veröffentlichung: 04.02.2026

-

Änderungen:

-
    -
  • Fix: Audio-Pfad bleibt zuverlässig aktiv und verhindert „Stall“ nach langem Idle/Screensaver.
  • -
  • Fix: Auto-Recovery bei „Audio lost“ nach Screensaver-Wake und User-Activity.
  • -
-
-
-
-
- 2.3.6 -
-
-

Veröffentlichung: 04.02.2026

-

Änderungen:

-
    -
  • Fix: Loudness/LUFS – rechter Kanal korrekt normalisiert.
  • -
  • Optimierung: LRA/Loudness ohne wiederholte Array-Allokationen (Ringpuffer + Scratch-Buffer) für weniger GC/CPU-Spikes.
  • -
  • Fix: Config-Load ist jetzt idempotent (verhindert doppeltes Laden beim Start/Options-Init).
  • -
  • PPM DIN: Peak-Hold implementiert (rein visuell), steuerbar über PPM_DIN_HOLD_MS und PPM_DIN_HOLD_DECAY_DB_PER_S.
  • -
  • Peak History: Historie als Ringpuffer statt copyWithin-Shift (weniger CPU, stabilere Darstellung).
  • -
-
-
-
-
- 2.3.4 -
-
-

Veröffentlichung: 04.03.2026

-

Änderungen:

-
    -
-
-
-
-
- 2.3.2 -
-
-

Veröffentlichung: 02.02.2026

-

Änderungen:

-
    -
  • Peak History: Nach dem Beenden des Bildschirmschoners wird die History nicht mehr in einem Frame mit einem Wert „aufgefüllt“ (kein Backfill nach Pause); nutzt nur frische Audiodaten.
  • -
  • XY / Korrelation: Neue Option „Sofortige Nullstellung wenn kein Pegel“ (setzt den Würfel bei Stille sofort auf 0, ohne Hold/Drift).
  • -
  • Stoppuhr (Slot‑Meter): Fix für Canvas‑State/Font‑Leak, der nach Aktivierung die Skalen‑Schrift in anderen Views riesig gemacht hat.
  • -
  • Audio/Timing: Anzeige-Update-Intervall reduziert und an die effektive Render‑FPS gekoppelt (verringert Jitter/Drift, verhindert Message‑Backlogs).
  • -
  • Default: FFT_SIZE auf 4096 reduziert (weniger CPU; betrifft v.a. Spectrogram/LUFS/FFT‑RTA).
  • -
  • Performance: Keine FFT‑Berechnung mehr, wenn kein Spectrogram aktiv ist und der Real Time Analyzer auf IIR läuft.
  • -
-
-
-
-
- 2.3.1 -
-
-

Veröffentlichung: 30.01.2026

-

Änderungen:

-
    -
  • XY / Korrelation: Nach dem Beenden des Bildschirmschoners wird die Anzeige sauber zurückgesetzt und nur mit frischen Audiodaten berechnet (kein Sprung auf +1 ohne Signal).
  • -
-
-
-
-
- 2.3 -
-
-

Veröffentlichung: 26.01.2026

-

Änderungen:

-
    -
  • Quad‑View: Klick‑Verhalten wählbar (direkt wechseln oder Popup‑Ansicht).
  • -
  • Quad‑View: Popup‑Ansicht rendert exakt wie die Einzel‑View (Layout/Skalen identisch); Tap/ESC schließt.
  • -
  • Split‑View: Klick‑Verhalten wählbar (direkt wechseln oder Popup‑Ansicht).
  • -
  • Split‑View: Popup‑Ansicht rendert exakt wie die Einzel‑View; Tap/ESC schließt.
  • -
-
-
-
-
- 2.1 -
-
-

Veröffentlichung: 26.01.2026

-

Änderungen:

-
    -
  • Split‑View: kompaktere HUD‑Bedienung (schmalere Plot‑Dropdowns, Trennzeichen „|“).
  • -
  • Split‑View: Slot‑Zuordnung über Button + Popup (zeigt nur aktive Slots).
  • -
  • Split‑View: Popup schließt per Klick außerhalb oder ESC.
  • -
  • Split‑View: „Slot:“ wird nur angezeigt, wenn mindestens ein Slot aktiv ist.
  • -
  • Neu: Quad‑View (4 unabhängige Plots im 2×2‑Raster).
  • -
  • Quad‑View: Plot‑Auswahl über Popup; eigene Slot‑Konfiguration (unabhängig vom Split‑View).
  • -
  • Phase Wheel: Phase‑Box im Split‑View korrekt positioniert (stabil am Plot‑Panel, mit Abstand; Box kleiner).
  • -
  • Bildschirmschoner: Recorder‑UI/Modals sowie Slot‑Popup werden zuverlässig ausgeblendet.
  • -
-
-
-
-
- 2.0 -
-
-

Veröffentlichung: 22.01.2026

-

Änderungen:

-
    -
  • Neu: Split‑View (zwei Plots nebeneinander, Links/Rechts wählbar).
  • -
  • Split‑View: optionale Meter‑Slots (0–3) mit frei wählbarer Position (links/mitte/rechts).
  • -
-
-
-
-
- 1.8 -
-
-

Veröffentlichung: 17.01.2026

-

Änderungen:

-
    -
  • LUFS (Integrated): I‑Zeitfenster wird korrekt gespeichert und angewendet.
  • -
  • LUFS (Integrated): I bleibt bei Stille (unter −70 LUFS Gate) stehen und fällt nicht weiter ab.
  • -
  • Phasenrad: Zahlenanzeige folgt dem Zeiger besser (ruhiger, ohne Nachkommastelle).
  • -
  • Neu: Stoppuhr als auswählbarer Slot (Start/Stop/Reset/Auto, optional 7‑Segment‑Anzeige).
  • -
-
-
-
-
- 1.7.8 -
-
-

Veröffentlichung: 16.01.2026

-

Änderungen:

-
    -
  • LUFS (Integrated): Zeitfenster per Slider (1–10 min) einstellbar.
  • -
-
-
-
-
- 1.7.7 -
-
-

Veröffentlichung: 09.01.2026

-

Änderungen:

-
    -
  • Input-Offset: kanalgetrennte Einstellung (L/R).
  • -
  • LUFS: Lesbarkeit der Skala angepasst.
  • -
-
-
-
-
- 1.7.5 -
-
-

Veröffentlichung: 31.12.2025

-

Änderungen:

-
    -
  • Phase Wheel: Phasenrad gespiegelt (z. B. +90° liegt rechts).
  • -
  • PPM DIN: Ballistik im eigenen AudioWorklet; Anzeige/Ballistik nach IEC 60268-10 Type I (DIN) korrigiert.
  • -
-
-
-
-
- 1.7.3 -
-
-

Veröffentlichung: 29.12.2025

-

Neuerungen:

-
    -
  • LUFS: Skalen-Zahlen & Skalen-Striche in eigener Farbe einstellbar.
  • -
  • Phase Wheel: Phasenanzeige (Box links oben) größer und weiter links; Zahl zusätzlich geglättet und Update-Rate begrenzt (max. 10×/s) für bessere Lesbarkeit.
  • -
  • PPM DIN: RTW-Loudness-Offset als Schieberegler (−7 … +7 dB).
  • -
-
-
-
-
- 1.7 (Auslieferungsversion) -
-
-

Veröffentlichung: 21.12.2025

-

Neuerungen:

-
    -
  • Slots können auf „(leer)“ gesetzt werden: der Slot wird ausgeblendet und die verbleibenden Anzeigen rücken nach (z. B. nutzt der Real Time Analyzer dann die volle Breite).
  • -
  • Peak History und Classic Needles benötigen einen belegten Slot; dort ist „(leer)“ deaktiviert.
  • -
-
-
-
-
- 1.6 -
-
-

Veröffentlichung: 19.12.2025

-

Neuerungen:

-
    -
  • Neuer View: „Classic Needles“.
  • -
-
-
-
-
- 1.5.5 -
-
-

Veröffentlichung: 08.12.2025

-

Neuerungen:

-
    -
  • Silence-Gate für Goniometer & Korrelation (RMS-basiert, Threshold einstellbar im XY-Menü).
  • -
  • Header-Werte pro Kanal: sauber zentriert über den Balken, Farbumschlag pro Kanal erst ab Rot-Schwelle.
  • -
  • Anzeige der vollen Pegel (auch unterhalb der Skala) mit sanfter Glättung der Zahlen.
  • -
  • RMS-Meter: hält jetzt den letzten gültigen Wert, kein kurzer Drop mehr auf den Floor bei sporadisch fehlenden RMS-Paketen.
  • -
-
-
-
-
- 1.5 -
-
-

Veröffentlichung: 05.12.2025

-

Peak History – Neu hinzugefügt:

-
    -
  • Neuer „Peak History“-View: verlaufende Pegelanzeige mit Meter rechts daneben.
  • -
  • Konfigurierbare Scroll-Geschwindigkeit (½×, 1×, 2×, 4×, 6×) wie beim Spectrogram.
  • -
  • Farbgenaue Linie: Rot erst ab Warnschwelle (z. B. PPM_DIN_RED_START), Segment-Splitting genau auf der Schwelle.
  • -
  • Optionale Flächenfüllung unter/über der Linie in Linienfarbe; invertierbar.
  • -
  • Zeitskala passt sich der Scroll-Geschwindigkeit an (länger bei ½×, kürzer bei 2×/4×/6×).
  • -
  • Peak-History-Optionen im Menü: Scroll-Speed, Fläche füllen, Fläche invertieren.
  • -
-
-
-
-
- 1.2 -
-
-

Veröffentlichung: 28.11.2025

-

Änderungen:

-
    -
  • Studio Clock: neue Stile „Analog + PPM“ und „Digital + PPM“; optional LED-Band für PPM.
  • -
  • PPM in der Clock nutzt DIN-Skala, Offsets und Ballistik identisch zum PPM-DIN-Meter.
  • -
  • PPM-Skalenfarben vereinheitlicht (DIN-Orange), Warnfarbe nur oberhalb 0 dB.
  • -
  • Neue Uhr-Funktion: Studio Clock mit Stilen „Analog“, „Digital“, „Analog + PPM“, „Digital + PPM“, optional LED-Band.
  • -
  • Neuer Bildschirmschoner: DVD/Starfield/Clock, Vorschau ignoriert Pegel (endet nur per Klick/Touch), Optionen gebündelt.
  • -
  • Clock- und Screensaver-Optionen sind unabhängig im Menü steuerbar.
  • -
-
-
-
-
- 1.1.4.1 -
-
-

Veröffentlichung: 26.11.2025

-

Änderungen:

-
    -
  • Goniometer-Korrelationswürfel: Clamping angepasst (bleibt sauber innerhalb des Balkens)
  • -
  • PPM DIN: Skala renormiert (Bottom sitzt am Balkenfuß), %-Label korrigiert
  • -
  • PPM DIN: Loudness-Boxen verlängerte Skala (bis −70 dB), sichtbarer Teil nur oberhalb −50 dB
  • -
  • Input-Offset: Slider auf 0,5-dB-Schritte gestellt
  • -
  • Recorder: Einmaliger Hinweis/Bestätigung bei Erstaufruf (WAV-Limit, WebM/Opus-Hinweis, Beta)
  • -
  • LUFS: Farben pro I/M/S separat einstellbar
  • -
-
-
-
-
- 1.1.4 -
-
-

Veröffentlichung: 25.11.2025

-

Änderungen:

-
    -
  • XY-Optionen wiederhergestellt und zugeordnet (Punkte/Stil/Korrelation/Gain/Fade/Gap)
  • -
  • XY Default-Punkte auf 1024 gesetzt; Hinweise ergänzt
  • -
  • Spectrogram/Waveform/Phase Wheel: Beschreibungen ergänzt
  • -
  • Spectrogram: Worker stabilisiert (Ringpuffer, Draw-Loop-Fix)
  • -
-
-
-
-
- 1.1.3.2 -
-
-

Veröffentlichung: 25.11.2025

-

Änderungen:

-
    -
  • Fix: Spectrogram-Worker stabilisiert (kein Stoppen nach wenigen Sekunden)
  • -
-
-
-
-
- 1.1.3.1 -
-
-

Veröffentlichung: 25.11.2025

-

Änderungen:

-
    -
  • Fix: RTW-Loudness-Boxen im PPM DIN Meter (fehlendes Timestamp-Init behoben)
  • -
-
-
-
-
- 1.1.3 -
-
-

Veröffentlichung: 25.11.2025

-

Änderungen:

-
    -
  • Optionen-Überschrift umbenannt & unterstrichen
  • -
  • RMS: IEC-Zeitkonstanten (Impulse/Fast/Slow) auswählbar, EWMA entfernt
  • -
-
-
-
-
- 1.1.2 -
-
-

Veröffentlichung: 25.11.2025

-

Änderungen:

-
    -
  • Einführung: Changelog im Optionen-Menü
  • -
  • Korrektur: PPM DIN & PPM EBU ohne doppelte Ballistik (UI-Glättung entfernt)
  • -
  • Korrektur: RMS-Anzeige ohne zusätzliche Glättung
  • -
-
-
-
-
- 1.1 -
-
-

Veröffentlichung: 25.11.2025

-

Änderungen:

-
    -
  • Hinzugefügt: RTW-Style Loudnessanzeige auf PPM DIN Meter
  • -
-
-
-
-
- 1.0 -
-
-

Erste Veröffentlichung: 21.11.2025

-
-
-
-
+
+ Version / Änderungen +
diff --git a/www/meters/static_layer.js b/www/meters/static_layer.js index cc0b561..39e7f9f 100644 --- a/www/meters/static_layer.js +++ b/www/meters/static_layer.js @@ -1,22 +1,4 @@ -const CAN_USE_OFFSCREEN = typeof OffscreenCanvas === 'function'; - -function createSurface(width, height) { - const w = Math.max(1, Math.ceil(width)); - const h = Math.max(1, Math.ceil(height)); - if (CAN_USE_OFFSCREEN) { - const canvas = new OffscreenCanvas(w, h); - const ctx = canvas.getContext('2d'); - if (ctx) return { canvas, ctx, width: w, height: h }; - } - if (typeof document !== 'undefined') { - const canvas = document.createElement('canvas'); - canvas.width = w; - canvas.height = h; - const ctx = canvas.getContext('2d'); - if (ctx) return { canvas, ctx, width: w, height: h }; - } - return null; -} +import { createCanvasSurface } from '../core/canvas_surface.js'; export function drawCachedStaticLayer(targetCtx, shared, layerId, key, x, y, width, height, build) { if (!targetCtx || !shared || typeof build !== 'function') return false; @@ -30,7 +12,7 @@ export function drawCachedStaticLayer(targetCtx, shared, layerId, key, x, y, wid || layer.height !== h; if (needsRebuild) { - layer = createSurface(w, h); + layer = createCanvasSurface(w, h); if (!layer) return false; layer.key = key; layer.ctx.save(); diff --git a/www/styles.css b/www/styles.css index 725a2f6..e152bfe 100644 --- a/www/styles.css +++ b/www/styles.css @@ -415,3 +415,15 @@ canvas.waveform-layer{ #optionsPanelNew summary{font-size:17px;padding:8px 4px;} #optionsPanelNew .opt{font-size:16px;} +.changelog-details { margin: 20px 0 0 5px; } +.changelog-content > .opt-group { margin-top: 0; } +.changelog-version { margin-left: 12px; } +.changelog-date { margin: 0; color: #8fd3d4; } +.changelog-heading { margin: 4px 0 0 12px; color: #8fd3d4; } +.changelog-list { margin: 2px 0 0 24px; color: #b2c7d9; } +.opt-w-110 { width: 110px !important; } +.opt-w-120 { width: 120px !important; } +.opt-w-140 { width: 140px !important; } +.opt-w-150 { width: 150px !important; } +.opt-w-180 { width: 180px !important; } +.opt-w-220 { width: 220px !important; } diff --git a/www/ui/changelog.js b/www/ui/changelog.js new file mode 100644 index 0000000..8e6d102 --- /dev/null +++ b/www/ui/changelog.js @@ -0,0 +1,30 @@ +let loadPromise = null; + +export function setupLazyChangelog(details, content) { + if (!details || !content || details.dataset.bound === '1') return; + details.dataset.bound = '1'; + + const load = async () => { + if (content.dataset.loaded === '1') return; + if (!loadPromise) { + loadPromise = fetch('./changelog.html', { cache: 'no-cache' }) + .then((response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.text(); + }); + } + try { + content.innerHTML = await loadPromise; + content.dataset.loaded = '1'; + } catch (error) { + loadPromise = null; + content.textContent = 'Versionsverlauf konnte nicht geladen werden.'; + console.warn('Changelog load error:', error); + } + }; + + details.addEventListener('toggle', () => { + if (details.open) void load(); + }); + if (details.open) void load(); +} diff --git a/www/ui/options.js b/www/ui/options.js index 0811575..d0beca7 100644 --- a/www/ui/options.js +++ b/www/ui/options.js @@ -3,6 +3,8 @@ import { CONFIG, saveConfig, loadConfig, applyAlignmentProfile, applyRtaBpoSelection, applySoftwarePreset, loadSoftwarePreset, saveSoftwarePreset, loadLayoutPreset, saveLayoutPreset, exportSoftwarePreset, importSoftwarePreset, updateVuReference, resetConfigToDefaults, updateInputOffsetGain } from '../core/config.js'; import { clamp, clampPow2 } from '../core/utils.js'; +import { bindOptionsViewportAssist } from './options_viewport.js'; +import { setupLazyChangelog } from './changelog.js'; // DOM helper const E = (id) => document.getElementById(id); @@ -86,86 +88,14 @@ export function setupOptions(env) { enforcePhaseAmplitudeConstraints({ persist: true }); syncUI(); wireHandlers(env); - bindOptionsViewportAssist(); + bindOptionsViewportAssist(E('optionsPanelNew')); + setupLazyChangelog(E('changelogDetails'), E('changelogContent')); bindAlignmentToggle(env); bindVuRefToggle(env); // Sichtbarkeit nach View steuern übernimmt main.js — hier nur Export der Sync-Funktion return { syncUI }; } -let optionsViewportAssistBound = false; - -function bindOptionsViewportAssist() { - if (optionsViewportAssistBound) return; - optionsViewportAssistBound = true; - - const panel = E('optionsPanelNew'); - if (!panel) return; - - const setKeyboardInset = () => { - const vv = window.visualViewport; - if (!vv) { - panel.style.setProperty('--options-kb-inset', '0px'); - return; - } - const inset = Math.max(0, window.innerHeight - (vv.height + vv.offsetTop)); - panel.style.setProperty('--options-kb-inset', `${Math.round(inset)}px`); - }; - - const revealFocusedField = (target) => { - if (!panel || !target || !panel.contains(target)) return; - const vv = window.visualViewport; - const panelRect = panel.getBoundingClientRect(); - const targetRect = target.getBoundingClientRect(); - const keyboardInset = vv ? Math.max(0, window.innerHeight - (vv.height + vv.offsetTop)) : 0; - const visibleTop = panelRect.top + 16; - const visibleBottom = panelRect.bottom - keyboardInset - 16; - - if (targetRect.bottom > visibleBottom || targetRect.top < visibleTop) { - const targetCenter = targetRect.top + (targetRect.height / 2); - const visibleCenter = visibleTop + ((visibleBottom - visibleTop) / 2); - const delta = targetCenter - visibleCenter; - panel.scrollTop += delta; - } - }; - - const onFocus = (ev) => { - const el = ev.target; - if (!(el instanceof HTMLElement)) return; - const type = String(el.getAttribute('type') || '').toLowerCase(); - if (el.tagName !== 'INPUT' && el.tagName !== 'SELECT' && el.tagName !== 'TEXTAREA') return; - if (type === 'checkbox' || type === 'range' || type === 'color') return; - setKeyboardInset(); - requestAnimationFrame(() => revealFocusedField(el)); - setTimeout(() => revealFocusedField(el), 250); - }; - - const onBlur = () => { - setTimeout(() => { - const active = document.activeElement; - if (!(active instanceof HTMLElement) || !panel.contains(active)) { - panel.style.setProperty('--options-kb-inset', '0px'); - } - }, 50); - }; - - panel.addEventListener('focusin', onFocus); - panel.addEventListener('focusout', onBlur); - - if (window.visualViewport) { - window.visualViewport.addEventListener('resize', () => { - setKeyboardInset(); - const active = document.activeElement; - if (active instanceof HTMLElement && panel.contains(active)) { - requestAnimationFrame(() => revealFocusedField(active)); - } - }); - window.visualViewport.addEventListener('scroll', setKeyboardInset); - } - - setKeyboardInset(); -} - function syncSoftwarePresetControls() { const presetId = String(E('opt_softwarePreset')?.value || CONFIG.SOFTWARE_PRESET || 'default').toLowerCase(); const isDefault = presetId === 'default'; diff --git a/www/ui/options_viewport.js b/www/ui/options_viewport.js new file mode 100644 index 0000000..48ea895 --- /dev/null +++ b/www/ui/options_viewport.js @@ -0,0 +1,65 @@ +let bound = false; + +// Hält fokussierte Eingabefelder auch bei eingeblendeter Bildschirmtastatur sichtbar. +export function bindOptionsViewportAssist(panel) { + if (bound || !panel) return; + bound = true; + + const setKeyboardInset = () => { + const viewport = window.visualViewport; + if (!viewport) { + panel.style.setProperty('--options-kb-inset', '0px'); + return; + } + const inset = Math.max(0, window.innerHeight - (viewport.height + viewport.offsetTop)); + panel.style.setProperty('--options-kb-inset', `${Math.round(inset)}px`); + }; + + const revealFocusedField = (target) => { + if (!target || !panel.contains(target)) return; + const viewport = window.visualViewport; + const panelRect = panel.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + const keyboardInset = viewport ? Math.max(0, window.innerHeight - (viewport.height + viewport.offsetTop)) : 0; + const visibleTop = panelRect.top + 16; + const visibleBottom = panelRect.bottom - keyboardInset - 16; + if (targetRect.bottom > visibleBottom || targetRect.top < visibleTop) { + const targetCenter = targetRect.top + targetRect.height / 2; + const visibleCenter = visibleTop + (visibleBottom - visibleTop) / 2; + panel.scrollTop += targetCenter - visibleCenter; + } + }; + + const onFocus = (event) => { + const element = event.target; + if (!(element instanceof HTMLElement)) return; + const type = String(element.getAttribute('type') || '').toLowerCase(); + if (!['INPUT', 'SELECT', 'TEXTAREA'].includes(element.tagName)) return; + if (type === 'checkbox' || type === 'range' || type === 'color') return; + setKeyboardInset(); + requestAnimationFrame(() => revealFocusedField(element)); + setTimeout(() => revealFocusedField(element), 250); + }; + + panel.addEventListener('focusin', onFocus); + panel.addEventListener('focusout', () => { + setTimeout(() => { + const active = document.activeElement; + if (!(active instanceof HTMLElement) || !panel.contains(active)) { + panel.style.setProperty('--options-kb-inset', '0px'); + } + }, 50); + }); + + if (window.visualViewport) { + window.visualViewport.addEventListener('resize', () => { + setKeyboardInset(); + const active = document.activeElement; + if (active instanceof HTMLElement && panel.contains(active)) { + requestAnimationFrame(() => revealFocusedField(active)); + } + }); + window.visualViewport.addEventListener('scroll', setKeyboardInset); + } + setKeyboardInset(); +} diff --git a/www/views/multi_view_shared.js b/www/views/multi_view_shared.js new file mode 100644 index 0000000..e6e22fb --- /dev/null +++ b/www/views/multi_view_shared.js @@ -0,0 +1,237 @@ +// Gemeinsame, rein interne Bausteine für Split- und Quad-View. +import * as viewGoni from './goniometer_rtw.js'; +import * as viewPhaseWheel from './phase_wheel.js'; +import * as viewPanel from './panel.js'; +import * as viewRealtime from './realtime.js'; +import * as viewClassicNeedles from './classic_needles.js'; +import * as viewPeakHistory from './peak_history.js'; +import * as viewClock from './clock.js'; +import * as viewWaveform from './waveform.js'; +import * as viewSpectrogram from './spectrogram.js'; +import { FRAME_COLOR, PANEL_BG } from '../core/theme.js'; +import { drawCachedStaticLayer } from './static_layer.js'; + +export const CHILD_VIEWS = { + 'realtime': viewRealtime, + 'classic-needles': viewClassicNeedles, + 'peak-history': viewPeakHistory, + 'goniometer-rtw': viewGoni, + 'phase-wheel': viewPhaseWheel, + 'panel': viewPanel, + 'clock': viewClock, + 'waveform': viewWaveform, + 'spectrogram': viewSpectrogram, +}; + +const ALLOWED_CHILD_IDS = new Set(['none', ...Object.keys(CHILD_VIEWS)]); +const ALLOWED_METER_IDS = new Set(['none', 'vu', 'ppm-ebu', 'ppm-din', 'tp', 'hifi-peak', 'rms', 'lufs', 'stopwatch']); +const ALLOWED_METER_POSITIONS = new Set(['left', 'center', 'right']); +const METER_GAP = 12; +const METER_PAD_TOP = 15; +const METER_PAD_BOTTOM = 5; +const METER_SLOT_SHRINK = 24; +const METER_EXTRA_BOTTOM_PAD = 6; +const OUTER_GAP = 10; +const SIDE_INNER_GAP = 8; +const METER_W_DEFAULT = 140; +const METER_W_MIN = 90; +const MIN_PLOT_W = 240; + +export function sanitizeChildId(value, fallback) { + return ALLOWED_CHILD_IDS.has(value) ? value : fallback; +} + +export function sanitizePlotId(value, fallback) { + return ALLOWED_CHILD_IDS.has(value) ? value : fallback; +} + +export function sanitizeMeterId(value, fallback) { + const id = String(value || ''); + return ALLOWED_METER_IDS.has(id) ? id : fallback; +} + +export function sanitizeMeterPos(value, fallback) { + const id = String(value || ''); + return ALLOWED_METER_POSITIONS.has(id) ? id : fallback; +} + +function clampMeterCount(value) { + const n = Number(value); + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.min(3, n | 0)); +} + +export function readMultiViewMeters(config, prefix) { + const count = clampMeterCount(config?.[`${prefix}_METER_COUNT`]); + const defaults = ['vu', 'ppm-din', 'lufs']; + return defaults.map((fallback, index) => { + const number = index + 1; + return { + id: sanitizeMeterId(config?.[`${prefix}_METER_${number}`], fallback), + pos: sanitizeMeterPos(config?.[`${prefix}_METER_${number}_POS`], 'right'), + }; + }).slice(0, count); +} + +export function groupMetersByPosition(slots) { + const out = { left: [], center: [], right: [] }; + for (const slot of slots || []) { + if (!slot) continue; + if (slot.pos === 'left') out.left.push(slot.id); + else if (slot.pos === 'center') out.center.push(slot.id); + else out.right.push(slot.id); + } + return out; +} + +export function computeMultiViewBaseLayout(rect, slots, contentTop, contentBottom = 0) { + const contentH = Math.max(0, rect.h - contentTop - contentBottom); + const hasContent = contentH >= 140; + let effectiveSlots = hasContent ? (Array.isArray(slots) ? slots.slice(0, 3) : []) : []; + + while (true) { + const grouped = groupMetersByPosition(effectiveSlots); + const leftCount = grouped.left.length; + const centerCount = grouped.center.length; + const rightCount = grouped.right.length; + const totalSlots = leftCount + centerCount + rightCount; + const hasLeftMeters = leftCount > 0; + const hasCenterMeters = centerCount > 0; + const hasRightMeters = rightCount > 0; + const interBlockGaps = + (hasLeftMeters ? SIDE_INNER_GAP : 0) + + (hasRightMeters ? SIDE_INNER_GAP : 0) + + (hasCenterMeters ? 2 * SIDE_INNER_GAP : OUTER_GAP); + const internalGaps = + Math.max(0, leftCount - 1) * METER_GAP + + Math.max(0, centerCount - 1) * METER_GAP + + Math.max(0, rightCount - 1) * METER_GAP; + const remainingForMeters = rect.w - (2 * MIN_PLOT_W + interBlockGaps + internalGaps); + let slotW = totalSlots > 0 ? Math.min(METER_W_DEFAULT, Math.floor(remainingForMeters / totalSlots)) : 0; + + if (totalSlots > 0 && slotW < METER_W_MIN && effectiveSlots.length) { + effectiveSlots = effectiveSlots.slice(0, -1); + continue; + } + if (totalSlots > 0) slotW = Math.max(METER_W_MIN, Math.min(METER_W_DEFAULT, slotW)); + + const groupWidth = (count) => count > 0 ? count * slotW + Math.max(0, count - 1) * METER_GAP : 0; + const leftW = groupWidth(leftCount); + const centerW = groupWidth(centerCount); + const rightW = groupWidth(rightCount); + const plotAvail = Math.max(0, rect.w - interBlockGaps - leftW - centerW - rightW); + const leftPlotW = Math.floor(plotAvail / 2); + const rightPlotW = plotAvail - leftPlotW; + if ((leftPlotW < MIN_PLOT_W || rightPlotW < MIN_PLOT_W) && effectiveSlots.length) { + effectiveSlots = effectiveSlots.slice(0, -1); + continue; + } + + let x = 0; + const leftMetersRect = hasLeftMeters ? { x, y: contentTop, w: leftW, h: contentH } : null; + if (leftMetersRect) x += leftW + SIDE_INNER_GAP; + const leftPlotRect = { x, y: 0, w: leftPlotW, h: rect.h }; + x += leftPlotW; + let centerMetersRect = null; + if (hasCenterMeters) { + x += SIDE_INNER_GAP; + centerMetersRect = { x, y: contentTop, w: centerW, h: contentH }; + x += centerW + SIDE_INNER_GAP; + } else { + x += OUTER_GAP; + } + const rightPlotRect = { x, y: 0, w: rightPlotW, h: rect.h }; + x += rightPlotW; + let rightMetersRect = null; + if (hasRightMeters) { + x += SIDE_INNER_GAP; + rightMetersRect = { x, y: contentTop, w: rightW, h: contentH }; + } + return { + plots: { left: leftPlotRect, right: rightPlotRect }, + meters: { left: leftMetersRect, center: centerMetersRect, right: rightMetersRect }, + meterIds: grouped, + slotW, + effectiveSlots, + }; + } +} + +export async function withClippedSubRect(g, rect, fn) { + g.save(); + g.translate(rect.x, rect.y); + g.beginPath(); + g.rect(0, 0, rect.w, rect.h); + g.clip(); + try { return await fn(); } finally { g.restore(); } +} + +export function drawSubframe(g, rect) { + g.save(); + g.strokeStyle = FRAME_COLOR; + g.lineWidth = 2; + g.strokeRect(rect.x + 0.5, rect.y + 0.5, Math.max(0, rect.w - 1), Math.max(0, rect.h - 1)); + g.restore(); +} + +export function drawEmptyPlot(state, g, rect, label) { + drawCachedStaticLayer(state, g, 'empty-plot', String(label || '(leer)'), rect, (lg) => { + lg.fillStyle = PANEL_BG; + lg.fillRect(0, 0, rect.w, rect.h); + lg.fillStyle = '#9aa'; + lg.textAlign = 'left'; + lg.font = 'bold 14px ui-monospace, monospace'; + lg.fillText(label || '(leer)', 12, 32); + lg.textAlign = 'start'; + }); +} + +export async function drawMetersPanel(env, state, rect, meterIds, slotW, ownerLabel) { + const { ctx: g, meters, config } = env; + if (!rect || rect.w <= 0 || rect.h <= 0) return; + const ids = Array.isArray(meterIds) ? meterIds.slice(0, 3) : []; + const count = ids.length; + if (!count) return; + + drawCachedStaticLayer(state, g, 'meter-panel-shell', 'bg-frame', rect, (lg) => { + lg.fillStyle = PANEL_BG; + lg.fillRect(0, 0, rect.w, rect.h); + drawSubframe(lg, { x: 0, y: 0, w: rect.w, h: rect.h }); + }); + + const n = Math.max(1, Math.min(3, count)); + const usedW = n * slotW + (n - 1) * METER_GAP; + const startX = rect.x + Math.max(0, Math.floor((rect.w - usedW) / 2)); + const innerHeight = Math.max(40, rect.h - METER_PAD_TOP - METER_PAD_BOTTOM - METER_SLOT_SHRINK - METER_EXTRA_BOTTOM_PAD); + const slotY = rect.y + METER_PAD_TOP + METER_SLOT_SHRINK / 2; + + for (let i = 0; i < n; i++) { + const id = sanitizeMeterId(ids[i], 'none'); + const r = { x: startX + i * (slotW + METER_GAP), y: slotY, w: slotW, h: innerHeight }; + if (id === 'none') { + drawCachedStaticLayer(state, g, 'meter-slot-empty', `${slotW}x${innerHeight}`, r, (lg) => { + lg.strokeStyle = 'rgba(0,231,255,0.25)'; + lg.setLineDash([6, 5]); + lg.strokeRect(0.5, 0.5, Math.max(0, r.w - 1), Math.max(0, r.h - 1)); + lg.setLineDash([]); + lg.fillStyle = '#9aa'; + lg.textAlign = 'center'; + lg.font = '12px ui-monospace, monospace'; + lg.fillText('(leer)', r.w / 2, 22); + lg.textAlign = 'start'; + }); + continue; + } + try { + g.save(); + g.beginPath(); + g.rect(rect.x + 1, rect.y + 1, Math.max(0, rect.w - 2), Math.max(0, rect.h - 2)); + g.clip(); + await meters.draw(g, r, id, config); + g.restore(); + } catch (error) { + g.restore(); + console.warn(`${ownerLabel} meter draw error:`, error); + } + } +} diff --git a/www/views/quad_view.js b/www/views/quad_view.js index 9b28c88..d4236e2 100644 --- a/www/views/quad_view.js +++ b/www/views/quad_view.js @@ -1,246 +1,29 @@ // views/quad_view.js — Quad-View: vier Plots (2x2) + optionales Meter-Panel (wie Split-View) -import * as viewGoni from './goniometer_rtw.js'; -import * as viewPhaseWheel from './phase_wheel.js'; -import * as viewPanel from './panel.js'; -import * as viewRealtime from './realtime.js'; -import * as viewClassicNeedles from './classic_needles.js'; -import * as viewPeakHistory from './peak_history.js'; -import * as viewClock from './clock.js'; -import * as viewWaveform from './waveform.js'; -import * as viewSpectrogram from './spectrogram.js'; -import { DEFAULT_TOP_INSET, FRAME_COLOR, PANEL_BG } from '../core/theme.js'; +import { DEFAULT_TOP_INSET } from '../core/theme.js'; +import { + CHILD_VIEWS, + computeMultiViewBaseLayout, + drawEmptyPlot, + drawMetersPanel, + readMultiViewMeters, + sanitizeChildId, + sanitizePlotId, + withClippedSubRect, +} from './multi_view_shared.js'; export const id = 'quad-view'; const CONTENT_TOP = DEFAULT_TOP_INSET; const CONTENT_BOTTOM = 0; -const CHILD_VIEWS = { - 'realtime': viewRealtime, - 'classic-needles': viewClassicNeedles, - 'peak-history': viewPeakHistory, - 'goniometer-rtw': viewGoni, - 'phase-wheel': viewPhaseWheel, - 'panel': viewPanel, - 'clock': viewClock, - 'waveform': viewWaveform, - 'spectrogram': viewSpectrogram, -}; -const ALLOWED_CHILD_VIEW_IDS = new Set(['none', ...Object.keys(CHILD_VIEWS)]); -const ALLOWED_PLOT_IDS = new Set(['none', 'phase-wheel', 'realtime', 'goniometer-rtw', 'peak-history', 'classic-needles', 'panel', 'clock', 'waveform', 'spectrogram']); -const ALLOWED_METER_IDS = new Set(['none', 'vu', 'ppm-ebu', 'ppm-din', 'tp', 'hifi-peak', 'rms', 'lufs', 'stopwatch']); -const ALLOWED_METER_POSITIONS = new Set(['left', 'center', 'right']); - -const OUTER_GAP = 10; -const SIDE_INNER_GAP = 8; const ROW_GAP = 10; -const METER_GAP = 12; -const METER_W_DEFAULT = 140; -const METER_W_MIN = 90; -const MIN_PLOT_W = 240; -const METER_PAD_TOP = 15; -const METER_PAD_BOTTOM = 5; -const METER_SLOT_SHRINK = 24; -const METER_EXTRA_BOTTOM_PAD = 6; - -function sanitizeChildId(val, fallback) { - return ALLOWED_CHILD_VIEW_IDS.has(val) ? val : fallback; -} - -function sanitizePlotId(val, fallback) { - return ALLOWED_PLOT_IDS.has(val) ? val : fallback; -} - -function sanitizeMeterId(val, fallback) { - const id = String(val || ''); - return ALLOWED_METER_IDS.has(id) ? id : fallback; -} - -function sanitizeMeterPos(val, fallback) { - const id = String(val || ''); - return ALLOWED_METER_POSITIONS.has(id) ? id : fallback; -} - -function clampMeterCount(val) { - const n = Number(val); - if (!Number.isFinite(n)) return 0; - return Math.max(0, Math.min(3, n | 0)); -} - -async function withClippedSubRect(g, rect, fn) { - g.save(); - g.translate(rect.x, rect.y); - g.beginPath(); - g.rect(0, 0, rect.w, rect.h); - g.clip(); - try { return await fn(); } finally { g.restore(); } -} - -function drawSubframe(g, rect) { - g.save(); - g.strokeStyle = FRAME_COLOR; - g.lineWidth = 2; - g.strokeRect(rect.x + 0.5, rect.y + 0.5, Math.max(0, rect.w - 1), Math.max(0, rect.h - 1)); - g.restore(); -} - -function createStaticLayerCanvas(width, height) { - const w = Math.max(1, width | 0); - const h = Math.max(1, height | 0); - if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(w, h); - const c = document.createElement('canvas'); - c.width = w; - c.height = h; - return c; -} - -function drawCachedStaticLayer(state, g, layerId, key, rect, build) { - if (!state || !rect || rect.w <= 0 || rect.h <= 0) return; - if (!state.staticLayers) state.staticLayers = new Map(); - const fullKey = `${layerId}:${key}:${Math.max(0, rect.w | 0)}x${Math.max(0, rect.h | 0)}`; - let layer = state.staticLayers.get(fullKey); - if (!layer) { - const canvas = createStaticLayerCanvas(rect.w, rect.h); - const ctx = canvas.getContext('2d'); - build(ctx, rect); - layer = { canvas }; - state.staticLayers.set(fullKey, layer); - } - g.drawImage(layer.canvas, rect.x, rect.y); -} - -function drawEmptyPlot(state, g, rect, label) { - drawCachedStaticLayer(state, g, 'empty-plot', String(label || '(leer)'), rect, (lg) => { - lg.fillStyle = PANEL_BG; - lg.fillRect(0, 0, rect.w, rect.h); - lg.fillStyle = '#9aa'; - lg.textAlign = 'left'; - lg.font = 'bold 14px ui-monospace, monospace'; - lg.fillText(label || '(leer)', 12, 32); - lg.textAlign = 'start'; - }); -} - function readQuadMeters(CONFIG) { - const count = clampMeterCount(CONFIG?.QUAD_VIEW_METER_COUNT); - const slots = [ - { - id: sanitizeMeterId(CONFIG?.QUAD_VIEW_METER_1, 'vu'), - pos: sanitizeMeterPos(CONFIG?.QUAD_VIEW_METER_1_POS, 'right'), - }, - { - id: sanitizeMeterId(CONFIG?.QUAD_VIEW_METER_2, 'ppm-din'), - pos: sanitizeMeterPos(CONFIG?.QUAD_VIEW_METER_2_POS, 'right'), - }, - { - id: sanitizeMeterId(CONFIG?.QUAD_VIEW_METER_3, 'lufs'), - pos: sanitizeMeterPos(CONFIG?.QUAD_VIEW_METER_3_POS, 'right'), - }, - ].slice(0, count); - return slots; -} - -function groupMetersByPosition(slots) { - const out = { left: [], center: [], right: [] }; - for (const s of slots || []) { - if (!s) continue; - if (s.pos === 'left') out.left.push(s.id); - else if (s.pos === 'center') out.center.push(s.id); - else out.right.push(s.id); - } - return out; + return readMultiViewMeters(CONFIG, 'QUAD_VIEW'); } function computeBaseLayout(rect, slots) { - const contentH = Math.max(0, rect.h - CONTENT_TOP - CONTENT_BOTTOM); - const hasContent = contentH >= 140; - const BLOCK_GAP = SIDE_INNER_GAP; - - let effectiveSlots = hasContent ? (Array.isArray(slots) ? slots.slice(0, 3) : []) : []; - while (true) { - const grouped = groupMetersByPosition(effectiveSlots); - const leftCount = grouped.left.length; - const centerCount = grouped.center.length; - const rightCount = grouped.right.length; - const totalSlots = leftCount + centerCount + rightCount; - const hasLeftMeters = leftCount > 0; - const hasCenterMeters = centerCount > 0; - const hasRightMeters = rightCount > 0; - - const interBlockGaps = - (hasLeftMeters ? BLOCK_GAP : 0) + - (hasRightMeters ? BLOCK_GAP : 0) + - (hasCenterMeters ? (2 * BLOCK_GAP) : OUTER_GAP); - - const internalGaps = - Math.max(0, leftCount - 1) * METER_GAP + - Math.max(0, centerCount - 1) * METER_GAP + - Math.max(0, rightCount - 1) * METER_GAP; - - const minRequired = 2 * MIN_PLOT_W + interBlockGaps + internalGaps; - const remainingForMeters = rect.w - minRequired; - - let slotW = 0; - if (totalSlots > 0) { - slotW = Math.floor(remainingForMeters / totalSlots); - slotW = Math.min(METER_W_DEFAULT, slotW); - } - - if (totalSlots > 0 && slotW < METER_W_MIN && effectiveSlots.length) { - effectiveSlots = effectiveSlots.slice(0, -1); - continue; - } - if (totalSlots > 0) slotW = Math.max(METER_W_MIN, Math.min(METER_W_DEFAULT, slotW)); - - const leftW = hasLeftMeters ? (leftCount * slotW + Math.max(0, leftCount - 1) * METER_GAP) : 0; - const centerW = hasCenterMeters ? (centerCount * slotW + Math.max(0, centerCount - 1) * METER_GAP) : 0; - const rightW = hasRightMeters ? (rightCount * slotW + Math.max(0, rightCount - 1) * METER_GAP) : 0; - - const metersTotalW = leftW + centerW + rightW; - const plotAvail = Math.max(0, rect.w - interBlockGaps - metersTotalW); - const leftPlotW = Math.floor(plotAvail / 2); - const rightPlotW = plotAvail - leftPlotW; - - const plotFits = leftPlotW >= MIN_PLOT_W && rightPlotW >= MIN_PLOT_W; - if (!plotFits && effectiveSlots.length) { - effectiveSlots = effectiveSlots.slice(0, -1); - continue; - } - - let x = 0; - const leftMetersRect = hasLeftMeters ? { x, y: CONTENT_TOP, w: leftW, h: contentH } : null; - if (leftMetersRect) x += leftW + BLOCK_GAP; - - const leftPlotRect = { x, y: 0, w: leftPlotW, h: rect.h }; - x += leftPlotW; - - let centerMetersRect = null; - if (hasCenterMeters) { - x += BLOCK_GAP; - centerMetersRect = { x, y: CONTENT_TOP, w: centerW, h: contentH }; - x += centerW + BLOCK_GAP; - } else { - x += OUTER_GAP; - } - - const rightPlotRect = { x, y: 0, w: rightPlotW, h: rect.h }; - x += rightPlotW; - - let rightMetersRect = null; - if (hasRightMeters) { - x += BLOCK_GAP; - rightMetersRect = { x, y: CONTENT_TOP, w: rightW, h: contentH }; - } - - return { - plots: { left: leftPlotRect, right: rightPlotRect }, - meters: { left: leftMetersRect, center: centerMetersRect, right: rightMetersRect }, - meterIds: grouped, - slotW, - effectiveSlots, - }; - } + return computeMultiViewBaseLayout(rect, slots, CONTENT_TOP, CONTENT_BOTTOM); } function splitRectToRows(rect) { @@ -274,60 +57,6 @@ function ensureChildRectSize(env, rect, child) { resizeChild(env, { x: 0, y: 0, w: rect.w, h: rect.h }, child); } -async function drawMetersPanel(env, state, rect, meterIds, slotW) { - const { ctx: g, meters, config: CONFIG } = env; - if (!rect || rect.w <= 0 || rect.h <= 0) return; - const ids = Array.isArray(meterIds) ? meterIds.slice(0, 3) : []; - const count = ids.length; - if (!count) return; - - drawCachedStaticLayer(state, g, 'meter-panel-shell', 'bg-frame', rect, (lg) => { - lg.fillStyle = PANEL_BG; - lg.fillRect(0, 0, rect.w, rect.h); - drawSubframe(lg, { x: 0, y: 0, w: rect.w, h: rect.h }); - }); - - const gap = METER_GAP; - const n = Math.max(1, Math.min(3, count)); - const usedW = n * slotW + (n - 1) * gap; - const startX = rect.x + Math.max(0, Math.floor((rect.w - usedW) / 2)); - const shrink = Math.max(0, METER_SLOT_SHRINK); - const innerHeight = Math.max(40, rect.h - METER_PAD_TOP - METER_PAD_BOTTOM - shrink - METER_EXTRA_BOTTOM_PAD); - const innerOffset = shrink / 2; - const slotY = rect.y + METER_PAD_TOP + innerOffset; - const slotH = innerHeight; - - for (let i = 0; i < n; i++) { - const id = sanitizeMeterId(ids[i], 'none'); - const r = { x: startX + i * (slotW + gap), y: slotY, w: slotW, h: slotH }; - if (id === 'none') { - drawCachedStaticLayer(state, g, 'meter-slot-empty', `${slotW}x${slotH}`, r, (lg) => { - lg.strokeStyle = 'rgba(0,231,255,0.25)'; - lg.setLineDash([6, 5]); - lg.strokeRect(0.5, 0.5, Math.max(0, r.w - 1), Math.max(0, r.h - 1)); - lg.setLineDash([]); - lg.fillStyle = '#9aa'; - lg.textAlign = 'center'; - lg.font = '12px ui-monospace, monospace'; - lg.fillText('(leer)', r.w / 2, 22); - lg.textAlign = 'start'; - }); - } else { - try { - g.save(); - g.beginPath(); - g.rect(rect.x + 1, rect.y + 1, Math.max(0, rect.w - 2), Math.max(0, rect.h - 2)); - g.clip(); - await meters.draw(g, r, id, CONFIG); - g.restore(); - } catch (e) { - g.restore(); - console.warn('Quad meter draw error:', e); - } - } - } -} - function destroyChild(child) { if (!child || child.id === 'none') return; const mod = CHILD_VIEWS[child.id]; diff --git a/www/views/realtime.js b/www/views/realtime.js index 25b5fc4..fcbb441 100644 --- a/www/views/realtime.js +++ b/www/views/realtime.js @@ -1,5 +1,6 @@ import { getRtwCenters, resolveRtwBpoValue } from '../core/rtw_centers.js'; import { DEFAULT_TOP_INSET, FRAME_COLOR, MID_COLOR, PANEL_BG, WARN_COLOR } from '../core/theme.js'; +import { drawCachedStaticLayer } from './static_layer.js'; // views/realtime.js — IEC-konformer Real-Time Analyzer @@ -115,6 +116,23 @@ export async function render(env, state) { const gutterL = Number.isFinite(CONFIG?.AXIS_GUTTER_LEFT) ? Math.max(8, Number(CONFIG.AXIS_GUTTER_LEFT)) : 14; + const rtwBarGrid = CONFIG.RTA_BAR_LAYOUT === 'rtw' + && (CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'bars'; + const rtwGridCenters = rtwBarGrid + ? (isVectorLike(displayRtaPacket?.centers) + ? Array.from(displayRtaPacket.centers) + : getRtwCenters(CONFIG.RTA_BPO_MODE || '1_3')) + .filter((center) => Number.isFinite(center) + && center >= freqBounds.min + && center <= freqBounds.max) + : null; + const freqTickPositions = rtwGridCenters + ? buildRtwTickPositions(rtwGridCenters, freqTicks) + : null; + const gridLeftExtension = rtwBarGrid ? gutterL : 0; + const tickPositionKey = freqTickPositions + ? freqTicks.map((f) => `${f}:${freqTickPositions[String(f)] ?? 'log'}`).join(',') + : 'log'; const plotPadLeft = gutterL + STATIC_LABEL_PAD_LEFT; const plotPadBottom = STATIC_LABEL_PAD_BOTTOM; @@ -133,6 +151,8 @@ export async function render(env, state) { refDb ?? 'none', hide20HzTick ? 1 : 0, plotPadLeft, + gridLeftExtension, + tickPositionKey, plotPadBottom, ].join('|'), { x: plotX - plotPadLeft, y: plotY, w: plotW + plotPadLeft, h: plotH + plotPadBottom }, @@ -147,7 +167,9 @@ export async function render(env, state) { freqBounds.max, { freqTicks, + freqTickPositions, freqMin: freqBounds.min, + xLeftExtension: gridLeftExtension, refDb, refStyle: 'rgba(0,231,255,0.35)', refDash: [3, 4], @@ -197,7 +219,8 @@ export async function render(env, state) { range, freqBounds, ballisticsData?.overlay || null, - ballisticsData?.mode || CONFIG.RTA_BALLISTICS_MODE || 'average' + ballisticsData?.mode || CONFIG.RTA_BALLISTICS_MODE || 'average', + gridLeftExtension, ); } } else if (useNativeFftEngine) { @@ -230,7 +253,8 @@ export async function render(env, state) { range, freqBounds, ballisticsData?.overlay || null, - ballisticsData?.mode || CONFIG.RTA_BALLISTICS_MODE || 'average' + ballisticsData?.mode || CONFIG.RTA_BALLISTICS_MODE || 'average', + gridLeftExtension, ); } } else if (!analyser || !buf) { @@ -259,7 +283,8 @@ export async function render(env, state) { range, freqBounds, null, - CONFIG.RTA_BALLISTICS_MODE || 'average' + CONFIG.RTA_BALLISTICS_MODE || 'average', + gridLeftExtension, ); } } @@ -552,7 +577,33 @@ function applyRealtimeBarBallistics(state, levels, CONFIG, range) { return buffer; } -function renderSpectrum(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlayPeaks, ballisticsMode) { +export function buildRtwTickPositions(centers, ticks = RTA_X_TICKS) { + if (!isVectorLike(centers) || !centers.length) return {}; + const normalizedCenters = Array.from(centers, Number) + .filter((value) => Number.isFinite(value) && value > 0); + if (!normalizedCenters.length) return {}; + const positions = {}; + const count = normalizedCenters.length; + for (const tick of ticks || []) { + const frequency = Number(tick); + if (!Number.isFinite(frequency) || frequency <= 0) continue; + let bestIndex = -1; + let bestRelativeError = Infinity; + for (let index = 0; index < count; index++) { + const relativeError = Math.abs(normalizedCenters[index] - frequency) / frequency; + if (relativeError < bestRelativeError) { + bestRelativeError = relativeError; + bestIndex = index; + } + } + if (bestIndex >= 0 && bestRelativeError <= 0.02) { + positions[String(tick)] = (bestIndex + 0.5) / count; + } + } + return positions; +} + +function renderSpectrum(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlayPeaks, ballisticsMode, rtwLeftExtension = 0) { const layout = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'rtw' : 'iec'; const overlay = (ballisticsMode === 'both' && isVectorLike(overlayPeaks) && overlayPeaks.length === state.mapping.length) ? overlayPeaks @@ -560,7 +611,8 @@ function renderSpectrum(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, ra if ((CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'line') { renderLine(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlay); } else if (layout === 'rtw') { - renderRtwBars(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, overlay); + const extension = Math.max(0, Number(rtwLeftExtension) || 0); + renderRtwBars(g, state, levels, plotX - extension, plotY, plotW + extension, plotH, CONFIG, range, overlay); } else { renderIecBars(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlay); } @@ -742,32 +794,6 @@ async function drawMeter(env, state, plotX, plotY, plotW, plotH) { g.restore(); } -function createStaticLayerCanvas(width, height) { - const w = Math.max(1, width | 0); - const h = Math.max(1, height | 0); - if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(w, h); - const c = document.createElement('canvas'); - c.width = w; - c.height = h; - return c; -} - -function drawCachedStaticLayer(state, g, layerId, key, rect, build) { - if (!state || !rect || rect.w <= 0 || rect.h <= 0) return; - if (!state.staticLayers) state.staticLayers = new Map(); - const fullKey = `${layerId}:${key}:${Math.max(0, rect.w | 0)}x${Math.max(0, rect.h | 0)}`; - let layer = state.staticLayers.get(fullKey); - if (!layer) { - const canvas = createStaticLayerCanvas(rect.w, rect.h); - const ctx = canvas.getContext('2d'); - build(ctx); - layer = { canvas }; - state.staticLayers.set(fullKey, layer); - } - g.drawImage(layer.canvas, rect.x, rect.y); -} - - function mapLogX(freq, x0, w, logMin, logSpan) { const clamped = Math.max(5, freq); const frac = (Math.log10(clamped) - logMin) / (logSpan || 1); diff --git a/www/views/split_view.js b/www/views/split_view.js index a5d0461..6e3cf6b 100644 --- a/www/views/split_view.js +++ b/www/views/split_view.js @@ -1,300 +1,28 @@ // views/split_view.js — Split-View: zwei nebeneinander gerenderte Views (links/rechts) -import * as viewGoni from './goniometer_rtw.js'; -import * as viewPhaseWheel from './phase_wheel.js'; -import * as viewPanel from './panel.js'; -import * as viewRealtime from './realtime.js'; -import * as viewClassicNeedles from './classic_needles.js'; -import * as viewPeakHistory from './peak_history.js'; -import * as viewClock from './clock.js'; -import * as viewWaveform from './waveform.js'; -import * as viewSpectrogram from './spectrogram.js'; -import { DEFAULT_TOP_INSET, FRAME_COLOR, PANEL_BG } from '../core/theme.js'; +import { DEFAULT_TOP_INSET } from '../core/theme.js'; +import { + CHILD_VIEWS, + computeMultiViewBaseLayout, + drawEmptyPlot, + drawMetersPanel, + readMultiViewMeters, + sanitizeChildId, + sanitizePlotId, + withClippedSubRect, +} from './multi_view_shared.js'; export const id = 'split-view'; const CONTENT_TOP = DEFAULT_TOP_INSET; const CONTENT_BOTTOM = 0; -const CHILD_VIEWS = { - 'realtime': viewRealtime, - 'classic-needles': viewClassicNeedles, - 'peak-history': viewPeakHistory, - 'goniometer-rtw': viewGoni, - 'phase-wheel': viewPhaseWheel, - 'panel': viewPanel, - 'clock': viewClock, - 'waveform': viewWaveform, - 'spectrogram': viewSpectrogram, -}; -const ALLOWED_CHILD_VIEW_IDS = new Set(['none', ...Object.keys(CHILD_VIEWS)]); -const ALLOWED_SPLIT_PLOT_IDS = new Set(['none', 'phase-wheel', 'realtime', 'goniometer-rtw', 'peak-history', 'classic-needles', 'panel', 'clock', 'waveform', 'spectrogram']); -const ALLOWED_METER_IDS = new Set(['none', 'vu', 'ppm-ebu', 'ppm-din', 'tp', 'hifi-peak', 'rms', 'lufs', 'stopwatch']); -const ALLOWED_METER_POSITIONS = new Set(['left', 'center', 'right']); - -const OUTER_GAP = 10; -const SIDE_INNER_GAP = 8; -const METER_GAP = 12; -const METER_W_DEFAULT = 140; -const METER_W_MIN = 90; -const MIN_PLOT_W = 240; -const METER_PAD_TOP = 15; -const METER_PAD_BOTTOM = 5; -const METER_SLOT_SHRINK = 24; -const METER_EXTRA_BOTTOM_PAD = 6; - -function sanitizeChildId(val, fallback) { - return ALLOWED_CHILD_VIEW_IDS.has(val) ? val : fallback; -} - -function sanitizePlotId(val, fallback) { - return ALLOWED_SPLIT_PLOT_IDS.has(val) ? val : fallback; -} - -function sanitizeMeterId(val, fallback) { - const id = String(val || ''); - return ALLOWED_METER_IDS.has(id) ? id : fallback; -} - -function sanitizeMeterPos(val, fallback) { - const id = String(val || ''); - return ALLOWED_METER_POSITIONS.has(id) ? id : fallback; -} - -function clampMeterCount(val) { - const n = Number(val); - if (!Number.isFinite(n)) return 0; - return Math.max(0, Math.min(3, n | 0)); -} - -async function withClippedSubRect(g, rect, fn) { - g.save(); - g.translate(rect.x, rect.y); - g.beginPath(); - g.rect(0, 0, rect.w, rect.h); - g.clip(); - try { return await fn(); } finally { g.restore(); } -} - -function drawSubframe(g, rect) { - g.save(); - g.strokeStyle = FRAME_COLOR; - g.lineWidth = 2; - g.strokeRect(rect.x + 0.5, rect.y + 0.5, Math.max(0, rect.w - 1), Math.max(0, rect.h - 1)); - g.restore(); -} - -function createStaticLayerCanvas(width, height) { - const w = Math.max(1, width | 0); - const h = Math.max(1, height | 0); - if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(w, h); - const c = document.createElement('canvas'); - c.width = w; - c.height = h; - return c; -} - -function drawCachedStaticLayer(state, g, layerId, key, rect, build) { - if (!state || !rect || rect.w <= 0 || rect.h <= 0) return; - if (!state.staticLayers) state.staticLayers = new Map(); - const fullKey = `${layerId}:${key}:${Math.max(0, rect.w | 0)}x${Math.max(0, rect.h | 0)}`; - let layer = state.staticLayers.get(fullKey); - if (!layer) { - const canvas = createStaticLayerCanvas(rect.w, rect.h); - const ctx = canvas.getContext('2d'); - build(ctx, rect); - layer = { canvas }; - state.staticLayers.set(fullKey, layer); - } - g.drawImage(layer.canvas, rect.x, rect.y); -} - -function drawEmptyPlot(state, g, rect, label) { - drawCachedStaticLayer(state, g, 'empty-plot', String(label || '(leer)'), rect, (lg) => { - lg.fillStyle = PANEL_BG; - lg.fillRect(0, 0, rect.w, rect.h); - lg.fillStyle = '#9aa'; - lg.textAlign = 'left'; - lg.font = 'bold 14px ui-monospace, monospace'; - lg.fillText(label || '(leer)', 12, 32); - lg.textAlign = 'start'; - }); -} - function readSplitMeters(CONFIG) { - const count = clampMeterCount(CONFIG?.SPLIT_VIEW_METER_COUNT); - const slots = [ - { - id: sanitizeMeterId(CONFIG?.SPLIT_VIEW_METER_1, 'vu'), - pos: sanitizeMeterPos(CONFIG?.SPLIT_VIEW_METER_1_POS, 'right'), - }, - { - id: sanitizeMeterId(CONFIG?.SPLIT_VIEW_METER_2, 'ppm-din'), - pos: sanitizeMeterPos(CONFIG?.SPLIT_VIEW_METER_2_POS, 'right'), - }, - { - id: sanitizeMeterId(CONFIG?.SPLIT_VIEW_METER_3, 'lufs'), - pos: sanitizeMeterPos(CONFIG?.SPLIT_VIEW_METER_3_POS, 'right'), - }, - ].slice(0, count); - return slots; -} - -function groupMetersByPosition(slots) { - const out = { left: [], center: [], right: [] }; - for (const s of slots || []) { - if (!s) continue; - if (s.pos === 'left') out.left.push(s.id); - else if (s.pos === 'center') out.center.push(s.id); - else out.right.push(s.id); - } - return out; + return readMultiViewMeters(CONFIG, 'SPLIT_VIEW'); } function computeSplitLayout(rect, slots) { - const contentH = Math.max(0, rect.h - CONTENT_TOP - CONTENT_BOTTOM); - const hasContent = contentH >= 140; - const BLOCK_GAP = SIDE_INNER_GAP; - - let effectiveSlots = hasContent ? (Array.isArray(slots) ? slots.slice(0, 3) : []) : []; - while (true) { - const grouped = groupMetersByPosition(effectiveSlots); - const leftCount = grouped.left.length; - const centerCount = grouped.center.length; - const rightCount = grouped.right.length; - const totalSlots = leftCount + centerCount + rightCount; - - const hasLeftMeters = hasContent && leftCount > 0; - const hasCenterMeters = hasContent && centerCount > 0; - const hasRightMeters = hasContent && rightCount > 0; - - const interBlockGaps = - (hasLeftMeters ? BLOCK_GAP : 0) + - (hasRightMeters ? BLOCK_GAP : 0) + - (hasCenterMeters ? (2 * BLOCK_GAP) : OUTER_GAP); - - const internalGaps = - Math.max(0, leftCount - 1) * METER_GAP + - Math.max(0, centerCount - 1) * METER_GAP + - Math.max(0, rightCount - 1) * METER_GAP; - - const minRequired = 2 * MIN_PLOT_W + interBlockGaps + internalGaps; - const remainingForMeters = rect.w - minRequired; - - let slotW = 0; - if (totalSlots > 0) { - slotW = Math.floor(remainingForMeters / totalSlots); - slotW = Math.min(METER_W_DEFAULT, slotW); - } - - if (totalSlots > 0 && slotW < METER_W_MIN && effectiveSlots.length) { - effectiveSlots = effectiveSlots.slice(0, -1); - continue; - } - if (totalSlots > 0) slotW = Math.max(METER_W_MIN, Math.min(METER_W_DEFAULT, slotW)); - - const leftW = hasLeftMeters ? (leftCount * slotW + Math.max(0, leftCount - 1) * METER_GAP) : 0; - const centerW = hasCenterMeters ? (centerCount * slotW + Math.max(0, centerCount - 1) * METER_GAP) : 0; - const rightW = hasRightMeters ? (rightCount * slotW + Math.max(0, rightCount - 1) * METER_GAP) : 0; - - const metersTotalW = leftW + centerW + rightW; - const plotAvail = Math.max(0, rect.w - interBlockGaps - metersTotalW); - const leftPlotW = Math.floor(plotAvail / 2); - const rightPlotW = plotAvail - leftPlotW; - - const plotFits = leftPlotW >= MIN_PLOT_W && rightPlotW >= MIN_PLOT_W; - if (!plotFits && effectiveSlots.length) { - effectiveSlots = effectiveSlots.slice(0, -1); - continue; - } - - let x = 0; - const leftMetersRect = hasLeftMeters ? { x, y: CONTENT_TOP, w: leftW, h: contentH } : null; - if (leftMetersRect) x += leftW + BLOCK_GAP; - - const leftPlotRect = { x, y: 0, w: leftPlotW, h: rect.h }; - x += leftPlotW; - - let centerMetersRect = null; - if (hasCenterMeters) { - x += BLOCK_GAP; - centerMetersRect = { x, y: CONTENT_TOP, w: centerW, h: contentH }; - x += centerW + BLOCK_GAP; - } else { - x += OUTER_GAP; - } - - const rightPlotRect = { x, y: 0, w: rightPlotW, h: rect.h }; - x += rightPlotW; - - let rightMetersRect = null; - if (hasRightMeters) { - x += BLOCK_GAP; - rightMetersRect = { x, y: CONTENT_TOP, w: rightW, h: contentH }; - } - - return { - plots: { left: leftPlotRect, right: rightPlotRect }, - meters: { left: leftMetersRect, center: centerMetersRect, right: rightMetersRect }, - meterIds: grouped, - slotW, - effectiveSlots, - }; - } -} - -async function drawMetersPanel(env, state, rect, meterIds, slotW) { - const { ctx: g, meters, config: CONFIG } = env; - if (!rect || rect.w <= 0 || rect.h <= 0) return; - const ids = Array.isArray(meterIds) ? meterIds.slice(0, 3) : []; - const count = ids.length; - if (!count) return; - - drawCachedStaticLayer(state, g, 'meter-panel-shell', 'bg-frame', rect, (lg) => { - lg.fillStyle = PANEL_BG; - lg.fillRect(0, 0, rect.w, rect.h); - drawSubframe(lg, { x: 0, y: 0, w: rect.w, h: rect.h }); - }); - - const gap = METER_GAP; - const n = Math.max(1, Math.min(3, count)); - const usedW = n * slotW + (n - 1) * gap; - const startX = rect.x + Math.max(0, Math.floor((rect.w - usedW) / 2)); - const shrink = Math.max(0, METER_SLOT_SHRINK); - const innerHeight = Math.max(40, rect.h - METER_PAD_TOP - METER_PAD_BOTTOM - shrink - METER_EXTRA_BOTTOM_PAD); - const innerOffset = shrink / 2; - const slotY = rect.y + METER_PAD_TOP + innerOffset; - const slotH = innerHeight; - - for (let i = 0; i < n; i++) { - const id = sanitizeMeterId(ids[i], 'none'); - const r = { x: startX + i * (slotW + gap), y: slotY, w: slotW, h: slotH }; - if (id === 'none') { - drawCachedStaticLayer(state, g, 'meter-slot-empty', `${slotW}x${slotH}`, r, (lg) => { - lg.strokeStyle = 'rgba(0,231,255,0.25)'; - lg.setLineDash([6, 5]); - lg.strokeRect(0.5, 0.5, Math.max(0, r.w - 1), Math.max(0, r.h - 1)); - lg.setLineDash([]); - lg.fillStyle = '#9aa'; - lg.textAlign = 'center'; - lg.font = '12px ui-monospace, monospace'; - lg.fillText('(leer)', r.w / 2, 22); - lg.textAlign = 'start'; - }); - } else { - try { - g.save(); - g.beginPath(); - g.rect(rect.x + 1, rect.y + 1, Math.max(0, rect.w - 2), Math.max(0, rect.h - 2)); - g.clip(); - await meters.draw(g, r, id, CONFIG); - g.restore(); - } catch (e) { - g.restore(); - console.warn('Split meter draw error:', e); - } - } - } + return computeMultiViewBaseLayout(rect, slots, CONTENT_TOP, CONTENT_BOTTOM); } function destroyChild(child) { diff --git a/www/views/static_layer.js b/www/views/static_layer.js index 5ce7c7d..dee2ff0 100644 --- a/www/views/static_layer.js +++ b/www/views/static_layer.js @@ -1,11 +1,9 @@ +import { createCanvasSurface } from '../core/canvas_surface.js'; + +const MAX_STATIC_LAYERS = 24; + export function createStaticLayerCanvas(width, height) { - const w = Math.max(1, width | 0); - const h = Math.max(1, height | 0); - if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(w, h); - const c = document.createElement('canvas'); - c.width = w; - c.height = h; - return c; + return createCanvasSurface(width, height)?.canvas || null; } export function drawCachedStaticLayer(state, g, layerId, key, rect, build) { @@ -13,12 +11,21 @@ export function drawCachedStaticLayer(state, g, layerId, key, rect, build) { if (!state.staticLayers) state.staticLayers = new Map(); const fullKey = `${layerId}:${key}:${Math.max(0, rect.w | 0)}x${Math.max(0, rect.h | 0)}`; let layer = state.staticLayers.get(fullKey); + if (layer) { + // Map-Reihenfolge als kleine LRU-Liste verwenden. + state.staticLayers.delete(fullKey); + state.staticLayers.set(fullKey, layer); + } if (!layer) { const canvas = createStaticLayerCanvas(rect.w, rect.h); + if (!canvas) return; const ctx = canvas.getContext('2d'); build(ctx, rect); layer = { canvas }; state.staticLayers.set(fullKey, layer); + while (state.staticLayers.size > MAX_STATIC_LAYERS) { + state.staticLayers.delete(state.staticLayers.keys().next().value); + } } g.drawImage(layer.canvas, rect.x, rect.y); }