Initial Phoenix analyzer
This commit is contained in:
+21
@@ -0,0 +1,21 @@
|
||||
.DS_Store
|
||||
|
||||
# Rust build output
|
||||
target/
|
||||
|
||||
# Local editor and environment files
|
||||
.env
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Runtime and generated media
|
||||
*.log
|
||||
*.wav
|
||||
*.mp3
|
||||
*.webm
|
||||
recordings/
|
||||
Aufnahmen/
|
||||
|
||||
# Temporary archives
|
||||
*.zip
|
||||
*.tar.gz
|
||||
Generated
+1024
File diff suppressed because it is too large
Load Diff
+21
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "phoenix"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
description = "Native Raspberry Pi audio backend for the Analyzer browser UI"
|
||||
license = "UNLICENSED"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
axum = { version = "0.7", features = ["ws", "macros"] }
|
||||
futures-util = "0.3"
|
||||
http-body-util = "0.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
tokio = { version = "1.37", features = ["full"] }
|
||||
tower-http = { version = "0.5", features = ["cors", "trace"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
alsa = "0.10"
|
||||
@@ -0,0 +1,108 @@
|
||||
Mini-Dokumentation: Analyzer Kiosk auf Raspberry Pi OS
|
||||
|
||||
1. Dienste deaktivieren
|
||||
sudo systemctl disable --now lightdm.service
|
||||
sudo systemctl disable --now getty@tty1.service
|
||||
sudo systemctl reset-failed getty@tty1.service
|
||||
|
||||
2. systemd neu laden
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
3. Benutzer vorbereiten
|
||||
sudo usermod -aG audio,video,input,render analyzer
|
||||
|
||||
4. Build-Abhängigkeiten installieren
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libasound2-dev curl ca-certificates git build-essential
|
||||
|
||||
5. Projekt nach /home/analyzer/Phoenix kopieren
|
||||
Erwartete Struktur:
|
||||
/home/analyzer/Phoenix/
|
||||
|- kiosk-start.sh
|
||||
|- session.sh
|
||||
|- www/
|
||||
|- etc/systemd/system/
|
||||
| |- analyzer-kiosk.service
|
||||
| `- analyzer-web.service
|
||||
`- target/release/phoenix
|
||||
|
||||
Hinweis:
|
||||
- Das Binary target/release/phoenix wird bei Bedarf automatisch gebaut.
|
||||
|
||||
6. Skripte ausführbar machen
|
||||
sudo chmod 755 /home/analyzer/Phoenix/session.sh
|
||||
sudo chmod 755 /home/analyzer/Phoenix/kiosk-start.sh
|
||||
|
||||
7. systemd-Units installieren
|
||||
sudo cp /home/analyzer/Phoenix/etc/systemd/system/analyzer-kiosk.service /etc/systemd/system/
|
||||
sudo cp /home/analyzer/Phoenix/etc/systemd/system/analyzer-web.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
8. Dienste aktivieren
|
||||
sudo systemctl enable analyzer-web.service
|
||||
sudo systemctl enable analyzer-kiosk.service
|
||||
|
||||
9. Dienste starten
|
||||
sudo systemctl restart analyzer-web.service
|
||||
sudo systemctl restart analyzer-kiosk.service
|
||||
|
||||
10. Prüfen
|
||||
sudo systemctl status analyzer-web.service --no-pager
|
||||
sudo systemctl status analyzer-kiosk.service --no-pager
|
||||
sudo systemctl cat analyzer-kiosk.service
|
||||
sudo systemctl cat analyzer-web.service
|
||||
tail -n 80 /home/analyzer/analyzer-kiosk.log
|
||||
|
||||
Erwartet:
|
||||
- Webserver läuft auf Port 80
|
||||
- Kiosk startet über kiosk-start.sh
|
||||
- GUI lokal unter http://localhost/
|
||||
- Phoenix unter http://<pi-ip>:8789
|
||||
|
||||
11. Erster Start / Update
|
||||
- Wenn target/release/phoenix fehlt oder der Rust-Code neuer ist, baut kiosk-start.sh automatisch neu.
|
||||
- Falls cargo/rustc fehlen oder das System-Rust zu alt ist, versucht kiosk-start.sh eine aktuelle Rust-Toolchain per rustup zu installieren.
|
||||
- Zusätzlich installiert das Startskript Build-Abhängigkeiten wie pkg-config, libasound2-dev, curl, git und build-essential per apt.
|
||||
- Ohne pkg-config und libasound2-dev schlägt der ALSA-Rust-Build mit `alsa-sys`/`alsa.pc not found` fehl.
|
||||
- Mit sehr altem Debian-/Volumio-Rust (`rustc 1.63` o.ä.) schlägt der Build an neueren Crates fehl; deshalb nutzt Phoenix bei Bedarf rustup/stable.
|
||||
- Währenddessen erscheint die Meldung auf tty1, erst danach startet X/Chromium.
|
||||
|
||||
12. Externer Zugriff
|
||||
- GUI: http://<pi-ip>/
|
||||
- Phoenix: http://<pi-ip>:8789
|
||||
|
||||
13. Referenz-Global-Config setzen
|
||||
|
||||
Analyzer:
|
||||
```bash
|
||||
mkdir -p /home/analyzer/.config/phoenix
|
||||
cp /home/analyzer/Phoenix/reference-config/phoenix-global-config.analyzer.json /home/analyzer/.config/phoenix/global-config.json
|
||||
```
|
||||
|
||||
Volumio:
|
||||
```bash
|
||||
mkdir -p /home/volumio/.config/phoenix
|
||||
cp /home/volumio/Phoenix/reference-config/phoenix-global-config.volumio.json /home/volumio/.config/phoenix/global-config.json
|
||||
```
|
||||
|
||||
Danach Phoenix bzw. den Kiosk-Dienst neu starten.
|
||||
|
||||
14. Analyzer vs. Volumio
|
||||
|
||||
Analyzer:
|
||||
- Pfad: `/home/analyzer/Phoenix`
|
||||
- Webserver: `:80`
|
||||
- GUI lokal: `http://localhost/`
|
||||
- Kiosk-Session: `session.sh`
|
||||
|
||||
Volumio:
|
||||
- Pfad: `/home/volumio/Phoenix`
|
||||
- Analyzer-Webserver: `:8088`
|
||||
- Analyzer-GUI lokal: `http://localhost:8088/`
|
||||
- Volumio intern: `http://localhost:3000/`
|
||||
- Kiosk-Session: `session_volumio.sh`
|
||||
- `toggle-ext`: `/home/volumio/Phoenix/toggle-ext`
|
||||
|
||||
Wichtig
|
||||
- Diese Anleitung geht von Benutzer analyzer und Pfad /home/analyzer/Phoenix aus.
|
||||
- Wenn Benutzer oder Home anders heißen, müssen die Pfade in analyzer-kiosk.service und analyzer-web.service angepasst werden.
|
||||
@@ -0,0 +1,241 @@
|
||||
# Phoenix v1.0.0
|
||||
|
||||
Phoenix ist das native Rust-Backend des Analyzers auf Raspberry Pi.
|
||||
|
||||
Der Browser rendert nur noch die GUI. Audio-Capture, Messung, Recorder und die globale Gerätekonfiguration laufen in Phoenix.
|
||||
|
||||
## Aktueller Stand
|
||||
|
||||
Phoenix übernimmt heute:
|
||||
|
||||
- ALSA-Audio-Capture
|
||||
- Live-Metering über WebSocket
|
||||
- globale Gerätekonfiguration
|
||||
- RTA
|
||||
- Spectrogram
|
||||
- Waveform
|
||||
- XY / Goniometer
|
||||
- Phase Wheel
|
||||
- Recorder in `wav`, `mp3` und `webm`
|
||||
- direktes Speichern auf dem Gerät
|
||||
|
||||
Nicht mehr Teil der Architektur:
|
||||
|
||||
- WebAudio-/AudioWorklet-Analyse im Browser
|
||||
- Browser-MediaRecorder als Haupt-Recorder
|
||||
- Umschalten zwischen `line` und `spdif`
|
||||
|
||||
Der aktuelle Zielpfad ist `Line In` auf der Behringer/USB-Audio-Hardware.
|
||||
|
||||
## Architektur
|
||||
|
||||
- Phoenix besitzt die Audio-Hardware
|
||||
- Phoenix berechnet die Messwerte
|
||||
- die Web-GUI verbindet sich per `HTTP + WebSocket` mit Phoenix
|
||||
- globale Geräteoptionen werden in Phoenix gespeichert und an Browser synchronisiert
|
||||
- browser-lokale Optionen wie `Phoenix URL` bleiben clientseitig
|
||||
|
||||
## Standard-Endpunkte
|
||||
|
||||
- `GET /health`
|
||||
- `GET /api/v1/status`
|
||||
- `GET /api/v1/global-config`
|
||||
- `POST /api/v1/global-config`
|
||||
- `GET /api/v1/rta-config`
|
||||
- `POST /api/v1/rta-config`
|
||||
- `GET /api/v1/metrics/ws`
|
||||
- `POST /api/v1/recordings/wav/start/:session_id`
|
||||
- `POST /api/v1/recordings/wav/stop/:session_id`
|
||||
- `POST /api/v1/recordings/stop/:session_id/:format`
|
||||
- `POST /api/v1/recordings/save/:target/:filename`
|
||||
|
||||
## Build
|
||||
|
||||
Lokaler Build:
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Check:
|
||||
|
||||
```bash
|
||||
cargo check
|
||||
```
|
||||
|
||||
Das Binary liegt danach unter:
|
||||
|
||||
```text
|
||||
target/release/phoenix
|
||||
```
|
||||
|
||||
## Raspberry Pi Betrieb
|
||||
|
||||
Für den Pi ist der maßgebliche Ablauf in:
|
||||
|
||||
[RASPBERRY_PI_SETUP.txt](/Users/mike_i386/Desktop/RÄTSEL/VERSION2/Phoenix/RASPBERRY_PI_SETUP.txt)
|
||||
|
||||
Dort sind enthalten:
|
||||
|
||||
- benötigte Systempakete
|
||||
- systemd-Services
|
||||
- Kiosk-Start
|
||||
- Webserver auf Port `80`
|
||||
- Autostart
|
||||
|
||||
Relevante Dateien:
|
||||
|
||||
- [kiosk-start.sh](/Users/mike_i386/Desktop/RÄTSEL/VERSION2/Phoenix/kiosk-start.sh)
|
||||
- [session.sh](/Users/mike_i386/Desktop/RÄTSEL/VERSION2/Phoenix/session.sh)
|
||||
- [analyzer-kiosk.service](/Users/mike_i386/Desktop/RÄTSEL/VERSION2/Phoenix/etc/systemd/system/analyzer-kiosk.service)
|
||||
- [analyzer-web.service](/Users/mike_i386/Desktop/RÄTSEL/VERSION2/Phoenix/etc/systemd/system/analyzer-web.service)
|
||||
|
||||
## Volumio-Betrieb
|
||||
|
||||
Für Volumio gibt es einen getrennten Kiosk-Pfad mit eigener Session und eigenen systemd-Units:
|
||||
|
||||
- [session_volumio.sh](/Users/mike_i386/Desktop/RÄTSEL/VERSION2/Phoenix/session_volumio.sh)
|
||||
- [analyzer-kiosk-volumio.service](/Users/mike_i386/Desktop/RÄTSEL/VERSION2/Phoenix/etc/systemd/system/analyzer-kiosk-volumio.service)
|
||||
- [analyzer-web-volumio.service](/Users/mike_i386/Desktop/RÄTSEL/VERSION2/Phoenix/etc/systemd/system/analyzer-web-volumio.service)
|
||||
|
||||
Erwarteter Projektpfad auf dem Zielgerät:
|
||||
|
||||
```text
|
||||
/home/volumio/Phoenix
|
||||
```
|
||||
|
||||
Volumio-spezifisch:
|
||||
|
||||
- `session_volumio.sh` startet Analyzer-GUI und Volumio im Dual-Tab-Kiosk
|
||||
- die Analyzer-GUI läuft im Volumio-Fall auf `http://localhost:8088/`
|
||||
- `toggle-ext` wird unter `Phoenix/toggle-ext` erwartet
|
||||
- `VirtualKeyboard` wird bevorzugt unter `Phoenix/VirtualKeyboard` gesucht, mit Fallback auf die bekannten Systempfade
|
||||
- Logs liegen typischerweise unter:
|
||||
|
||||
```text
|
||||
/home/volumio/dual-kiosk.log
|
||||
/home/volumio/phoenix.log
|
||||
```
|
||||
|
||||
Wenn `toggle-ext` genutzt werden soll, sollte es also unter diesem Pfad liegen:
|
||||
|
||||
```text
|
||||
/home/volumio/Phoenix/toggle-ext
|
||||
```
|
||||
|
||||
## Analyzer vs. Volumio
|
||||
|
||||
Die beiden Betriebsarten teilen sich Phoenix, aber nicht denselben Kiosk-Rahmen.
|
||||
|
||||
- Analyzer:
|
||||
- Projektpfad: `/home/analyzer/Phoenix`
|
||||
- Web-GUI: `http://localhost/`
|
||||
- systemd-Units: [analyzer-kiosk.service](/Users/mike_i386/Desktop/RÄTSEL/VERSION2/Phoenix/etc/systemd/system/analyzer-kiosk.service), [analyzer-web.service](/Users/mike_i386/Desktop/RÄTSEL/VERSION2/Phoenix/etc/systemd/system/analyzer-web.service)
|
||||
- Session: [session.sh](/Users/mike_i386/Desktop/RÄTSEL/VERSION2/Phoenix/session.sh)
|
||||
- Chromium-Profil: `/data/chrome-analyzer`
|
||||
|
||||
- Volumio:
|
||||
- Projektpfad: `/home/volumio/Phoenix`
|
||||
- Analyzer-GUI: `http://localhost:8088/`
|
||||
- Volumio-GUI intern: `http://localhost:3000/`
|
||||
- systemd-Units: [analyzer-kiosk-volumio.service](/Users/mike_i386/Desktop/RÄTSEL/VERSION2/Phoenix/etc/systemd/system/analyzer-kiosk-volumio.service), [analyzer-web-volumio.service](/Users/mike_i386/Desktop/RÄTSEL/VERSION2/Phoenix/etc/systemd/system/analyzer-web-volumio.service)
|
||||
- Session: [session_volumio.sh](/Users/mike_i386/Desktop/RÄTSEL/VERSION2/Phoenix/session_volumio.sh)
|
||||
- Chromium-Profil: `/data/chrome-dualtabs`
|
||||
- `toggle-ext` erwartet unter `/home/volumio/Phoenix/toggle-ext`
|
||||
|
||||
Wichtig:
|
||||
|
||||
- Volumio darf den Analyzer-Webserver nicht auf `:80` binden, weil dort Volumio selbst bzw. dessen Frontproxy hängt.
|
||||
- Die einzige beabsichtigte Global-Config-Abweichung zwischen den Referenzständen ist normalerweise `recordTarget`.
|
||||
|
||||
## Referenz-Global-Configs
|
||||
|
||||
Für einen sauberen Gerätestand liegen zwei Referenzdateien im Projekt:
|
||||
|
||||
- [phoenix-global-config.analyzer.json](/Users/mike_i386/Desktop/RÄTSEL/VERSION2/Phoenix/reference-config/phoenix-global-config.analyzer.json)
|
||||
- [phoenix-global-config.volumio.json](/Users/mike_i386/Desktop/RÄTSEL/VERSION2/Phoenix/reference-config/phoenix-global-config.volumio.json)
|
||||
|
||||
Kopieren auf dem Zielgerät:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/phoenix
|
||||
cp /home/<user>/Phoenix/reference-config/phoenix-global-config.<profil>.json ~/.config/phoenix/global-config.json
|
||||
```
|
||||
|
||||
Beispiele:
|
||||
|
||||
```bash
|
||||
cp /home/analyzer/Phoenix/reference-config/phoenix-global-config.analyzer.json /home/analyzer/.config/phoenix/global-config.json
|
||||
cp /home/volumio/Phoenix/reference-config/phoenix-global-config.volumio.json /home/volumio/.config/phoenix/global-config.json
|
||||
```
|
||||
|
||||
Danach Phoenix bzw. den Kiosk-Dienst neu starten.
|
||||
|
||||
## Erster Start auf neuem Gerät
|
||||
|
||||
Für einen frischen Pi werden mindestens diese Build-Abhängigkeiten benötigt:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libasound2-dev curl ca-certificates git build-essential
|
||||
```
|
||||
|
||||
Wenn `cargo`/`rustc` fehlen oder zu alt sind, kann `kiosk-start.sh` beim Start eine aktuelle Rust-Toolchain per `rustup` installieren.
|
||||
Das ist besonders für ältere Volumio-/Debian-Systeme wichtig, deren `apt`-Rust oft zu alt für aktuelle Crates ist.
|
||||
|
||||
## Wichtige Umgebungsvariablen
|
||||
|
||||
- `PHOENIX_LISTEN`
|
||||
default: `127.0.0.1:8789`
|
||||
|
||||
- `PHOENIX_ALSA_CARD`
|
||||
default: `2`
|
||||
|
||||
- `PHOENIX_ALSA_DEVICE`
|
||||
optional explizites ALSA-Gerät, z. B. `hw:CARD=CODEC,DEV=0`
|
||||
|
||||
- `PHOENIX_SAMPLE_RATE`
|
||||
default: `48000`
|
||||
|
||||
- `PHOENIX_PERIOD_SIZE`
|
||||
default: `128`
|
||||
|
||||
- `PHOENIX_BUFFER_SIZE`
|
||||
default: `512`
|
||||
|
||||
- `PHOENIX_GLOBAL_CONFIG_PATH`
|
||||
default: `$HOME/.config/phoenix/global-config.json`
|
||||
|
||||
- `PHOENIX_RECORDINGS_DIR_ANALYZER`
|
||||
default: `/home/analyzer/Aufnahmen`
|
||||
|
||||
- `PHOENIX_RECORDINGS_DIR_VOLUMIO`
|
||||
default: `/home/volumio/Aufnahmen`
|
||||
|
||||
- `PHOENIX_MAX_RECORDING_UPLOAD_BYTES`
|
||||
default: `2147483648`
|
||||
|
||||
- `PHOENIX_LR_FRAC_DELAY_ENABLED`
|
||||
default: `true`
|
||||
|
||||
- `PHOENIX_LR_FRAC_DELAY_SAMPLES`
|
||||
default: `0.996`
|
||||
|
||||
- `PHOENIX_LOG`
|
||||
default: `phoenix=info,tower_http=info`
|
||||
|
||||
## Hinweise
|
||||
|
||||
- Phoenix ist jetzt der produktive Hauptpfad, nicht mehr nur ein Scaffold.
|
||||
- Das Changelog der GUI liegt in [index.html](/Users/mike_i386/Desktop/RÄTSEL/VERSION2/Phoenix/www/index.html).
|
||||
- Build-Logs beim Pi-Start landen in der Regel unter:
|
||||
|
||||
```text
|
||||
~/phoenix-build.log
|
||||
```
|
||||
|
||||
- Laufzeit-Logs der X-/Kiosk-Session liegen unter:
|
||||
|
||||
```text
|
||||
/home/analyzer/analyzer-kiosk.log
|
||||
```
|
||||
@@ -0,0 +1,26 @@
|
||||
[Unit]
|
||||
Description=Analyzer Kiosk Volumio (Raspberry Pi OS)
|
||||
After=network-online.target
|
||||
After=analyzer-web-volumio.service
|
||||
Wants=analyzer-web-volumio.service
|
||||
|
||||
[Service]
|
||||
User=root
|
||||
Group=root
|
||||
Environment=HOME=/root
|
||||
Environment=KIOSK_USER=volumio
|
||||
Environment=SESSION_SCRIPT=/home/volumio/Phoenix/session_volumio.sh
|
||||
WorkingDirectory=/home/volumio/Phoenix
|
||||
TTYPath=/dev/tty1
|
||||
TTYReset=yes
|
||||
TTYVHangup=yes
|
||||
TTYVTDisallocate=yes
|
||||
StandardInput=tty
|
||||
StandardOutput=tty
|
||||
StandardError=tty
|
||||
ExecStart=/bin/bash /home/volumio/Phoenix/kiosk-start.sh
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,36 @@
|
||||
[Unit]
|
||||
Description=Analyzer Kiosk (Raspberry Pi OS)
|
||||
After=network-online.target
|
||||
After=analyzer-web.service
|
||||
Wants=analyzer-web.service
|
||||
|
||||
[Service]
|
||||
# Run as root to ensure we can control the VT and X server.
|
||||
User=root
|
||||
Group=root
|
||||
Environment=HOME=/root
|
||||
WorkingDirectory=/home/analyzer/Phoenix
|
||||
|
||||
# Allocate a dedicated virtual terminal for the X server. Without this,
|
||||
# xinit will fail when launched from an SSH session because it cannot
|
||||
# open /dev/tty0. tty1 is reserved for this service.
|
||||
TTYPath=/dev/tty1
|
||||
TTYReset=yes
|
||||
TTYVHangup=yes
|
||||
TTYVTDisallocate=yes
|
||||
StandardInput=tty
|
||||
StandardOutput=tty
|
||||
StandardError=tty
|
||||
|
||||
# Start a minimal X server and run our kiosk session script. The web UI
|
||||
# is served by analyzer-web.service; this unit only brings up X,
|
||||
# Chromium and Phoenix. The `vt1` argument ensures the X server uses the
|
||||
# dedicated virtual terminal configured above.
|
||||
ExecStart=/bin/bash /home/analyzer/Phoenix/kiosk-start.sh
|
||||
|
||||
# Automatically restart the kiosk if it crashes or exits.
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=Analyzer Web Volumio (HTTP on :8088)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
User=volumio
|
||||
Group=volumio
|
||||
WorkingDirectory=/home/volumio/Phoenix/www
|
||||
NoNewPrivileges=true
|
||||
ExecStart=/usr/bin/python3 -m http.server 8088 --bind 0.0.0.0
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=Analyzer Web (HTTP on :80)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
User=analyzer
|
||||
Group=analyzer
|
||||
WorkingDirectory=/home/analyzer/Phoenix/www
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
NoNewPrivileges=true
|
||||
ExecStart=/usr/bin/python3 -m http.server 80 --bind 0.0.0.0
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
|
||||
KIOSK_USER="${KIOSK_USER:-analyzer}"
|
||||
PHOENIX_DIR="${PHOENIX_DIR:-$SCRIPT_DIR}"
|
||||
PHOENIX_BIN="${PHOENIX_BIN:-$PHOENIX_DIR/target/release/phoenix}"
|
||||
SESSION_SCRIPT="${SESSION_SCRIPT:-$PHOENIX_DIR/session.sh}"
|
||||
KIOSK_HOME="$(getent passwd "$KIOSK_USER" | cut -d: -f6)"
|
||||
if [ -z "${KIOSK_HOME:-}" ]; then
|
||||
KIOSK_HOME="/home/$KIOSK_USER"
|
||||
fi
|
||||
PHOENIX_BUILD_LOG="${PHOENIX_BUILD_LOG:-$KIOSK_HOME/phoenix-build.log}"
|
||||
PHOENIX_MIN_RUSTC_VERSION="${PHOENIX_MIN_RUSTC_VERSION:-1.71.0}"
|
||||
|
||||
export PATH="/root/.cargo/bin:/home/${KIOSK_USER}/.cargo/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin:${PATH:-}"
|
||||
export CARGO_NET_GIT_FETCH_WITH_CLI="${CARGO_NET_GIT_FETCH_WITH_CLI:-true}"
|
||||
|
||||
mkdir -p "$(dirname "$PHOENIX_BUILD_LOG")"
|
||||
exec 3>&1 4>&2
|
||||
BUILD_LOG_PIPE="$(mktemp -u /tmp/phoenix-build-log.XXXXXX)"
|
||||
mkfifo "$BUILD_LOG_PIPE"
|
||||
sed -u -E $'s/\x1B\\[[0-9;?]*[ -/]*[@-~]//g' < "$BUILD_LOG_PIPE" >> "$PHOENIX_BUILD_LOG" &
|
||||
BUILD_LOG_FILTER_PID=$!
|
||||
cleanup_logging() {
|
||||
if [ -n "${BUILD_LOG_FILTER_PID:-}" ]; then
|
||||
kill "$BUILD_LOG_FILTER_PID" 2>/dev/null || true
|
||||
wait "$BUILD_LOG_FILTER_PID" 2>/dev/null || true
|
||||
fi
|
||||
if [ -n "${BUILD_LOG_PIPE:-}" ] && [ -p "${BUILD_LOG_PIPE:-}" ]; then
|
||||
rm -f "$BUILD_LOG_PIPE"
|
||||
fi
|
||||
}
|
||||
stop_build_logging() {
|
||||
exec 1>&3 2>&4
|
||||
cleanup_logging
|
||||
exec 3>&- 4>&-
|
||||
trap - EXIT
|
||||
}
|
||||
trap cleanup_logging EXIT
|
||||
exec > >(tee "$BUILD_LOG_PIPE") 2>&1
|
||||
echo "=== $(date): kiosk-start.sh START ==="
|
||||
|
||||
print_banner() {
|
||||
local title="${1:-PHOENIX STARTET}"
|
||||
local detail="${2:-Bitte warten...}"
|
||||
if command -v clear >/dev/null 2>&1; then
|
||||
clear || true
|
||||
fi
|
||||
cat <<EOF
|
||||
--------------------------
|
||||
$title
|
||||
--------------------------
|
||||
$detail
|
||||
EOF
|
||||
}
|
||||
|
||||
needs_phoenix_build() {
|
||||
if [ ! -x "$PHOENIX_BIN" ] || [ ! -s "$PHOENIX_BIN" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ -f "$PHOENIX_DIR/Cargo.toml" ] && [ "$PHOENIX_DIR/Cargo.toml" -nt "$PHOENIX_BIN" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ -f "$PHOENIX_DIR/Cargo.lock" ] && [ "$PHOENIX_DIR/Cargo.lock" -nt "$PHOENIX_BIN" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ -d "$PHOENIX_DIR/src" ] && find "$PHOENIX_DIR/src" -type f -name '*.rs' -newer "$PHOENIX_BIN" -print -quit | grep -q .; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
build_phoenix_if_needed() {
|
||||
if ! needs_phoenix_build; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
print_banner \
|
||||
"PHOENIX WIRD GEBAUT" \
|
||||
"Der erste Start oder ein Update kann einige Minuten dauern."
|
||||
|
||||
ensure_build_prerequisites
|
||||
|
||||
wait_for_build_network
|
||||
build_phoenix_with_retries
|
||||
}
|
||||
|
||||
ensure_build_prerequisites() {
|
||||
if [ "$(id -u)" -eq 0 ] && command -v apt-get >/dev/null 2>&1; then
|
||||
print_banner \
|
||||
"BUILD-ABHAENGIGKEITEN WERDEN GEPRUEFT" \
|
||||
"Rust-Toolchain und Systempakete werden bei Bedarf vorbereitet."
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
apt-get install -y pkg-config libasound2-dev curl ca-certificates git build-essential
|
||||
fi
|
||||
|
||||
if ! has_supported_rust_toolchain; then
|
||||
install_or_update_rust_toolchain
|
||||
fi
|
||||
|
||||
if ! has_supported_rust_toolchain; then
|
||||
echo "No supported Rust toolchain found. Need rustc >= ${PHOENIX_MIN_RUSTC_VERSION}."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
has_supported_rust_toolchain() {
|
||||
if ! command -v cargo >/dev/null 2>&1 || ! command -v rustc >/dev/null 2>&1; then
|
||||
return 1
|
||||
fi
|
||||
local current
|
||||
current="$(rustc_version)"
|
||||
if [ -z "$current" ]; then
|
||||
return 1
|
||||
fi
|
||||
version_ge "$current" "$PHOENIX_MIN_RUSTC_VERSION"
|
||||
}
|
||||
|
||||
rustc_version() {
|
||||
if ! command -v rustc >/dev/null 2>&1; then
|
||||
return 1
|
||||
fi
|
||||
rustc --version 2>/dev/null | awk '{print $2}'
|
||||
}
|
||||
|
||||
version_ge() {
|
||||
local current="${1:-0}"
|
||||
local minimum="${2:-0}"
|
||||
[ "$(printf '%s\n%s\n' "$minimum" "$current" | sort -V | head -n1)" = "$minimum" ]
|
||||
}
|
||||
|
||||
install_or_update_rust_toolchain() {
|
||||
local current
|
||||
current="$(rustc_version || true)"
|
||||
if [ -n "$current" ]; then
|
||||
echo "Current rustc ${current} is too old; need >= ${PHOENIX_MIN_RUSTC_VERSION}."
|
||||
else
|
||||
echo "Rust toolchain not found; installing via rustup."
|
||||
fi
|
||||
|
||||
print_banner \
|
||||
"AKTUELLE RUST-TOOLCHAIN WIRD INSTALLIERT" \
|
||||
"Das System-Rust ist zu alt fuer Phoenix. Es wird rustup/stable verwendet."
|
||||
|
||||
if command -v rustup >/dev/null 2>&1; then
|
||||
rustup toolchain install stable --profile minimal
|
||||
rustup default stable
|
||||
else
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "curl not found; cannot install rustup."
|
||||
return 1
|
||||
fi
|
||||
curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
fi
|
||||
|
||||
export PATH="/root/.cargo/bin:/home/${KIOSK_USER}/.cargo/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin"
|
||||
hash -r
|
||||
echo "Using rustc $(rustc_version || echo unknown)"
|
||||
}
|
||||
|
||||
wait_for_build_network() {
|
||||
if ! command -v getent >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local host="${PHOENIX_BUILD_TEST_HOST:-github.com}"
|
||||
local tries="${PHOENIX_BUILD_NET_WAIT_TRIES:-20}"
|
||||
local sleep_s="${PHOENIX_BUILD_NET_WAIT_SLEEP_S:-2}"
|
||||
local i
|
||||
|
||||
for ((i = 1; i <= tries; i++)); do
|
||||
if getent hosts "$host" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
echo "Waiting for DNS/network before build ($i/$tries): $host"
|
||||
sleep "$sleep_s"
|
||||
done
|
||||
|
||||
echo "DNS/network check did not succeed for $host; continuing with build retries."
|
||||
}
|
||||
|
||||
build_phoenix_with_retries() {
|
||||
local attempts="${PHOENIX_BUILD_RETRIES:-3}"
|
||||
local sleep_s="${PHOENIX_BUILD_RETRY_SLEEP_S:-5}"
|
||||
local attempt
|
||||
|
||||
for ((attempt = 1; attempt <= attempts; attempt++)); do
|
||||
echo "Starting Phoenix build (attempt $attempt/$attempts)"
|
||||
if (
|
||||
cd "$PHOENIX_DIR"
|
||||
CARGO_TERM_COLOR=always cargo build --release
|
||||
); then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$attempt" -lt "$attempts" ]; then
|
||||
echo "Phoenix build failed; retrying in ${sleep_s}s..."
|
||||
sleep "$sleep_s"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Phoenix build failed after $attempts attempts."
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [ ! -f "$SESSION_SCRIPT" ]; then
|
||||
echo "Session script missing: $SESSION_SCRIPT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build_phoenix_if_needed
|
||||
|
||||
if [ ! -x "$PHOENIX_BIN" ] || [ ! -s "$PHOENIX_BIN" ]; then
|
||||
echo "Phoenix binary missing, empty or not executable: $PHOENIX_BIN"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
stop_build_logging
|
||||
exec /usr/bin/xinit /bin/bash "$SESSION_SCRIPT" -- :0 -nolisten tcp -nocursor vt1
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"fftSize": 8192,
|
||||
"inputSource": "line",
|
||||
"inputOffsetDbL": -5.0,
|
||||
"inputOffsetDbR": -5.0,
|
||||
"monoInput": false,
|
||||
"lrFractionalDelayEnabled": true,
|
||||
"lrFractionalDelaySamples": 0.996,
|
||||
"alMarkersEnabled": false,
|
||||
"meterBarThin": 0.55,
|
||||
"panelDividersEnabled": true,
|
||||
"ppmDinAttackMs": 5.0,
|
||||
"ppmDinDecayDbPerS": 11.8,
|
||||
"ppmDinFastAttack": false,
|
||||
"ppmDinLoudnessBoxes": true,
|
||||
"ppmEbuAttackMs": 10.0,
|
||||
"ppmEbuDecayDbPerS": 8.6,
|
||||
"lufsIWindowMin": 4,
|
||||
"lufsINormEnabled": false,
|
||||
"ppmDinLoudnessOffsetDb": 0.0,
|
||||
"xyPoints": 1024,
|
||||
"gonioDisplayGainDb": -5.0,
|
||||
"phaseAmplitudeMode": "ppm-din",
|
||||
"recordOutputFormat": "wav",
|
||||
"recordMp3BitrateKbps": 192,
|
||||
"recordTarget": "analyzer",
|
||||
"recordAutoSplitGapSec": 1.5,
|
||||
"recordAutoThresholdDbfs": -50.0,
|
||||
"screensaverEnabled": true,
|
||||
"screensaverIdleMin": 5.0,
|
||||
"screensaverActivityDb": -50.0,
|
||||
"screensaverMode": "clock",
|
||||
"screensaverLedGlow": true,
|
||||
"screensaverLedColor": "#ff0000"
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"fftSize": 8192,
|
||||
"inputSource": "line",
|
||||
"inputOffsetDbL": -5.0,
|
||||
"inputOffsetDbR": -5.0,
|
||||
"monoInput": false,
|
||||
"lrFractionalDelayEnabled": true,
|
||||
"lrFractionalDelaySamples": 0.996,
|
||||
"alMarkersEnabled": false,
|
||||
"meterBarThin": 0.55,
|
||||
"panelDividersEnabled": true,
|
||||
"ppmDinAttackMs": 5.0,
|
||||
"ppmDinDecayDbPerS": 11.8,
|
||||
"ppmDinFastAttack": false,
|
||||
"ppmDinLoudnessBoxes": true,
|
||||
"ppmEbuAttackMs": 10.0,
|
||||
"ppmEbuDecayDbPerS": 8.6,
|
||||
"lufsIWindowMin": 4,
|
||||
"lufsINormEnabled": false,
|
||||
"ppmDinLoudnessOffsetDb": 0.0,
|
||||
"xyPoints": 1024,
|
||||
"gonioDisplayGainDb": -5.0,
|
||||
"phaseAmplitudeMode": "ppm-din",
|
||||
"recordOutputFormat": "wav",
|
||||
"recordMp3BitrateKbps": 192,
|
||||
"recordTarget": "volumio",
|
||||
"recordAutoSplitGapSec": 1.5,
|
||||
"recordAutoThresholdDbfs": -50.0,
|
||||
"screensaverEnabled": true,
|
||||
"screensaverIdleMin": 5.0,
|
||||
"screensaverActivityDb": -50.0,
|
||||
"screensaverMode": "clock",
|
||||
"screensaverLedGlow": true,
|
||||
"screensaverLedColor": "#ff0000"
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
PHOENIX_ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||
PHOENIX_PARENT="$(cd "$PHOENIX_ROOT/.." && pwd -P)"
|
||||
PHOENIX_NAME="$(basename "$PHOENIX_ROOT")"
|
||||
STAGE_DIR="$PHOENIX_PARENT/${PHOENIX_NAME}.update"
|
||||
BACKUP_DIR="$PHOENIX_PARENT/${PHOENIX_NAME}.backup"
|
||||
FAILED_DIR="$PHOENIX_PARENT/${PHOENIX_NAME}.failed"
|
||||
LOG_FILE="$PHOENIX_PARENT/${PHOENIX_NAME}.update.log"
|
||||
|
||||
exec >>"$LOG_FILE" 2>&1
|
||||
echo "=== $(date): restart_phoenix_services.sh START ==="
|
||||
|
||||
restart_if_present() {
|
||||
local unit="$1"
|
||||
if ! command -v systemctl >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
if run_systemctl list-unit-files "$unit" >/dev/null 2>&1 || run_systemctl status "$unit" >/dev/null 2>&1; then
|
||||
run_systemctl restart "$unit" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
stop_if_present() {
|
||||
local unit="$1"
|
||||
if ! command -v systemctl >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
if run_systemctl list-unit-files "$unit" >/dev/null 2>&1 || run_systemctl status "$unit" >/dev/null 2>&1; then
|
||||
run_systemctl stop "$unit" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
run_systemctl() {
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
systemctl "$@"
|
||||
return $?
|
||||
fi
|
||||
if ! command -v sudo >/dev/null 2>&1; then
|
||||
echo "sudo not found; cannot control system services as non-root" >&2
|
||||
return 1
|
||||
fi
|
||||
sudo -n systemctl "$@"
|
||||
}
|
||||
|
||||
run_systemd_run() {
|
||||
local unit_name="phoenix-online-update-$(date +%s)"
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
systemd-run --unit="$unit_name" --collect --property=Type=oneshot \
|
||||
/usr/bin/env PHOENIX_UPDATE_DETACHED=1 /bin/bash "$0"
|
||||
return $?
|
||||
fi
|
||||
if ! command -v sudo >/dev/null 2>&1; then
|
||||
echo "sudo not found; cannot detach update restart as non-root" >&2
|
||||
return 1
|
||||
fi
|
||||
sudo -n systemd-run --unit="$unit_name" --collect --property=Type=oneshot \
|
||||
/usr/bin/env PHOENIX_UPDATE_DETACHED=1 /bin/bash "$0"
|
||||
}
|
||||
|
||||
health_ok() {
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
curl -fsS http://127.0.0.1:8789/health >/dev/null 2>&1
|
||||
}
|
||||
|
||||
needs_phoenix_build() {
|
||||
local phoenix_dir="${1:-$PHOENIX_ROOT}"
|
||||
local phoenix_bin="$phoenix_dir/target/release/phoenix"
|
||||
if [ ! -x "$phoenix_bin" ] || [ ! -s "$phoenix_bin" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ -f "$phoenix_dir/Cargo.toml" ] && [ "$phoenix_dir/Cargo.toml" -nt "$phoenix_bin" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ -f "$phoenix_dir/Cargo.lock" ] && [ "$phoenix_dir/Cargo.lock" -nt "$phoenix_bin" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ -d "$phoenix_dir/src" ] && find "$phoenix_dir/src" -type f -name '*.rs' -newer "$phoenix_bin" -print -quit | grep -q .; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
clear_browser_asset_caches() {
|
||||
local cache_roots=(
|
||||
"/data/chrome-analyzer/Default/Cache"
|
||||
"/data/chrome-analyzer/Default/Code Cache"
|
||||
"/data/chrome-analyzer/Default/GPUCache"
|
||||
"/data/chrome-dualtabs/Default/Cache"
|
||||
"/data/chrome-dualtabs/Default/Code Cache"
|
||||
"/data/chrome-dualtabs/Default/GPUCache"
|
||||
)
|
||||
local path
|
||||
for path in "${cache_roots[@]}"; do
|
||||
if [ -e "$path" ]; then
|
||||
rm -rf "$path" || true
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
wait_for_health() {
|
||||
local attempts="${1:-30}"
|
||||
local delay="${2:-1}"
|
||||
local i
|
||||
for i in $(seq 1 "$attempts"); do
|
||||
if health_ok; then
|
||||
return 0
|
||||
fi
|
||||
sleep "$delay"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_candidate_health() {
|
||||
local phoenix_dir="${1:-$PHOENIX_ROOT}"
|
||||
local attempts="${2:-45}"
|
||||
local delay="${3:-1}"
|
||||
local start_try
|
||||
local max_start_tries="${PHOENIX_UPDATE_START_TRIES:-3}"
|
||||
|
||||
if needs_phoenix_build "$phoenix_dir"; then
|
||||
attempts="${PHOENIX_UPDATE_BUILD_HEALTH_ATTEMPTS:-420}"
|
||||
echo "Phoenix tree requires local rebuild; extending health wait to ${attempts}s"
|
||||
else
|
||||
echo "Phoenix tree already has runnable binary; health wait ${attempts}s"
|
||||
fi
|
||||
|
||||
for start_try in $(seq 1 "$max_start_tries"); do
|
||||
echo "starting Phoenix services health attempt ${start_try}/${max_start_tries}"
|
||||
start_all_services
|
||||
if wait_for_health "$attempts" "$delay"; then
|
||||
echo "Phoenix health OK on attempt ${start_try}/${max_start_tries}"
|
||||
return 0
|
||||
fi
|
||||
echo "Phoenix health failed on attempt ${start_try}/${max_start_tries}"
|
||||
stop_all_services
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
start_all_services() {
|
||||
restart_if_present phoenix.service
|
||||
restart_if_present analyzer-web.service
|
||||
restart_if_present analyzer-web-volumio.service
|
||||
clear_browser_asset_caches
|
||||
restart_if_present analyzer-kiosk.service
|
||||
restart_if_present analyzer-kiosk-volumio.service
|
||||
}
|
||||
|
||||
stop_all_services() {
|
||||
stop_if_present analyzer-kiosk.service
|
||||
stop_if_present analyzer-kiosk-volumio.service
|
||||
stop_if_present analyzer-web.service
|
||||
stop_if_present analyzer-web-volumio.service
|
||||
stop_if_present phoenix.service
|
||||
}
|
||||
|
||||
rollback_to_backup() {
|
||||
stop_all_services
|
||||
rm -rf "$FAILED_DIR"
|
||||
if [ -d "$PHOENIX_ROOT" ]; then
|
||||
mv "$PHOENIX_ROOT" "$FAILED_DIR"
|
||||
fi
|
||||
if [ ! -d "$BACKUP_DIR" ]; then
|
||||
echo "backup missing; cannot roll back to $BACKUP_DIR" >&2
|
||||
return 1
|
||||
fi
|
||||
mv "$BACKUP_DIR" "$PHOENIX_ROOT"
|
||||
if ! wait_for_candidate_health "$PHOENIX_ROOT" "${PHOENIX_ROLLBACK_HEALTH_ATTEMPTS:-120}" 1; then
|
||||
echo "backup rollback restored files but Phoenix health did not recover" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "rollback to backup succeeded"
|
||||
}
|
||||
|
||||
if [ "${PHOENIX_UPDATE_DETACHED:-0}" != "1" ]; then
|
||||
if ! command -v systemd-run >/dev/null 2>&1; then
|
||||
echo "systemd-run not found; cannot safely detach updater" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "detaching updater into transient systemd unit"
|
||||
if run_systemd_run; then
|
||||
echo "detached updater started"
|
||||
exit 0
|
||||
fi
|
||||
echo "failed to start detached updater" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -d "$STAGE_DIR" ]; then
|
||||
echo "staged update missing: $STAGE_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$STAGE_DIR/Cargo.toml" ] || [ ! -d "$STAGE_DIR/src" ] || [ ! -d "$STAGE_DIR/www" ] || [ ! -d "$STAGE_DIR/scripts" ]; then
|
||||
echo "staged update invalid: $STAGE_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
stop_all_services
|
||||
|
||||
rm -rf "$BACKUP_DIR"
|
||||
if [ -d "$PHOENIX_ROOT" ]; then
|
||||
mv "$PHOENIX_ROOT" "$BACKUP_DIR"
|
||||
fi
|
||||
mv "$STAGE_DIR" "$PHOENIX_ROOT"
|
||||
|
||||
health_attempts=45
|
||||
health_delay=1
|
||||
|
||||
if ! wait_for_candidate_health "$PHOENIX_ROOT" "$health_attempts" "$health_delay"; then
|
||||
if ! rollback_to_backup; then
|
||||
echo "new Phoenix version failed health check and rollback failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "new Phoenix version failed health check; rolled back to backup" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== $(date): restart_phoenix_services.sh OK ==="
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ZIP_URL="${1:-https://webshare.casaderoll.de/share/Phoenix.zip}"
|
||||
PHOENIX_ROOT="${2:-}"
|
||||
|
||||
if [ -z "$PHOENIX_ROOT" ] || [ ! -d "$PHOENIX_ROOT" ]; then
|
||||
echo "invalid Phoenix root: $PHOENIX_ROOT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TMP_BASE="${TMPDIR:-/tmp}"
|
||||
WORK_DIR="$(mktemp -d "$TMP_BASE/phoenix-update.XXXXXX")"
|
||||
ZIP_PATH="$WORK_DIR/phoenix.zip"
|
||||
EXTRACT_DIR="$WORK_DIR/extract"
|
||||
PHOENIX_PARENT="$(cd "$PHOENIX_ROOT/.." && pwd -P)"
|
||||
PHOENIX_NAME="$(basename "$PHOENIX_ROOT")"
|
||||
STAGE_DIR="$PHOENIX_PARENT/${PHOENIX_NAME}.update"
|
||||
LOG_FILE="$PHOENIX_PARENT/${PHOENIX_NAME}.update.log"
|
||||
|
||||
exec >>"$LOG_FILE" 2>&1
|
||||
echo "=== $(date): update_phoenix_from_zip.sh START ==="
|
||||
echo "ZIP_URL=$ZIP_URL"
|
||||
echo "PHOENIX_ROOT=$PHOENIX_ROOT"
|
||||
echo "STAGE_DIR=$STAGE_DIR"
|
||||
|
||||
cleanup() {
|
||||
echo "cleanup work dir: $WORK_DIR"
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$EXTRACT_DIR"
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "curl not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
extract_zip() {
|
||||
local zip_path="$1"
|
||||
local extract_dir="$2"
|
||||
|
||||
if command -v unzip >/dev/null 2>&1; then
|
||||
unzip -oq "$zip_path" -d "$extract_dir"
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 -m zipfile -e "$zip_path" "$extract_dir"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "no zip extractor found: install unzip or python3" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "downloading update archive..."
|
||||
curl -fL "$ZIP_URL" -o "$ZIP_PATH"
|
||||
echo "download complete: $ZIP_PATH"
|
||||
extract_zip "$ZIP_PATH" "$EXTRACT_DIR"
|
||||
echo "archive extracted to: $EXTRACT_DIR"
|
||||
|
||||
SOURCE_DIR=""
|
||||
while IFS= read -r candidate; do
|
||||
if [ -f "$candidate/Cargo.toml" ] && [ -d "$candidate/www" ] && [ -d "$candidate/src" ]; then
|
||||
SOURCE_DIR="$candidate"
|
||||
break
|
||||
fi
|
||||
done < <(find "$EXTRACT_DIR" -mindepth 0 -maxdepth 4 -type d | sort)
|
||||
|
||||
if [ -z "$SOURCE_DIR" ]; then
|
||||
echo "no Phoenix project found inside update archive" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "source project root: $SOURCE_DIR"
|
||||
|
||||
# Build-Artefakte und Mac-Reste nie mitdeployen.
|
||||
find "$SOURCE_DIR" -type d \( -name target -o -name .git \) -prune -exec rm -rf {} +
|
||||
find "$SOURCE_DIR" -type f \( -name '.DS_Store' -o -name 'Thumbs.db' \) -delete
|
||||
echo "source tree cleaned"
|
||||
|
||||
rm -rf "$STAGE_DIR"
|
||||
mkdir -p "$STAGE_DIR"
|
||||
echo "stage dir prepared"
|
||||
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
rsync -a --delete --omit-dir-times --no-perms --no-owner --no-group \
|
||||
"$SOURCE_DIR"/ "$STAGE_DIR"/
|
||||
else
|
||||
echo "rsync not found; using cp fallback"
|
||||
cp -a "$SOURCE_DIR"/. "$STAGE_DIR"/
|
||||
fi
|
||||
echo "stage sync complete"
|
||||
|
||||
if [ ! -f "$STAGE_DIR/Cargo.toml" ] || [ ! -d "$STAGE_DIR/src" ] || [ ! -d "$STAGE_DIR/www" ] || [ ! -d "$STAGE_DIR/scripts" ]; then
|
||||
echo "staged update is incomplete: $STAGE_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "staged update validated"
|
||||
echo "=== $(date): update_phoenix_from_zip.sh OK ==="
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================
|
||||
# Analyzer Kiosk Session for Raspberry Pi OS
|
||||
#
|
||||
# This script launches Phoenix and the audio analyser UI in kiosk mode
|
||||
# using Chromium. It assumes that a lightweight HTTP server is running
|
||||
# on http://localhost to serve the analyser files. The X session
|
||||
# is started by the accompanying systemd unit. Phoenix owns the audio
|
||||
# hardware; Chromium only renders the GUI.
|
||||
# ==========================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Resolve the installation root from the actual script location so the
|
||||
# setup does not depend on a hard-coded home directory.
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
|
||||
# ----- Logging -----
|
||||
# Write logs to the kiosk user's home directory. Use an
|
||||
# environment variable to allow overrides.
|
||||
LOG="${LOG:-/home/analyzer/analyzer-kiosk.log}"
|
||||
KIOSK_USER="${KIOSK_USER:-analyzer}"
|
||||
CHROME_USER_DATA_DIR="${CHROME_USER_DATA_DIR:-/data/chrome-analyzer}"
|
||||
PHOENIX_DIR="${PHOENIX_DIR:-$SCRIPT_DIR}"
|
||||
PHOENIX_BIN="${PHOENIX_BIN:-$PHOENIX_DIR/target/release/phoenix}"
|
||||
PHOENIX_LOG_FILE="${PHOENIX_LOG_FILE:-/home/analyzer/phoenix.log}"
|
||||
# Listen on all interfaces so Phoenix is reachable both locally on the Pi
|
||||
# and from external browsers in the LAN.
|
||||
PHOENIX_LISTEN="${PHOENIX_LISTEN:-0.0.0.0:8789}"
|
||||
PHOENIX_ALSA_DEVICE="${PHOENIX_ALSA_DEVICE:-hw:CARD=CODEC,DEV=0}"
|
||||
PHOENIX_SAMPLE_RATE="${PHOENIX_SAMPLE_RATE:-48000}"
|
||||
PHOENIX_PERIOD_SIZE="${PHOENIX_PERIOD_SIZE:-128}"
|
||||
PHOENIX_BUFFER_SIZE="${PHOENIX_BUFFER_SIZE:-512}"
|
||||
PHOENIX_GLOBAL_CONFIG_PATH="${PHOENIX_GLOBAL_CONFIG_PATH:-/home/analyzer/.config/phoenix/global-config.json}"
|
||||
PHOENIX_RECORDINGS_DIR_ANALYZER="${PHOENIX_RECORDINGS_DIR_ANALYZER:-/home/analyzer/Aufnahmen}"
|
||||
PHOENIX_RECORDINGS_DIR_VOLUMIO="${PHOENIX_RECORDINGS_DIR_VOLUMIO:-/home/volumio/Aufnahmen}"
|
||||
PHOENIX_LR_FRAC_DELAY_ENABLED="${PHOENIX_LR_FRAC_DELAY_ENABLED:-true}"
|
||||
PHOENIX_LR_FRAC_DELAY_SAMPLES="${PHOENIX_LR_FRAC_DELAY_SAMPLES:-0.996}"
|
||||
mkdir -p "$(dirname "$LOG")"
|
||||
exec >>"$LOG" 2>&1
|
||||
echo "=== $(date): session.sh START ==="
|
||||
|
||||
configure_locale() {
|
||||
if locale -a 2>/dev/null | grep -Eiq '^de_DE\.(utf8|UTF-8)$'; then
|
||||
export LANG=de_DE.UTF-8
|
||||
export LC_ALL=de_DE.UTF-8
|
||||
elif locale -a 2>/dev/null | grep -Eiq '^C\.UTF-?8$'; then
|
||||
export LANG=C.UTF-8
|
||||
export LC_ALL=C.UTF-8
|
||||
else
|
||||
export LANG=C
|
||||
export LC_ALL=C
|
||||
fi
|
||||
}
|
||||
|
||||
# ----- Environment -----
|
||||
export DISPLAY=:0
|
||||
configure_locale
|
||||
|
||||
if [ ! -x "$PHOENIX_BIN" ] || [ ! -s "$PHOENIX_BIN" ]; then
|
||||
echo "Phoenix binary missing, empty or not executable: $PHOENIX_BIN"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if pgrep -f "$PHOENIX_BIN" >/dev/null 2>&1; then
|
||||
echo "Phoenix already running; reusing existing process."
|
||||
else
|
||||
echo "Starting Phoenix: $PHOENIX_BIN"
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
install -d -o analyzer -g analyzer -m 0755 "$PHOENIX_RECORDINGS_DIR_ANALYZER" || true
|
||||
install -d -o volumio -g volumio -m 0755 "$PHOENIX_RECORDINGS_DIR_VOLUMIO" || true
|
||||
else
|
||||
mkdir -p "$PHOENIX_RECORDINGS_DIR_ANALYZER" || true
|
||||
mkdir -p "$PHOENIX_RECORDINGS_DIR_VOLUMIO" || true
|
||||
fi
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
install -o "$KIOSK_USER" -g "$KIOSK_USER" -m 0644 /dev/null "$PHOENIX_LOG_FILE" 2>/dev/null || true
|
||||
sudo -u "$KIOSK_USER" env \
|
||||
PHOENIX_LISTEN="$PHOENIX_LISTEN" \
|
||||
PHOENIX_ALSA_DEVICE="$PHOENIX_ALSA_DEVICE" \
|
||||
PHOENIX_SAMPLE_RATE="$PHOENIX_SAMPLE_RATE" \
|
||||
PHOENIX_PERIOD_SIZE="$PHOENIX_PERIOD_SIZE" \
|
||||
PHOENIX_BUFFER_SIZE="$PHOENIX_BUFFER_SIZE" \
|
||||
PHOENIX_GLOBAL_CONFIG_PATH="$PHOENIX_GLOBAL_CONFIG_PATH" \
|
||||
PHOENIX_RECORDINGS_DIR_ANALYZER="$PHOENIX_RECORDINGS_DIR_ANALYZER" \
|
||||
PHOENIX_RECORDINGS_DIR_VOLUMIO="$PHOENIX_RECORDINGS_DIR_VOLUMIO" \
|
||||
PHOENIX_LR_FRAC_DELAY_ENABLED="$PHOENIX_LR_FRAC_DELAY_ENABLED" \
|
||||
PHOENIX_LR_FRAC_DELAY_SAMPLES="$PHOENIX_LR_FRAC_DELAY_SAMPLES" \
|
||||
"$PHOENIX_BIN" >>"$PHOENIX_LOG_FILE" 2>&1 &
|
||||
else
|
||||
touch "$PHOENIX_LOG_FILE"
|
||||
env \
|
||||
PHOENIX_LISTEN="$PHOENIX_LISTEN" \
|
||||
PHOENIX_ALSA_DEVICE="$PHOENIX_ALSA_DEVICE" \
|
||||
PHOENIX_SAMPLE_RATE="$PHOENIX_SAMPLE_RATE" \
|
||||
PHOENIX_PERIOD_SIZE="$PHOENIX_PERIOD_SIZE" \
|
||||
PHOENIX_BUFFER_SIZE="$PHOENIX_BUFFER_SIZE" \
|
||||
PHOENIX_GLOBAL_CONFIG_PATH="$PHOENIX_GLOBAL_CONFIG_PATH" \
|
||||
PHOENIX_RECORDINGS_DIR_ANALYZER="$PHOENIX_RECORDINGS_DIR_ANALYZER" \
|
||||
PHOENIX_RECORDINGS_DIR_VOLUMIO="$PHOENIX_RECORDINGS_DIR_VOLUMIO" \
|
||||
PHOENIX_LR_FRAC_DELAY_ENABLED="$PHOENIX_LR_FRAC_DELAY_ENABLED" \
|
||||
PHOENIX_LR_FRAC_DELAY_SAMPLES="$PHOENIX_LR_FRAC_DELAY_SAMPLES" \
|
||||
"$PHOENIX_BIN" >>"$PHOENIX_LOG_FILE" 2>&1 &
|
||||
fi
|
||||
fi
|
||||
|
||||
PHOENIX_HEALTH_TARGET="$PHOENIX_LISTEN"
|
||||
case "$PHOENIX_HEALTH_TARGET" in
|
||||
0.0.0.0:*) PHOENIX_HEALTH_TARGET="127.0.0.1:${PHOENIX_HEALTH_TARGET#*:}" ;;
|
||||
:::*) PHOENIX_HEALTH_TARGET="[::1]:${PHOENIX_HEALTH_TARGET#*:}" ;;
|
||||
esac
|
||||
PHOENIX_HEALTH_URL="http://${PHOENIX_HEALTH_TARGET}/health"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
echo "Waiting for Phoenix health: $PHOENIX_HEALTH_URL"
|
||||
for _ in $(seq 1 40); do
|
||||
if curl -fsS "$PHOENIX_HEALTH_URL" >/dev/null 2>&1; then
|
||||
echo "Phoenix is ready."
|
||||
break
|
||||
fi
|
||||
sleep 0.25
|
||||
done
|
||||
else
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
# ----- Start the kiosk browser -----
|
||||
# Disable screen blanking and power management if xset is available.
|
||||
if command -v xset >/dev/null 2>&1; then
|
||||
xset -dpms || true
|
||||
xset s off || true
|
||||
xset s noblank || true
|
||||
fi
|
||||
|
||||
# Ensure the Chromium profile directory exists with stable ownership,
|
||||
# otherwise Chromium may fall back to a profile under the user's home.
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
install -d -m 0755 /data
|
||||
install -d -o "$KIOSK_USER" -g "$KIOSK_USER" -m 0700 "$CHROME_USER_DATA_DIR"
|
||||
else
|
||||
mkdir -p "$CHROME_USER_DATA_DIR"
|
||||
chmod 700 "$CHROME_USER_DATA_DIR"
|
||||
fi
|
||||
|
||||
# Launch Chromium as the non-root kiosk user. The analyser UI is served
|
||||
# over HTTP on localhost. Audio capture is handled exclusively by
|
||||
# Phoenix, so Chromium only renders the GUI.
|
||||
sudo -u "$KIOSK_USER" /usr/bin/chromium \
|
||||
--kiosk \
|
||||
--touch-events \
|
||||
--user-data-dir="$CHROME_USER_DATA_DIR" \
|
||||
--load-extension='/data/VirtualKeyboard' \
|
||||
--no-first-run --no-default-browser-check \
|
||||
--disable-session-crashed-bubble --disable-restore-session-state --restore-last-session=false \
|
||||
--disable-background-networking --disable-remote-extensions \
|
||||
--disable-pinch \
|
||||
--force-device-scale-factor=1 \
|
||||
--lang=de-DE --disable-translate --disable-features=Translate,TranslateUI \
|
||||
--memory-pressure-off \
|
||||
--max-active-webgl-contexts=1 \
|
||||
http://localhost/index.html \
|
||||
>>"$LOG" 2>&1
|
||||
|
||||
echo "=== $(date): session.sh END ==="
|
||||
@@ -0,0 +1,230 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================
|
||||
# Analyzer + Volumio DualTab-Kiosk Session
|
||||
#
|
||||
# Phoenix owns the audio hardware and analysis.
|
||||
# Chromium only renders the Analyzer GUI and Volumio UI.
|
||||
# ==========================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
|
||||
# ----- Logging / Runtime -----
|
||||
KIOSK_USER="${KIOSK_USER:-volumio}"
|
||||
LOG="${LOG:-/home/volumio/dual-kiosk.log}"
|
||||
CHROME_USER_DATA_DIR="${CHROME_USER_DATA_DIR:-/data/chrome-dualtabs}"
|
||||
PHOENIX_DIR="${PHOENIX_DIR:-$SCRIPT_DIR}"
|
||||
PHOENIX_BIN="${PHOENIX_BIN:-$PHOENIX_DIR/target/release/phoenix}"
|
||||
PHOENIX_LOG_FILE="${PHOENIX_LOG_FILE:-/home/volumio/phoenix.log}"
|
||||
PHOENIX_LISTEN="${PHOENIX_LISTEN:-0.0.0.0:8789}"
|
||||
PHOENIX_ALSA_DEVICE="${PHOENIX_ALSA_DEVICE:-hw:CARD=CODEC,DEV=0}"
|
||||
PHOENIX_SAMPLE_RATE="${PHOENIX_SAMPLE_RATE:-48000}"
|
||||
PHOENIX_PERIOD_SIZE="${PHOENIX_PERIOD_SIZE:-128}"
|
||||
PHOENIX_BUFFER_SIZE="${PHOENIX_BUFFER_SIZE:-512}"
|
||||
PHOENIX_GLOBAL_CONFIG_PATH="${PHOENIX_GLOBAL_CONFIG_PATH:-/home/volumio/.config/phoenix/global-config.json}"
|
||||
PHOENIX_RECORDINGS_DIR_ANALYZER="${PHOENIX_RECORDINGS_DIR_ANALYZER:-/home/analyzer/Aufnahmen}"
|
||||
PHOENIX_RECORDINGS_DIR_VOLUMIO="${PHOENIX_RECORDINGS_DIR_VOLUMIO:-/home/volumio/Aufnahmen}"
|
||||
PHOENIX_LR_FRAC_DELAY_ENABLED="${PHOENIX_LR_FRAC_DELAY_ENABLED:-true}"
|
||||
PHOENIX_LR_FRAC_DELAY_SAMPLES="${PHOENIX_LR_FRAC_DELAY_SAMPLES:-0.996}"
|
||||
|
||||
CHROME_BIN="${CHROME_BIN:-/usr/bin/chromium-browser}"
|
||||
EXT_VK="${EXT_VK:-/data/volumiokioskextensions/VirtualKeyboard}"
|
||||
EXT_TOGGLE="${EXT_TOGGLE:-/opt/analyzer/toggle-ext}"
|
||||
ANALYZER_URL="${ANALYZER_URL:-http://localhost:8088/index.html}"
|
||||
VOLUMIO_URL="${VOLUMIO_URL:-http://localhost:3000}"
|
||||
|
||||
mkdir -p "$(dirname "$LOG")"
|
||||
exec >>"$LOG" 2>&1
|
||||
echo "=== $(date): session_volumio.sh START ==="
|
||||
|
||||
resolve_extension_dir() {
|
||||
local current="${1:-}"
|
||||
shift || true
|
||||
if [ -n "$current" ] && [ -d "$current" ]; then
|
||||
printf '%s\n' "$current"
|
||||
return 0
|
||||
fi
|
||||
local candidate
|
||||
for candidate in "$@"; do
|
||||
if [ -n "$candidate" ] && [ -d "$candidate" ]; then
|
||||
printf '%s\n' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
configure_locale() {
|
||||
if locale -a 2>/dev/null | grep -Eiq '^de_DE\.(utf8|UTF-8)$'; then
|
||||
export LANG=de_DE.UTF-8
|
||||
export LC_ALL=de_DE.UTF-8
|
||||
elif locale -a 2>/dev/null | grep -Eiq '^C\.UTF-?8$'; then
|
||||
export LANG=C.UTF-8
|
||||
export LC_ALL=C.UTF-8
|
||||
else
|
||||
export LANG=C
|
||||
export LC_ALL=C
|
||||
fi
|
||||
}
|
||||
|
||||
# ----- Environment -----
|
||||
export DISPLAY=:0
|
||||
configure_locale
|
||||
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u "$KIOSK_USER")}"
|
||||
mkdir -p "$XDG_RUNTIME_DIR" || true
|
||||
export DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS:-unix:path=${XDG_RUNTIME_DIR}/bus}"
|
||||
|
||||
if [ ! -x "$PHOENIX_BIN" ] || [ ! -s "$PHOENIX_BIN" ]; then
|
||||
echo "Phoenix binary missing, empty or not executable: $PHOENIX_BIN"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if pgrep -f "$PHOENIX_BIN" >/dev/null 2>&1; then
|
||||
echo "Phoenix already running; reusing existing process."
|
||||
else
|
||||
echo "Starting Phoenix: $PHOENIX_BIN"
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
install -d -o analyzer -g analyzer -m 0755 "$PHOENIX_RECORDINGS_DIR_ANALYZER" || true
|
||||
install -d -o volumio -g volumio -m 0755 "$PHOENIX_RECORDINGS_DIR_VOLUMIO" || true
|
||||
install -o "$KIOSK_USER" -g "$KIOSK_USER" -m 0644 /dev/null "$PHOENIX_LOG_FILE" 2>/dev/null || true
|
||||
sudo -u "$KIOSK_USER" env \
|
||||
PHOENIX_LISTEN="$PHOENIX_LISTEN" \
|
||||
PHOENIX_ALSA_DEVICE="$PHOENIX_ALSA_DEVICE" \
|
||||
PHOENIX_SAMPLE_RATE="$PHOENIX_SAMPLE_RATE" \
|
||||
PHOENIX_PERIOD_SIZE="$PHOENIX_PERIOD_SIZE" \
|
||||
PHOENIX_BUFFER_SIZE="$PHOENIX_BUFFER_SIZE" \
|
||||
PHOENIX_GLOBAL_CONFIG_PATH="$PHOENIX_GLOBAL_CONFIG_PATH" \
|
||||
PHOENIX_RECORDINGS_DIR_ANALYZER="$PHOENIX_RECORDINGS_DIR_ANALYZER" \
|
||||
PHOENIX_RECORDINGS_DIR_VOLUMIO="$PHOENIX_RECORDINGS_DIR_VOLUMIO" \
|
||||
PHOENIX_LR_FRAC_DELAY_ENABLED="$PHOENIX_LR_FRAC_DELAY_ENABLED" \
|
||||
PHOENIX_LR_FRAC_DELAY_SAMPLES="$PHOENIX_LR_FRAC_DELAY_SAMPLES" \
|
||||
"$PHOENIX_BIN" >>"$PHOENIX_LOG_FILE" 2>&1 &
|
||||
else
|
||||
mkdir -p "$PHOENIX_RECORDINGS_DIR_ANALYZER" || true
|
||||
mkdir -p "$PHOENIX_RECORDINGS_DIR_VOLUMIO" || true
|
||||
touch "$PHOENIX_LOG_FILE"
|
||||
env \
|
||||
PHOENIX_LISTEN="$PHOENIX_LISTEN" \
|
||||
PHOENIX_ALSA_DEVICE="$PHOENIX_ALSA_DEVICE" \
|
||||
PHOENIX_SAMPLE_RATE="$PHOENIX_SAMPLE_RATE" \
|
||||
PHOENIX_PERIOD_SIZE="$PHOENIX_PERIOD_SIZE" \
|
||||
PHOENIX_BUFFER_SIZE="$PHOENIX_BUFFER_SIZE" \
|
||||
PHOENIX_GLOBAL_CONFIG_PATH="$PHOENIX_GLOBAL_CONFIG_PATH" \
|
||||
PHOENIX_RECORDINGS_DIR_ANALYZER="$PHOENIX_RECORDINGS_DIR_ANALYZER" \
|
||||
PHOENIX_RECORDINGS_DIR_VOLUMIO="$PHOENIX_RECORDINGS_DIR_VOLUMIO" \
|
||||
PHOENIX_LR_FRAC_DELAY_ENABLED="$PHOENIX_LR_FRAC_DELAY_ENABLED" \
|
||||
PHOENIX_LR_FRAC_DELAY_SAMPLES="$PHOENIX_LR_FRAC_DELAY_SAMPLES" \
|
||||
"$PHOENIX_BIN" >>"$PHOENIX_LOG_FILE" 2>&1 &
|
||||
fi
|
||||
fi
|
||||
|
||||
PHOENIX_HEALTH_TARGET="$PHOENIX_LISTEN"
|
||||
case "$PHOENIX_HEALTH_TARGET" in
|
||||
0.0.0.0:*) PHOENIX_HEALTH_TARGET="127.0.0.1:${PHOENIX_HEALTH_TARGET#*:}" ;;
|
||||
:::*) PHOENIX_HEALTH_TARGET="[::1]:${PHOENIX_HEALTH_TARGET#*:}" ;;
|
||||
esac
|
||||
PHOENIX_HEALTH_URL="http://${PHOENIX_HEALTH_TARGET}/health"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
echo "Waiting for Phoenix health: $PHOENIX_HEALTH_URL"
|
||||
for _ in $(seq 1 40); do
|
||||
if curl -fsS "$PHOENIX_HEALTH_URL" >/dev/null 2>&1; then
|
||||
echo "Phoenix is ready."
|
||||
break
|
||||
fi
|
||||
sleep 0.25
|
||||
done
|
||||
else
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
# ----- X / Browser -----
|
||||
for _ in $(seq 1 30); do
|
||||
if xset q >/dev/null 2>&1; then break; fi
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
if command -v xset >/dev/null 2>&1; then
|
||||
xset -dpms || true
|
||||
xset s off || true
|
||||
xset s noblank || true
|
||||
fi
|
||||
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
install -d -m 0755 /data
|
||||
install -d -o "$KIOSK_USER" -g "$KIOSK_USER" -m 0700 "$CHROME_USER_DATA_DIR"
|
||||
else
|
||||
mkdir -p "$CHROME_USER_DATA_DIR"
|
||||
chmod 700 "$CHROME_USER_DATA_DIR"
|
||||
fi
|
||||
|
||||
pkill -9 -x chromium || true
|
||||
pkill -9 -f "chromium.*--kiosk" || true
|
||||
sleep 0.3
|
||||
|
||||
EXTENSIONS=()
|
||||
EXT_VK_RESOLVED="$(resolve_extension_dir "$EXT_VK" \
|
||||
"$PHOENIX_DIR/VirtualKeyboard" \
|
||||
"/data/volumiokioskextensions/VirtualKeyboard" \
|
||||
"/data/VirtualKeyboard" \
|
||||
"" || true)"
|
||||
EXT_TOGGLE_RESOLVED="$(resolve_extension_dir "$EXT_TOGGLE" \
|
||||
"$PHOENIX_DIR/toggle-ext" \
|
||||
"/opt/analyzer/toggle-ext" \
|
||||
"" || true)"
|
||||
|
||||
if [ -n "$EXT_VK_RESOLVED" ]; then
|
||||
echo "Loading VirtualKeyboard extension: $EXT_VK_RESOLVED"
|
||||
EXTENSIONS+=("$EXT_VK_RESOLVED")
|
||||
else
|
||||
echo "VirtualKeyboard extension not found."
|
||||
fi
|
||||
if [ -n "$EXT_TOGGLE_RESOLVED" ]; then
|
||||
echo "Loading tab-toggle extension: $EXT_TOGGLE_RESOLVED"
|
||||
EXTENSIONS+=("$EXT_TOGGLE_RESOLVED")
|
||||
else
|
||||
echo "Tab-toggle extension not found."
|
||||
fi
|
||||
EXTENSION_ARG=""
|
||||
if [ "${#EXTENSIONS[@]}" -gt 0 ]; then
|
||||
EXTENSION_ARG="$(IFS=,; echo "${EXTENSIONS[*]}")"
|
||||
fi
|
||||
|
||||
CHROME_CMD=(
|
||||
"$CHROME_BIN"
|
||||
--kiosk
|
||||
--touch-events
|
||||
--user-data-dir="$CHROME_USER_DATA_DIR"
|
||||
--no-first-run
|
||||
--no-default-browser-check
|
||||
--disable-session-crashed-bubble
|
||||
--disable-restore-session-state
|
||||
--restore-last-session=false
|
||||
--disable-background-networking
|
||||
--disable-remote-extensions
|
||||
--disable-pinch
|
||||
--force-device-scale-factor=1
|
||||
--lang=de-DE
|
||||
--disable-translate
|
||||
--disable-features=Translate,TranslateUI
|
||||
--memory-pressure-off
|
||||
--max-active-webgl-contexts=1
|
||||
"$ANALYZER_URL"
|
||||
"$VOLUMIO_URL"
|
||||
)
|
||||
|
||||
if [ -n "$EXTENSION_ARG" ]; then
|
||||
CHROME_CMD+=(--load-extension="$EXTENSION_ARG")
|
||||
fi
|
||||
|
||||
sudo -u "$KIOSK_USER" "${CHROME_CMD[@]}" >>"$LOG" 2>&1 &
|
||||
CHROME_PID=$!
|
||||
|
||||
sleep 2
|
||||
if command -v xdotool >/dev/null 2>&1; then
|
||||
xdotool key Ctrl+t type "$VOLUMIO_URL" key Return || true
|
||||
fi
|
||||
|
||||
wait "$CHROME_PID"
|
||||
echo "=== $(date): session_volumio.sh END ==="
|
||||
+2104
File diff suppressed because it is too large
Load Diff
+154
@@ -0,0 +1,154 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::model::{InputSource, PhoenixGlobalConfig, PhoenixRtaConfig};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PhoenixConfig {
|
||||
pub listen_addr: String,
|
||||
pub alsa_card_index: u8,
|
||||
pub alsa_device_name: Option<String>,
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
pub sample_rate: u32,
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
pub period_size: u32,
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
pub buffer_size: u32,
|
||||
pub global_config_path: PathBuf,
|
||||
pub frontend_presets_path: PathBuf,
|
||||
pub frontend_layouts_path: PathBuf,
|
||||
pub recordings_dir_analyzer: PathBuf,
|
||||
pub recordings_dir_volumio: PathBuf,
|
||||
pub max_recording_upload_bytes: u64,
|
||||
pub lr_fractional_delay_enabled: bool,
|
||||
pub lr_fractional_delay_samples: f32,
|
||||
}
|
||||
|
||||
impl PhoenixConfig {
|
||||
pub fn from_env() -> Self {
|
||||
Self {
|
||||
listen_addr: std::env::var("PHOENIX_LISTEN")
|
||||
.unwrap_or_else(|_| "127.0.0.1:8789".to_string()),
|
||||
alsa_card_index: std::env::var("PHOENIX_ALSA_CARD")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u8>().ok())
|
||||
.unwrap_or(2),
|
||||
alsa_device_name: std::env::var("PHOENIX_ALSA_DEVICE")
|
||||
.ok()
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty()),
|
||||
sample_rate: std::env::var("PHOENIX_SAMPLE_RATE")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.unwrap_or(48_000),
|
||||
period_size: std::env::var("PHOENIX_PERIOD_SIZE")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.unwrap_or(128),
|
||||
buffer_size: std::env::var("PHOENIX_BUFFER_SIZE")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.unwrap_or(512),
|
||||
global_config_path: std::env::var("PHOENIX_GLOBAL_CONFIG_PATH")
|
||||
.ok()
|
||||
.map(|v| PathBuf::from(v.trim()))
|
||||
.filter(|v| !v.as_os_str().is_empty())
|
||||
.unwrap_or_else(default_global_config_path),
|
||||
frontend_presets_path: std::env::var("PHOENIX_FRONTEND_PRESETS_PATH")
|
||||
.ok()
|
||||
.map(|v| PathBuf::from(v.trim()))
|
||||
.filter(|v| !v.as_os_str().is_empty())
|
||||
.unwrap_or_else(default_frontend_presets_path),
|
||||
frontend_layouts_path: std::env::var("PHOENIX_FRONTEND_LAYOUTS_PATH")
|
||||
.ok()
|
||||
.map(|v| PathBuf::from(v.trim()))
|
||||
.filter(|v| !v.as_os_str().is_empty())
|
||||
.unwrap_or_else(default_frontend_layouts_path),
|
||||
recordings_dir_analyzer: std::env::var("PHOENIX_RECORDINGS_DIR_ANALYZER")
|
||||
.ok()
|
||||
.map(|v| PathBuf::from(v.trim()))
|
||||
.filter(|v| !v.as_os_str().is_empty())
|
||||
.unwrap_or_else(default_recordings_dir_analyzer),
|
||||
recordings_dir_volumio: std::env::var("PHOENIX_RECORDINGS_DIR_VOLUMIO")
|
||||
.ok()
|
||||
.map(|v| PathBuf::from(v.trim()))
|
||||
.filter(|v| !v.as_os_str().is_empty())
|
||||
.unwrap_or_else(default_recordings_dir_volumio),
|
||||
max_recording_upload_bytes: std::env::var("PHOENIX_MAX_RECORDING_UPLOAD_BYTES")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<u64>().ok())
|
||||
.filter(|v| *v > 0)
|
||||
.unwrap_or(2 * 1024 * 1024 * 1024),
|
||||
lr_fractional_delay_enabled: std::env::var("PHOENIX_LR_FRAC_DELAY_ENABLED")
|
||||
.ok()
|
||||
.map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
|
||||
.unwrap_or(true),
|
||||
lr_fractional_delay_samples: std::env::var("PHOENIX_LR_FRAC_DELAY_SAMPLES")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<f32>().ok())
|
||||
.unwrap_or(0.996),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn alsa_device(&self) -> String {
|
||||
if let Some(device) = &self.alsa_device_name {
|
||||
return device.clone();
|
||||
}
|
||||
format!("hw:{},0", self.alsa_card_index)
|
||||
}
|
||||
|
||||
pub fn default_rta_config(&self) -> PhoenixRtaConfig {
|
||||
let mut config = PhoenixRtaConfig::default();
|
||||
config.lr_fractional_delay_enabled = self.lr_fractional_delay_enabled;
|
||||
config.lr_fractional_delay_samples = self.lr_fractional_delay_samples;
|
||||
config
|
||||
}
|
||||
|
||||
pub fn default_global_config(&self) -> PhoenixGlobalConfig {
|
||||
let mut config = PhoenixGlobalConfig::default();
|
||||
config.input_source = InputSource::Line;
|
||||
config.lr_fractional_delay_enabled = self.lr_fractional_delay_enabled;
|
||||
config.lr_fractional_delay_samples = self.lr_fractional_delay_samples;
|
||||
config
|
||||
}
|
||||
}
|
||||
|
||||
fn default_global_config_path() -> PathBuf {
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
let mut path = PathBuf::from(home);
|
||||
path.push(".config");
|
||||
path.push("phoenix");
|
||||
path.push("global-config.json");
|
||||
return path;
|
||||
}
|
||||
PathBuf::from("phoenix-global-config.json")
|
||||
}
|
||||
|
||||
fn default_frontend_presets_path() -> PathBuf {
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
let mut path = PathBuf::from(home);
|
||||
path.push(".config");
|
||||
path.push("phoenix");
|
||||
path.push("frontend-presets.json");
|
||||
return path;
|
||||
}
|
||||
PathBuf::from("phoenix-frontend-presets.json")
|
||||
}
|
||||
|
||||
fn default_frontend_layouts_path() -> PathBuf {
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
let mut path = PathBuf::from(home);
|
||||
path.push(".config");
|
||||
path.push("phoenix");
|
||||
path.push("frontend-layouts.json");
|
||||
return path;
|
||||
}
|
||||
PathBuf::from("phoenix-frontend-layouts.json")
|
||||
}
|
||||
|
||||
fn default_recordings_dir_analyzer() -> PathBuf {
|
||||
PathBuf::from("/home/analyzer/Aufnahmen")
|
||||
}
|
||||
|
||||
fn default_recordings_dir_volumio() -> PathBuf {
|
||||
PathBuf::from("/home/volumio/Aufnahmen")
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
mod audio;
|
||||
mod config;
|
||||
mod model;
|
||||
mod routes;
|
||||
mod state;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use anyhow::Context;
|
||||
use axum::{routing::{get, post}, Router};
|
||||
use config::PhoenixConfig;
|
||||
use state::AppState;
|
||||
use tower_http::{cors::CorsLayer, trace::TraceLayer};
|
||||
use tracing::info;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
std::env::var("PHOENIX_LOG")
|
||||
.unwrap_or_else(|_| "phoenix=info,tower_http=info".to_string()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let config = PhoenixConfig::from_env();
|
||||
let state = AppState::new(config.clone());
|
||||
|
||||
state.spawn_audio_capture();
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(routes::health))
|
||||
.route("/api/v1/status", get(routes::status))
|
||||
.route("/api/v1/global-config", get(routes::get_global_config).post(routes::set_global_config))
|
||||
.route("/api/v1/frontend-presets", get(routes::get_frontend_presets).post(routes::set_frontend_preset))
|
||||
.route("/api/v1/frontend-layouts", get(routes::get_frontend_layouts).post(routes::set_frontend_layout))
|
||||
.route("/api/v1/update/download", post(routes::download_online_update))
|
||||
.route("/api/v1/update/log", get(routes::get_online_update_log))
|
||||
.route("/api/v1/update/restart", post(routes::restart_after_online_update))
|
||||
.route("/api/v1/rta-config", get(routes::get_rta_config).post(routes::set_rta_config))
|
||||
.route("/api/v1/recordings/wav/start/:session_id", post(routes::start_wav_recording))
|
||||
.route("/api/v1/recordings/wav/stop/:session_id", post(routes::stop_wav_recording))
|
||||
.route("/api/v1/recordings/stop/:session_id/:format", post(routes::stop_recording_with_format))
|
||||
.route("/api/v1/recordings/save/:target/:filename", post(routes::save_recording_file))
|
||||
.route("/api/v1/metrics/ws", get(routes::metrics_ws))
|
||||
.layer(CorsLayer::permissive())
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
|
||||
let addr: SocketAddr = config
|
||||
.listen_addr
|
||||
.parse()
|
||||
.with_context(|| format!("invalid listen address: {}", config.listen_addr))?;
|
||||
|
||||
info!("Phoenix listening on http://{}", addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum InputSource {
|
||||
Line,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ServiceStatus {
|
||||
pub ok: bool,
|
||||
pub input: InputSource,
|
||||
pub audio_engine: &'static str,
|
||||
pub metrics_mode: &'static str,
|
||||
pub alsa_device: String,
|
||||
pub sample_rate: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct RtaFrame {
|
||||
pub engine: String,
|
||||
pub bands_avg: Vec<f32>,
|
||||
pub bands_peak: Vec<f32>,
|
||||
pub bands: Vec<f32>,
|
||||
pub centers: Vec<f32>,
|
||||
pub freq_min: f32,
|
||||
pub freq_max: f32,
|
||||
pub bpo: String,
|
||||
pub weighting: String,
|
||||
pub layout: String,
|
||||
pub sample_rate: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct SpectroFrame {
|
||||
pub bins: Vec<f32>,
|
||||
pub sample_rate: u32,
|
||||
pub fft_size: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct WaveEnvFrame {
|
||||
pub data: Vec<f32>,
|
||||
pub columns: usize,
|
||||
pub channels: u8,
|
||||
pub column_samples: usize,
|
||||
pub sample_rate: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PhoenixRtaConfig {
|
||||
pub engine: String,
|
||||
pub fft_size: u32,
|
||||
pub mono_input: bool,
|
||||
pub lr_fractional_delay_enabled: bool,
|
||||
pub lr_fractional_delay_samples: f32,
|
||||
pub bpo: String,
|
||||
pub freq_range: String,
|
||||
pub weighting: String,
|
||||
pub order: u8,
|
||||
pub tau_fast: f32,
|
||||
pub tau_slow: f32,
|
||||
pub integration: String,
|
||||
pub layout: String,
|
||||
pub input_offset_db_l: f32,
|
||||
pub input_offset_db_r: f32,
|
||||
pub ppm_din_attack_ms: f32,
|
||||
pub ppm_din_decay_db_per_s: f32,
|
||||
pub ppm_din_fast_attack: bool,
|
||||
pub ppm_ebu_attack_ms: f32,
|
||||
pub ppm_ebu_decay_db_per_s: f32,
|
||||
pub lufs_i_window_min: u32,
|
||||
pub lufs_i_norm_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for PhoenixRtaConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
engine: "iir".to_string(),
|
||||
fft_size: 8192,
|
||||
mono_input: false,
|
||||
lr_fractional_delay_enabled: true,
|
||||
lr_fractional_delay_samples: 0.05,
|
||||
bpo: "1_6".to_string(),
|
||||
freq_range: "norm".to_string(),
|
||||
weighting: "z".to_string(),
|
||||
order: 4,
|
||||
tau_fast: 0.12,
|
||||
tau_slow: 1.0,
|
||||
integration: "fast".to_string(),
|
||||
layout: "rtw".to_string(),
|
||||
input_offset_db_l: -5.0,
|
||||
input_offset_db_r: -5.0,
|
||||
ppm_din_attack_ms: 5.0,
|
||||
ppm_din_decay_db_per_s: 11.8,
|
||||
ppm_din_fast_attack: false,
|
||||
ppm_ebu_attack_ms: 10.0,
|
||||
ppm_ebu_decay_db_per_s: 8.6,
|
||||
lufs_i_window_min: 4,
|
||||
lufs_i_norm_enabled: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PhoenixGlobalConfig {
|
||||
pub fft_size: u32,
|
||||
pub input_source: InputSource,
|
||||
pub rta_bpo_mode: String,
|
||||
pub input_offset_db_l: f32,
|
||||
pub input_offset_db_r: f32,
|
||||
pub mono_input: bool,
|
||||
pub lr_fractional_delay_enabled: bool,
|
||||
pub lr_fractional_delay_samples: f32,
|
||||
pub al_markers_enabled: bool,
|
||||
pub meter_bar_thin: f32,
|
||||
pub panel_dividers_enabled: bool,
|
||||
pub ppm_din_attack_ms: f32,
|
||||
pub ppm_din_decay_db_per_s: f32,
|
||||
pub ppm_din_fast_attack: bool,
|
||||
pub ppm_ebu_attack_ms: f32,
|
||||
pub ppm_ebu_decay_db_per_s: f32,
|
||||
pub lufs_i_window_min: u32,
|
||||
pub lufs_i_norm_enabled: bool,
|
||||
pub ppm_din_loudness_boxes: bool,
|
||||
pub ppm_din_loudness_offset_db: f32,
|
||||
pub xy_points: u32,
|
||||
pub gonio_display_gain_db: f32,
|
||||
pub record_output_format: String,
|
||||
pub record_mp3_bitrate_kbps: u32,
|
||||
pub record_target: String,
|
||||
pub record_auto_split_gap_sec: f32,
|
||||
pub record_auto_threshold_dbfs: f32,
|
||||
pub clock_led_color: String,
|
||||
pub header_text_color: String,
|
||||
pub rta_bar_base_color: String,
|
||||
pub peak_history_scroll_mode: f32,
|
||||
pub peak_history_fill_enabled: bool,
|
||||
pub peak_history_fill_invert: bool,
|
||||
pub peak_history_slot: String,
|
||||
pub phase_display_gain_db: f32,
|
||||
pub phase_agc_enabled: bool,
|
||||
pub phase_trail_enabled: bool,
|
||||
pub phase_amplitude_mode: String,
|
||||
pub classic_needles_slot: String,
|
||||
pub waveform_color_left: String,
|
||||
pub waveform_color_right: String,
|
||||
pub waveform_color_diff: String,
|
||||
pub vu_color_normal: String,
|
||||
pub vu_color_warn: String,
|
||||
pub ppm_din_color_normal: String,
|
||||
pub ppm_din_color_warn: String,
|
||||
pub ppm_ebu_color_normal: String,
|
||||
pub ppm_ebu_color_warn: String,
|
||||
pub tp_color_normal: String,
|
||||
pub tp_color_warn: String,
|
||||
pub rms_color_normal: String,
|
||||
pub rms_color_warn: String,
|
||||
pub lufs_color_i: String,
|
||||
pub lufs_color_m: String,
|
||||
pub lufs_color_s: String,
|
||||
pub lufs_scale_label_color: String,
|
||||
pub screensaver_enabled: bool,
|
||||
pub screensaver_idle_min: f32,
|
||||
pub screensaver_activity_db: f32,
|
||||
pub screensaver_mode: String,
|
||||
pub screensaver_led_glow: bool,
|
||||
pub screensaver_led_color: String,
|
||||
}
|
||||
|
||||
impl Default for PhoenixGlobalConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
fft_size: 8192,
|
||||
input_source: InputSource::Line,
|
||||
rta_bpo_mode: "1_6".to_string(),
|
||||
input_offset_db_l: -5.0,
|
||||
input_offset_db_r: -5.0,
|
||||
mono_input: false,
|
||||
lr_fractional_delay_enabled: true,
|
||||
lr_fractional_delay_samples: 0.996,
|
||||
al_markers_enabled: false,
|
||||
meter_bar_thin: 0.55,
|
||||
panel_dividers_enabled: true,
|
||||
ppm_din_attack_ms: 5.0,
|
||||
ppm_din_decay_db_per_s: 11.8,
|
||||
ppm_din_fast_attack: false,
|
||||
ppm_ebu_attack_ms: 10.0,
|
||||
ppm_ebu_decay_db_per_s: 8.6,
|
||||
lufs_i_window_min: 4,
|
||||
lufs_i_norm_enabled: false,
|
||||
ppm_din_loudness_boxes: true,
|
||||
ppm_din_loudness_offset_db: 0.0,
|
||||
xy_points: 1024,
|
||||
gonio_display_gain_db: -5.0,
|
||||
record_output_format: "wav".to_string(),
|
||||
record_mp3_bitrate_kbps: 192,
|
||||
record_target: "analyzer".to_string(),
|
||||
record_auto_split_gap_sec: 1.5,
|
||||
record_auto_threshold_dbfs: -50.0,
|
||||
clock_led_color: "#ff0000".to_string(),
|
||||
header_text_color: "#ffe066".to_string(),
|
||||
rta_bar_base_color: "#ffe066".to_string(),
|
||||
peak_history_scroll_mode: 1.0,
|
||||
peak_history_fill_enabled: false,
|
||||
peak_history_fill_invert: false,
|
||||
peak_history_slot: "ppm-din".to_string(),
|
||||
phase_display_gain_db: 0.0,
|
||||
phase_agc_enabled: false,
|
||||
phase_trail_enabled: true,
|
||||
phase_amplitude_mode: "ppm-din".to_string(),
|
||||
classic_needles_slot: "vu".to_string(),
|
||||
waveform_color_left: "#00e7ff".to_string(),
|
||||
waveform_color_right: "#ff6b81".to_string(),
|
||||
waveform_color_diff: "#00e7ff".to_string(),
|
||||
vu_color_normal: "#ffe066".to_string(),
|
||||
vu_color_warn: "#ff3b3b".to_string(),
|
||||
ppm_din_color_normal: "#ffe066".to_string(),
|
||||
ppm_din_color_warn: "#ff3b3b".to_string(),
|
||||
ppm_ebu_color_normal: "#ffe066".to_string(),
|
||||
ppm_ebu_color_warn: "#ff3b3b".to_string(),
|
||||
tp_color_normal: "#ffe066".to_string(),
|
||||
tp_color_warn: "#ff3b3b".to_string(),
|
||||
rms_color_normal: "#34d399".to_string(),
|
||||
rms_color_warn: "#ff3b3b".to_string(),
|
||||
lufs_color_i: "#34d399".to_string(),
|
||||
lufs_color_m: "#ffe066".to_string(),
|
||||
lufs_color_s: "#ff3b3b".to_string(),
|
||||
lufs_scale_label_color: "#8fd3d4".to_string(),
|
||||
screensaver_enabled: true,
|
||||
screensaver_idle_min: 5.0,
|
||||
screensaver_activity_db: -50.0,
|
||||
screensaver_mode: "clock".to_string(),
|
||||
screensaver_led_glow: true,
|
||||
screensaver_led_color: "#ff0000".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PhoenixGlobalConfigEnvelope {
|
||||
pub revision: u64,
|
||||
pub config: PhoenixGlobalConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct MeterFrame {
|
||||
pub seq: u64,
|
||||
pub timestamp_ms: u128,
|
||||
pub rms_l: f32,
|
||||
pub rms_r: f32,
|
||||
pub vu_l: f32,
|
||||
pub vu_r: f32,
|
||||
pub tp_l: f32,
|
||||
pub tp_r: f32,
|
||||
pub ppm_din_l: f32,
|
||||
pub ppm_din_r: f32,
|
||||
pub ppm_ebu_l: f32,
|
||||
pub ppm_ebu_r: f32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lufs_m: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lufs_s: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lufs_i: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lra: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lufs_ml: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lufs_mr: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lufs_sl: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lufs_sr: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ppm_box_l: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ppm_box_r: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub wave_l: Vec<f32>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub wave_r: Vec<f32>,
|
||||
pub wave_channels: u8,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub xy_l: Vec<f32>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub xy_r: Vec<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rta: Option<RtaFrame>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub spectro: Option<SpectroFrame>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub wave_env: Option<WaveEnvFrame>,
|
||||
pub global_config_rev: u64,
|
||||
pub input: InputSource,
|
||||
pub source: &'static str,
|
||||
}
|
||||
+984
@@ -0,0 +1,984 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Path, Query, Request, State, WebSocketUpgrade},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use anyhow::Context;
|
||||
use http_body_util::BodyExt;
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
process::Stdio,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use tokio::{fs, io::AsyncWriteExt, process::Command};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{
|
||||
model::{PhoenixGlobalConfig, PhoenixRtaConfig},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
const ONLINE_UPDATE_ZIP_URL: &str = "https://webshare.casaderoll.de/share/Phoenix.zip";
|
||||
|
||||
fn stable_script_command(program: &str) -> Command {
|
||||
let mut command = Command::new(program);
|
||||
command.env("LANG", "C").env("LC_ALL", "C");
|
||||
command
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct StopRecordingQuery {
|
||||
#[serde(default)]
|
||||
pub bitrate_kbps: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FrontendPresetSavePayload {
|
||||
pub preset_id: String,
|
||||
pub config: serde_json::Value,
|
||||
}
|
||||
|
||||
pub async fn health() -> Json<serde_json::Value> {
|
||||
Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"service": "phoenix"
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn status(State(state): State<AppState>) -> Json<crate::model::ServiceStatus> {
|
||||
Json(state.status().await)
|
||||
}
|
||||
|
||||
pub async fn get_rta_config(State(state): State<AppState>) -> Json<PhoenixRtaConfig> {
|
||||
Json(state.rta_config().await)
|
||||
}
|
||||
|
||||
pub async fn get_global_config(State(state): State<AppState>) -> Json<crate::model::PhoenixGlobalConfigEnvelope> {
|
||||
Json(state.global_config_envelope().await)
|
||||
}
|
||||
|
||||
pub async fn get_frontend_presets(State(state): State<AppState>) -> Response {
|
||||
match read_frontend_presets(&state.config).await {
|
||||
Ok(presets) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"presets": presets
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": err.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_frontend_preset(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<FrontendPresetSavePayload>,
|
||||
) -> Response {
|
||||
let preset_id = normalize_frontend_preset_id(&payload.preset_id);
|
||||
if preset_id.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": "invalid preset id"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if !payload.config.is_object() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": "preset config must be a JSON object"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
match persist_frontend_preset(&state.config, &preset_id, &payload.config).await {
|
||||
Ok(saved) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"presetId": preset_id,
|
||||
"config": saved
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": err.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_frontend_layouts(State(state): State<AppState>) -> Response {
|
||||
match read_frontend_layouts(&state.config).await {
|
||||
Ok(layouts) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"layouts": layouts
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": err.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_frontend_layout(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<FrontendPresetSavePayload>,
|
||||
) -> Response {
|
||||
let layout_id = normalize_frontend_layout_id(&payload.preset_id);
|
||||
if layout_id.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": "invalid layout id"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if !payload.config.is_object() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": "layout config must be a JSON object"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
match persist_frontend_layout(&state.config, &layout_id, &payload.config).await {
|
||||
Ok(saved) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"layoutId": layout_id,
|
||||
"config": saved
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": err.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn download_online_update() -> Response {
|
||||
let phoenix_root = match resolve_phoenix_root_dir() {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": err.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let log_path = build_online_update_log_path(&phoenix_root);
|
||||
let script = phoenix_root.join("scripts").join("update_phoenix_from_zip.sh");
|
||||
if !script.is_file() {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": format!("update script missing: {}", script.display())
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
match stable_script_command("bash")
|
||||
.arg(&script)
|
||||
.arg(ONLINE_UPDATE_ZIP_URL)
|
||||
.arg(&phoenix_root)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(output) if output.status.success() => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"message": "download beendet"
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let log_tail = read_online_update_log_tail(&log_path, 80).await;
|
||||
let detail = summarize_update_failure(&stderr, &stdout, &log_tail);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": detail
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": err.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_online_update_log_tail(log_path: &PathBuf, max_lines: usize) -> String {
|
||||
let content = match tokio::fs::read_to_string(log_path).await {
|
||||
Ok(value) => value,
|
||||
Err(_) => return String::new(),
|
||||
};
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let start = lines.len().saturating_sub(max_lines);
|
||||
lines[start..].join("\n").trim().to_string()
|
||||
}
|
||||
|
||||
fn summarize_update_failure(stderr: &str, stdout: &str, log_tail: &str) -> String {
|
||||
let detail = match (stderr.is_empty(), stdout.is_empty()) {
|
||||
(false, false) => format!("{stderr}\n{stdout}"),
|
||||
(false, true) => stderr.to_string(),
|
||||
(true, false) => stdout.to_string(),
|
||||
(true, true) => String::new(),
|
||||
};
|
||||
if !detail.trim().is_empty() {
|
||||
return detail.trim().to_string();
|
||||
}
|
||||
if !log_tail.trim().is_empty() {
|
||||
return log_tail.trim().to_string();
|
||||
}
|
||||
"update failed".to_string()
|
||||
}
|
||||
|
||||
pub async fn restart_after_online_update() -> Response {
|
||||
let phoenix_root = match resolve_phoenix_root_dir() {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": err.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let script = phoenix_root.join("scripts").join("restart_phoenix_services.sh");
|
||||
if !script.is_file() {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": format!("restart script missing: {}", script.display())
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(750)).await;
|
||||
let _ = stable_script_command("bash")
|
||||
.arg(script)
|
||||
.spawn();
|
||||
});
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"ok": true
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn get_online_update_log() -> Response {
|
||||
let phoenix_root = match resolve_phoenix_root_dir() {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": err.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let log_path = build_online_update_log_path(&phoenix_root);
|
||||
let content = match tokio::fs::read_to_string(&log_path).await {
|
||||
Ok(value) => value,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||
Err(err) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": err.to_string(),
|
||||
"path": log_path.display().to_string(),
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let max_lines = 400usize;
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let start = lines.len().saturating_sub(max_lines);
|
||||
let tail = lines[start..].join("\n");
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"path": log_path.display().to_string(),
|
||||
"content": tail,
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn set_rta_config(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<PhoenixRtaConfig>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let applied = state.set_rta_config(payload).await;
|
||||
Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"config": applied
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn set_global_config(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<PhoenixGlobalConfig>,
|
||||
) -> Response {
|
||||
match state.set_global_config(payload).await {
|
||||
Ok(applied) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"revision": applied.revision,
|
||||
"config": applied.config
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": err.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn metrics_ws(ws: WebSocketUpgrade, State(state): State<AppState>) -> Response {
|
||||
ws.on_upgrade(move |socket| metrics_ws_inner(socket, state))
|
||||
}
|
||||
|
||||
pub async fn start_wav_recording(
|
||||
State(state): State<AppState>,
|
||||
Path(session_id): Path<u64>,
|
||||
) -> Response {
|
||||
match state.start_native_wav_recording(session_id) {
|
||||
Ok(()) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"sessionId": session_id
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": err.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn stop_wav_recording(
|
||||
State(state): State<AppState>,
|
||||
Path(session_id): Path<u64>,
|
||||
) -> Response {
|
||||
stop_recording_inner(state, session_id, "wav", None).await
|
||||
}
|
||||
|
||||
pub async fn stop_recording_with_format(
|
||||
State(state): State<AppState>,
|
||||
Path((session_id, format)): Path<(u64, String)>,
|
||||
Query(query): Query<StopRecordingQuery>,
|
||||
) -> Response {
|
||||
stop_recording_inner(state, session_id, &format, query.bitrate_kbps).await
|
||||
}
|
||||
|
||||
async fn stop_recording_inner(state: AppState, session_id: u64, format: &str, bitrate_kbps: Option<u32>) -> Response {
|
||||
let normalized_format = normalize_recording_format(format);
|
||||
if normalized_format.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": "invalid format; expected 'wav', 'mp3' or 'webm'"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
match state.stop_native_wav_recording(session_id) {
|
||||
Ok(capture) => match build_capture_bytes(normalized_format, &capture, bitrate_kbps).await {
|
||||
Ok((mime_type, payload)) => (
|
||||
StatusCode::OK,
|
||||
[
|
||||
("content-type", mime_type),
|
||||
("cache-control", "no-store"),
|
||||
],
|
||||
Body::from(payload),
|
||||
)
|
||||
.into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": err.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
},
|
||||
Err(err) => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": err.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_capture_bytes(
|
||||
format: &str,
|
||||
capture: &crate::state::NativeWavCapture,
|
||||
bitrate_kbps: Option<u32>,
|
||||
) -> anyhow::Result<(&'static str, Vec<u8>)> {
|
||||
match format {
|
||||
"mp3" => Ok((
|
||||
"audio/mpeg",
|
||||
encode_mp3_bytes(
|
||||
capture.sample_rate,
|
||||
capture.channels,
|
||||
&capture.pcm_bytes,
|
||||
normalize_mp3_bitrate_kbps(bitrate_kbps),
|
||||
).await?,
|
||||
)),
|
||||
"webm" => Ok((
|
||||
"audio/webm",
|
||||
encode_webm_bytes(capture.sample_rate, capture.channels, &capture.pcm_bytes).await?,
|
||||
)),
|
||||
_ => Ok((
|
||||
"audio/wav",
|
||||
build_wav_bytes(capture.sample_rate, capture.channels, &capture.pcm_bytes),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn encode_mp3_bytes(sample_rate: u32, channels: u16, pcm_bytes: &[u8], bitrate_kbps: u32) -> anyhow::Result<Vec<u8>> {
|
||||
let wav = build_wav_bytes(sample_rate, channels, pcm_bytes);
|
||||
let token = format!(
|
||||
"{}-{}-{}",
|
||||
std::process::id(),
|
||||
sample_rate,
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
);
|
||||
let mut input_path = std::env::temp_dir();
|
||||
input_path.push(format!("phoenix-recording-{}.wav", token));
|
||||
let mut output_path = std::env::temp_dir();
|
||||
output_path.push(format!("phoenix-recording-{}.mp3", token));
|
||||
fs::write(&input_path, wav).await?;
|
||||
let encode_result = try_encode_mp3_with_commands(&input_path, &output_path, bitrate_kbps).await;
|
||||
let payload = match encode_result {
|
||||
Ok(()) => fs::read(&output_path).await?,
|
||||
Err(err) => {
|
||||
let _ = fs::remove_file(&input_path).await;
|
||||
let _ = fs::remove_file(&output_path).await;
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let _ = fs::remove_file(&input_path).await;
|
||||
let _ = fs::remove_file(&output_path).await;
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
async fn try_encode_mp3_with_commands(input_path: &PathBuf, output_path: &PathBuf, bitrate_kbps: u32) -> anyhow::Result<()> {
|
||||
let ffmpeg = Command::new("ffmpeg")
|
||||
.arg("-y")
|
||||
.arg("-hide_banner")
|
||||
.arg("-loglevel")
|
||||
.arg("error")
|
||||
.arg("-i")
|
||||
.arg(input_path)
|
||||
.arg("-codec:a")
|
||||
.arg("libmp3lame")
|
||||
.arg("-b:a")
|
||||
.arg(format!("{}k", bitrate_kbps))
|
||||
.arg(output_path)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await;
|
||||
match ffmpeg {
|
||||
Ok(output) if output.status.success() => return Ok(()),
|
||||
Ok(_) | Err(_) => {}
|
||||
}
|
||||
|
||||
let lame = Command::new("lame")
|
||||
.arg("--silent")
|
||||
.arg("-b")
|
||||
.arg(bitrate_kbps.to_string())
|
||||
.arg(input_path)
|
||||
.arg(output_path)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await;
|
||||
match lame {
|
||||
Ok(output) if output.status.success() => Ok(()),
|
||||
Ok(output) => Err(anyhow::anyhow!(
|
||||
"MP3 encoding failed; ffmpeg/libmp3lame or lame is required. lame stderr: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)),
|
||||
Err(err) => Err(anyhow::anyhow!(
|
||||
"MP3 encoding unavailable; install ffmpeg with libmp3lame or lame ({})",
|
||||
err
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn encode_webm_bytes(sample_rate: u32, channels: u16, pcm_bytes: &[u8]) -> anyhow::Result<Vec<u8>> {
|
||||
let wav = build_wav_bytes(sample_rate, channels, pcm_bytes);
|
||||
let token = format!(
|
||||
"{}-{}-{}",
|
||||
std::process::id(),
|
||||
sample_rate,
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
);
|
||||
let mut input_path = std::env::temp_dir();
|
||||
input_path.push(format!("phoenix-recording-{}.wav", token));
|
||||
let mut output_path = std::env::temp_dir();
|
||||
output_path.push(format!("phoenix-recording-{}.webm", token));
|
||||
fs::write(&input_path, wav).await?;
|
||||
let encode_result = Command::new("ffmpeg")
|
||||
.arg("-y")
|
||||
.arg("-hide_banner")
|
||||
.arg("-loglevel")
|
||||
.arg("error")
|
||||
.arg("-i")
|
||||
.arg(&input_path)
|
||||
.arg("-codec:a")
|
||||
.arg("libopus")
|
||||
.arg("-b:a")
|
||||
.arg("128k")
|
||||
.arg(&output_path)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await;
|
||||
let payload = match encode_result {
|
||||
Ok(output) if output.status.success() => fs::read(&output_path).await?,
|
||||
Ok(output) => {
|
||||
let _ = fs::remove_file(&input_path).await;
|
||||
let _ = fs::remove_file(&output_path).await;
|
||||
return Err(anyhow::anyhow!(
|
||||
"WebM/Opus encoding failed; ffmpeg with libopus is required. ffmpeg stderr: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = fs::remove_file(&input_path).await;
|
||||
let _ = fs::remove_file(&output_path).await;
|
||||
return Err(anyhow::anyhow!(
|
||||
"WebM/Opus encoding unavailable; install ffmpeg with libopus ({})",
|
||||
err
|
||||
));
|
||||
}
|
||||
};
|
||||
let _ = fs::remove_file(&input_path).await;
|
||||
let _ = fs::remove_file(&output_path).await;
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
fn normalize_recording_format(raw: &str) -> &'static str {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"mp3" | "mpeg" => "mp3",
|
||||
"webm" | "opus" => "webm",
|
||||
"wav" | "" => "wav",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_mp3_bitrate_kbps(raw: Option<u32>) -> u32 {
|
||||
match raw.unwrap_or(192) {
|
||||
64 => 64,
|
||||
128 => 128,
|
||||
192 => 192,
|
||||
256 => 256,
|
||||
320 => 320,
|
||||
_ => 192,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save_recording_file(
|
||||
State(state): State<AppState>,
|
||||
Path((target, filename)): Path<(String, String)>,
|
||||
request: Request,
|
||||
) -> Response {
|
||||
let safe_name = sanitize_recording_filename(&filename);
|
||||
if safe_name.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": "invalid filename"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let safe_target = sanitize_recording_target(&target);
|
||||
if safe_target.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": "invalid target"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
match write_recording_body(&state.config, &safe_target, &safe_name, request.into_body()).await {
|
||||
Ok(path) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"target": safe_target,
|
||||
"filename": safe_name,
|
||||
"path": path
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(err) => {
|
||||
let message = err.to_string();
|
||||
let status = if is_recording_upload_too_large(&message) {
|
||||
StatusCode::PAYLOAD_TOO_LARGE
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
};
|
||||
(
|
||||
status,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": message
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_frontend_presets(config: &crate::config::PhoenixConfig) -> anyhow::Result<serde_json::Map<String, serde_json::Value>> {
|
||||
let path = config.frontend_presets_path.clone();
|
||||
let raw = match fs::read_to_string(&path).await {
|
||||
Ok(raw) => raw,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(serde_json::Map::new()),
|
||||
Err(err) => return Err(anyhow::anyhow!("failed to read frontend presets {} ({})", path.display(), err)),
|
||||
};
|
||||
let parsed = serde_json::from_str::<serde_json::Value>(&raw)
|
||||
.map_err(|err| anyhow::anyhow!("failed to parse frontend presets {} ({})", path.display(), err))?;
|
||||
Ok(parsed.as_object().cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn read_frontend_layouts(config: &crate::config::PhoenixConfig) -> anyhow::Result<serde_json::Map<String, serde_json::Value>> {
|
||||
let path = config.frontend_layouts_path.clone();
|
||||
let raw = match fs::read_to_string(&path).await {
|
||||
Ok(raw) => raw,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(serde_json::Map::new()),
|
||||
Err(err) => return Err(anyhow::anyhow!("failed to read frontend layouts {} ({})", path.display(), err)),
|
||||
};
|
||||
let parsed = serde_json::from_str::<serde_json::Value>(&raw)
|
||||
.map_err(|err| anyhow::anyhow!("failed to parse frontend layouts {} ({})", path.display(), err))?;
|
||||
Ok(parsed.as_object().cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn persist_frontend_presets(
|
||||
config: &crate::config::PhoenixConfig,
|
||||
presets: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> anyhow::Result<()> {
|
||||
let path = config.frontend_presets_path.clone();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.with_context(|| format!("failed to create frontend preset dir {}", parent.display()))?;
|
||||
}
|
||||
let json = serde_json::to_vec_pretty(&serde_json::Value::Object(presets.clone()))?;
|
||||
fs::write(&path, json)
|
||||
.await
|
||||
.with_context(|| format!("failed to write frontend presets {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn persist_frontend_layouts(
|
||||
config: &crate::config::PhoenixConfig,
|
||||
layouts: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> anyhow::Result<()> {
|
||||
let path = config.frontend_layouts_path.clone();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.with_context(|| format!("failed to create frontend layout dir {}", parent.display()))?;
|
||||
}
|
||||
let json = serde_json::to_vec_pretty(&serde_json::Value::Object(layouts.clone()))?;
|
||||
fs::write(&path, json)
|
||||
.await
|
||||
.with_context(|| format!("failed to write frontend layouts {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn persist_frontend_preset(
|
||||
config: &crate::config::PhoenixConfig,
|
||||
preset_id: &str,
|
||||
preset_config: &serde_json::Value,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let mut presets = read_frontend_presets(config).await?;
|
||||
presets.insert(preset_id.to_string(), preset_config.clone());
|
||||
persist_frontend_presets(config, &presets).await?;
|
||||
Ok(preset_config.clone())
|
||||
}
|
||||
|
||||
async fn persist_frontend_layout(
|
||||
config: &crate::config::PhoenixConfig,
|
||||
layout_id: &str,
|
||||
layout_config: &serde_json::Value,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let mut layouts = read_frontend_layouts(config).await?;
|
||||
layouts.insert(layout_id.to_string(), layout_config.clone());
|
||||
persist_frontend_layouts(config, &layouts).await?;
|
||||
Ok(layout_config.clone())
|
||||
}
|
||||
|
||||
fn normalize_frontend_preset_id(raw: &str) -> String {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"claus" => "claus".to_string(),
|
||||
"michael" => "michael".to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_frontend_layout_id(raw: &str) -> String {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "layout1" | "slot1" | "a" => "1".to_string(),
|
||||
"2" | "layout2" | "slot2" | "b" => "2".to_string(),
|
||||
"3" | "layout3" | "slot3" | "c" => "3".to_string(),
|
||||
"4" | "layout4" | "slot4" | "d" => "4".to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn metrics_ws_inner(mut socket: axum::extract::ws::WebSocket, state: AppState) {
|
||||
let mut rx = state.subscribe_metrics();
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(frame) => {
|
||||
let payload = match serde_json::to_string(&frame) {
|
||||
Ok(payload) => payload,
|
||||
Err(err) => {
|
||||
warn!("failed to encode metrics frame: {}", err);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if socket
|
||||
.send(axum::extract::ws::Message::Text(payload))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
match err {
|
||||
tokio::sync::broadcast::error::RecvError::Lagged(skipped) => {
|
||||
warn!("metrics channel lagged by {}; dropping stale frames", skipped);
|
||||
continue;
|
||||
}
|
||||
tokio::sync::broadcast::error::RecvError::Closed => {
|
||||
warn!("metrics channel closed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_phoenix_root_dir() -> anyhow::Result<PathBuf> {
|
||||
let exe = std::env::current_exe().context("failed to resolve current executable")?;
|
||||
for ancestor in exe.ancestors() {
|
||||
if ancestor.join("Cargo.toml").is_file()
|
||||
&& ancestor.join("www").is_dir()
|
||||
&& ancestor.join("src").is_dir()
|
||||
{
|
||||
return Ok(ancestor.to_path_buf());
|
||||
}
|
||||
}
|
||||
Err(anyhow::anyhow!(
|
||||
"failed to locate Phoenix root from executable path {}",
|
||||
exe.display()
|
||||
))
|
||||
}
|
||||
|
||||
fn build_online_update_log_path(phoenix_root: &PathBuf) -> PathBuf {
|
||||
let phoenix_parent = phoenix_root
|
||||
.parent()
|
||||
.map(|path| path.to_path_buf())
|
||||
.unwrap_or_else(|| phoenix_root.clone());
|
||||
let phoenix_name = phoenix_root
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("Phoenix");
|
||||
phoenix_parent.join(format!("{phoenix_name}.update.log"))
|
||||
}
|
||||
|
||||
fn build_wav_bytes(sample_rate: u32, channels: u16, pcm_bytes: &[u8]) -> Vec<u8> {
|
||||
let data_size = pcm_bytes.len() as u32;
|
||||
let bytes_per_sample = 2u16;
|
||||
let block_align = channels.saturating_mul(bytes_per_sample);
|
||||
let byte_rate = sample_rate.saturating_mul(block_align as u32);
|
||||
let mut out = Vec::with_capacity(44 + pcm_bytes.len());
|
||||
out.extend_from_slice(b"RIFF");
|
||||
out.extend_from_slice(&(36u32.saturating_add(data_size)).to_le_bytes());
|
||||
out.extend_from_slice(b"WAVE");
|
||||
out.extend_from_slice(b"fmt ");
|
||||
out.extend_from_slice(&16u32.to_le_bytes());
|
||||
out.extend_from_slice(&1u16.to_le_bytes());
|
||||
out.extend_from_slice(&channels.to_le_bytes());
|
||||
out.extend_from_slice(&sample_rate.to_le_bytes());
|
||||
out.extend_from_slice(&byte_rate.to_le_bytes());
|
||||
out.extend_from_slice(&block_align.to_le_bytes());
|
||||
out.extend_from_slice(&16u16.to_le_bytes());
|
||||
out.extend_from_slice(b"data");
|
||||
out.extend_from_slice(&data_size.to_le_bytes());
|
||||
out.extend_from_slice(pcm_bytes);
|
||||
out
|
||||
}
|
||||
|
||||
fn sanitize_recording_filename(raw: &str) -> String {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
trimmed
|
||||
.chars()
|
||||
.filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-'))
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
fn sanitize_recording_target(raw: &str) -> String {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"analyzer" => "analyzer".to_string(),
|
||||
"volumio" => "volumio".to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_recording_upload_too_large(message: &str) -> bool {
|
||||
message.starts_with("recording upload exceeds limit")
|
||||
}
|
||||
|
||||
async fn write_recording_body(
|
||||
config: &crate::config::PhoenixConfig,
|
||||
target: &str,
|
||||
filename: &str,
|
||||
mut body: Body,
|
||||
) -> anyhow::Result<String> {
|
||||
let dir = match target {
|
||||
"analyzer" => config.recordings_dir_analyzer.clone(),
|
||||
"volumio" => config.recordings_dir_volumio.clone(),
|
||||
_ => anyhow::bail!("invalid target"),
|
||||
};
|
||||
tokio::fs::create_dir_all(&dir).await?;
|
||||
let path = dir.join(filename);
|
||||
let tmp_path = dir.join(format!(".{}.part", filename));
|
||||
let mut file = tokio::fs::File::create(&tmp_path).await?;
|
||||
let mut written = 0u64;
|
||||
|
||||
while let Some(frame) = body.frame().await {
|
||||
let frame = frame
|
||||
.map_err(|err| anyhow::anyhow!("failed to read recording upload body ({})", err))?;
|
||||
if let Some(chunk) = frame.data_ref() {
|
||||
written = written.saturating_add(chunk.len() as u64);
|
||||
if written > config.max_recording_upload_bytes {
|
||||
drop(file);
|
||||
let _ = tokio::fs::remove_file(&tmp_path).await;
|
||||
anyhow::bail!(
|
||||
"recording upload exceeds limit of {} bytes",
|
||||
config.max_recording_upload_bytes
|
||||
);
|
||||
}
|
||||
file.write_all(chunk).await?;
|
||||
}
|
||||
}
|
||||
|
||||
file.flush().await?;
|
||||
drop(file);
|
||||
tokio::fs::rename(&tmp_path, &path).await?;
|
||||
Ok(path.display().to_string())
|
||||
}
|
||||
+573
@@ -0,0 +1,573 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
atomic::{AtomicU64, Ordering},
|
||||
Arc,
|
||||
Mutex,
|
||||
},
|
||||
};
|
||||
|
||||
use anyhow::{anyhow, Context};
|
||||
use tokio::{
|
||||
fs,
|
||||
sync::{broadcast, RwLock},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
audio::{spawn_audio_capture_worker, AudioWorkerDeps},
|
||||
config::PhoenixConfig,
|
||||
model::{InputSource, MeterFrame, PhoenixGlobalConfig, PhoenixGlobalConfigEnvelope, PhoenixRtaConfig, ServiceStatus},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub config: PhoenixConfig,
|
||||
current_input: Arc<RwLock<InputSource>>,
|
||||
seq: Arc<AtomicU64>,
|
||||
metrics_tx: broadcast::Sender<MeterFrame>,
|
||||
restart_token: Arc<AtomicU64>,
|
||||
rta_config: Arc<RwLock<PhoenixRtaConfig>>,
|
||||
global_config: Arc<RwLock<PhoenixGlobalConfig>>,
|
||||
global_config_rev: Arc<AtomicU64>,
|
||||
native_wav_recorders: Arc<Mutex<HashMap<u64, NativeWavRecorder>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct NativeWavCapture {
|
||||
pub sample_rate: u32,
|
||||
pub channels: u16,
|
||||
pub pcm_bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
pub(crate) struct NativeWavRecorder {
|
||||
pub sample_rate: u32,
|
||||
pub channels: u16,
|
||||
pub pcm_bytes: Vec<u8>,
|
||||
pub total_frames: usize,
|
||||
pub last_l: i16,
|
||||
pub last_r: i16,
|
||||
pub pending_discontinuity: bool,
|
||||
}
|
||||
|
||||
|
||||
const NATIVE_RECORDING_EDGE_FADE_FRAMES: usize = 128;
|
||||
|
||||
fn apply_native_recording_edge_fades(pcm_bytes: &mut [u8], channels: u16) {
|
||||
let ch = usize::from(channels.max(1));
|
||||
let frame_size = ch.saturating_mul(2);
|
||||
if frame_size == 0 || pcm_bytes.len() < frame_size * 2 {
|
||||
return;
|
||||
}
|
||||
let total_frames = pcm_bytes.len() / frame_size;
|
||||
if total_frames < 2 {
|
||||
return;
|
||||
}
|
||||
let fade_frames = NATIVE_RECORDING_EDGE_FADE_FRAMES.min(total_frames / 2).max(1);
|
||||
|
||||
for frame_idx in 0..fade_frames {
|
||||
let in_gain = (frame_idx as f32 + 1.0) / fade_frames as f32;
|
||||
let out_gain = (fade_frames as f32 - frame_idx as f32) / fade_frames as f32;
|
||||
let out_frame_idx = total_frames - fade_frames + frame_idx;
|
||||
for ch_idx in 0..ch {
|
||||
let in_off = frame_idx * frame_size + ch_idx * 2;
|
||||
let in_sample = i16::from_le_bytes([pcm_bytes[in_off], pcm_bytes[in_off + 1]]);
|
||||
let in_scaled = (in_sample as f32 * in_gain).round().clamp(i16::MIN as f32, i16::MAX as f32) as i16;
|
||||
pcm_bytes[in_off..in_off + 2].copy_from_slice(&in_scaled.to_le_bytes());
|
||||
|
||||
let out_off = out_frame_idx * frame_size + ch_idx * 2;
|
||||
let out_sample = i16::from_le_bytes([pcm_bytes[out_off], pcm_bytes[out_off + 1]]);
|
||||
let out_scaled = (out_sample as f32 * out_gain).round().clamp(i16::MIN as f32, i16::MAX as f32) as i16;
|
||||
pcm_bytes[out_off..out_off + 2].copy_from_slice(&out_scaled.to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(config: PhoenixConfig) -> Self {
|
||||
let (metrics_tx, _) = broadcast::channel(512);
|
||||
let initial_global_config = load_global_config(&config);
|
||||
let initial_rta_config = apply_global_to_rta(config.default_rta_config(), &initial_global_config);
|
||||
Self {
|
||||
config,
|
||||
current_input: Arc::new(RwLock::new(InputSource::Line)),
|
||||
seq: Arc::new(AtomicU64::new(0)),
|
||||
metrics_tx,
|
||||
restart_token: Arc::new(AtomicU64::new(0)),
|
||||
rta_config: Arc::new(RwLock::new(initial_rta_config)),
|
||||
global_config: Arc::new(RwLock::new(initial_global_config)),
|
||||
global_config_rev: Arc::new(AtomicU64::new(1)),
|
||||
native_wav_recorders: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subscribe_metrics(&self) -> broadcast::Receiver<MeterFrame> {
|
||||
self.metrics_tx.subscribe()
|
||||
}
|
||||
|
||||
pub async fn input(&self) -> InputSource {
|
||||
*self.current_input.read().await
|
||||
}
|
||||
|
||||
pub async fn status(&self) -> ServiceStatus {
|
||||
ServiceStatus {
|
||||
ok: true,
|
||||
input: self.input().await,
|
||||
audio_engine: "alsa-direct",
|
||||
metrics_mode: "live",
|
||||
alsa_device: self.config.alsa_device(),
|
||||
sample_rate: self.config.sample_rate,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn rta_config(&self) -> PhoenixRtaConfig {
|
||||
self.rta_config.read().await.clone()
|
||||
}
|
||||
|
||||
pub async fn set_rta_config(&self, config: PhoenixRtaConfig) -> PhoenixRtaConfig {
|
||||
let normalized = normalize_rta_config(config);
|
||||
*self.rta_config.write().await = normalized.clone();
|
||||
let current = self.global_config.read().await.clone();
|
||||
let updated_global = PhoenixGlobalConfig {
|
||||
fft_size: normalized.fft_size,
|
||||
input_source: current.input_source,
|
||||
rta_bpo_mode: current.rta_bpo_mode.clone(),
|
||||
input_offset_db_l: normalized.input_offset_db_l,
|
||||
input_offset_db_r: normalized.input_offset_db_r,
|
||||
mono_input: normalized.mono_input,
|
||||
lr_fractional_delay_enabled: normalized.lr_fractional_delay_enabled,
|
||||
lr_fractional_delay_samples: normalized.lr_fractional_delay_samples,
|
||||
al_markers_enabled: current.al_markers_enabled,
|
||||
meter_bar_thin: current.meter_bar_thin,
|
||||
panel_dividers_enabled: current.panel_dividers_enabled,
|
||||
ppm_din_attack_ms: normalized.ppm_din_attack_ms,
|
||||
ppm_din_decay_db_per_s: normalized.ppm_din_decay_db_per_s,
|
||||
ppm_din_fast_attack: normalized.ppm_din_fast_attack,
|
||||
ppm_ebu_attack_ms: normalized.ppm_ebu_attack_ms,
|
||||
ppm_ebu_decay_db_per_s: normalized.ppm_ebu_decay_db_per_s,
|
||||
lufs_i_window_min: normalized.lufs_i_window_min,
|
||||
lufs_i_norm_enabled: normalized.lufs_i_norm_enabled,
|
||||
ppm_din_loudness_boxes: current.ppm_din_loudness_boxes,
|
||||
ppm_din_loudness_offset_db: current.ppm_din_loudness_offset_db,
|
||||
xy_points: current.xy_points,
|
||||
gonio_display_gain_db: current.gonio_display_gain_db,
|
||||
record_output_format: current.record_output_format.clone(),
|
||||
record_mp3_bitrate_kbps: current.record_mp3_bitrate_kbps,
|
||||
record_target: current.record_target.clone(),
|
||||
record_auto_split_gap_sec: current.record_auto_split_gap_sec,
|
||||
record_auto_threshold_dbfs: current.record_auto_threshold_dbfs,
|
||||
clock_led_color: current.clock_led_color.clone(),
|
||||
header_text_color: current.header_text_color.clone(),
|
||||
rta_bar_base_color: current.rta_bar_base_color.clone(),
|
||||
peak_history_scroll_mode: current.peak_history_scroll_mode,
|
||||
peak_history_fill_enabled: current.peak_history_fill_enabled,
|
||||
peak_history_fill_invert: current.peak_history_fill_invert,
|
||||
peak_history_slot: current.peak_history_slot.clone(),
|
||||
phase_display_gain_db: current.phase_display_gain_db,
|
||||
phase_agc_enabled: current.phase_agc_enabled,
|
||||
phase_trail_enabled: current.phase_trail_enabled,
|
||||
phase_amplitude_mode: current.phase_amplitude_mode.clone(),
|
||||
classic_needles_slot: current.classic_needles_slot.clone(),
|
||||
waveform_color_left: current.waveform_color_left.clone(),
|
||||
waveform_color_right: current.waveform_color_right.clone(),
|
||||
waveform_color_diff: current.waveform_color_diff.clone(),
|
||||
vu_color_normal: current.vu_color_normal.clone(),
|
||||
vu_color_warn: current.vu_color_warn.clone(),
|
||||
ppm_din_color_normal: current.ppm_din_color_normal.clone(),
|
||||
ppm_din_color_warn: current.ppm_din_color_warn.clone(),
|
||||
ppm_ebu_color_normal: current.ppm_ebu_color_normal.clone(),
|
||||
ppm_ebu_color_warn: current.ppm_ebu_color_warn.clone(),
|
||||
tp_color_normal: current.tp_color_normal.clone(),
|
||||
tp_color_warn: current.tp_color_warn.clone(),
|
||||
rms_color_normal: current.rms_color_normal.clone(),
|
||||
rms_color_warn: current.rms_color_warn.clone(),
|
||||
lufs_color_i: current.lufs_color_i.clone(),
|
||||
lufs_color_m: current.lufs_color_m.clone(),
|
||||
lufs_color_s: current.lufs_color_s.clone(),
|
||||
lufs_scale_label_color: current.lufs_scale_label_color.clone(),
|
||||
screensaver_enabled: current.screensaver_enabled,
|
||||
screensaver_idle_min: current.screensaver_idle_min,
|
||||
screensaver_activity_db: current.screensaver_activity_db,
|
||||
screensaver_mode: current.screensaver_mode.clone(),
|
||||
screensaver_led_glow: current.screensaver_led_glow,
|
||||
screensaver_led_color: current.screensaver_led_color.clone(),
|
||||
};
|
||||
if current != updated_global {
|
||||
*self.global_config.write().await = updated_global.clone();
|
||||
let _ = persist_global_config(&self.config, &updated_global).await;
|
||||
self.global_config_rev.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
pub async fn global_config(&self) -> PhoenixGlobalConfig {
|
||||
self.global_config.read().await.clone()
|
||||
}
|
||||
|
||||
pub fn global_config_revision(&self) -> u64 {
|
||||
self.global_config_rev.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub async fn global_config_envelope(&self) -> PhoenixGlobalConfigEnvelope {
|
||||
PhoenixGlobalConfigEnvelope {
|
||||
revision: self.global_config_revision(),
|
||||
config: self.global_config().await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_global_config(
|
||||
&self,
|
||||
payload: PhoenixGlobalConfig,
|
||||
) -> anyhow::Result<PhoenixGlobalConfigEnvelope> {
|
||||
let normalized = normalize_global_config(payload, &self.config);
|
||||
let current_global = self.global_config.read().await.clone();
|
||||
if current_global == normalized {
|
||||
return Ok(PhoenixGlobalConfigEnvelope {
|
||||
revision: self.global_config_revision(),
|
||||
config: current_global,
|
||||
});
|
||||
}
|
||||
let merged_rta = {
|
||||
let current = self.rta_config.read().await.clone();
|
||||
apply_global_to_rta(current, &normalized)
|
||||
};
|
||||
*self.global_config.write().await = normalized.clone();
|
||||
*self.rta_config.write().await = merged_rta;
|
||||
persist_global_config(&self.config, &normalized).await?;
|
||||
let revision = self.global_config_rev.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
Ok(PhoenixGlobalConfigEnvelope {
|
||||
revision,
|
||||
config: normalized,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn spawn_audio_capture(&self) {
|
||||
spawn_audio_capture_worker(AudioWorkerDeps {
|
||||
config: self.config.clone(),
|
||||
input: self.current_input.clone(),
|
||||
rta_config: self.rta_config.clone(),
|
||||
global_config_rev: self.global_config_rev.clone(),
|
||||
native_wav_recorders: self.native_wav_recorders.clone(),
|
||||
seq: self.seq.clone(),
|
||||
metrics_tx: self.metrics_tx.clone(),
|
||||
restart_token: self.restart_token.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn start_native_wav_recording(&self, session_id: u64) -> anyhow::Result<()> {
|
||||
if session_id == 0 {
|
||||
return Err(anyhow!("invalid recording session"));
|
||||
}
|
||||
let mut guard = self
|
||||
.native_wav_recorders
|
||||
.lock()
|
||||
.map_err(|_| anyhow!("native recorder lock poisoned"))?;
|
||||
guard.insert(
|
||||
session_id,
|
||||
NativeWavRecorder {
|
||||
sample_rate: self.config.sample_rate,
|
||||
channels: 2,
|
||||
pcm_bytes: Vec::new(),
|
||||
total_frames: 0,
|
||||
last_l: 0,
|
||||
last_r: 0,
|
||||
pending_discontinuity: false,
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop_native_wav_recording(&self, session_id: u64) -> anyhow::Result<NativeWavCapture> {
|
||||
let mut guard = self
|
||||
.native_wav_recorders
|
||||
.lock()
|
||||
.map_err(|_| anyhow!("native recorder lock poisoned"))?;
|
||||
let Some(capture) = guard.remove(&session_id) else {
|
||||
return Err(anyhow!("recording session not found"));
|
||||
};
|
||||
let mut pcm_bytes = capture.pcm_bytes;
|
||||
apply_native_recording_edge_fades(&mut pcm_bytes, capture.channels);
|
||||
Ok(NativeWavCapture {
|
||||
sample_rate: capture.sample_rate,
|
||||
channels: capture.channels,
|
||||
pcm_bytes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_rta_config(mut config: PhoenixRtaConfig) -> PhoenixRtaConfig {
|
||||
config.engine = match config.engine.trim().to_ascii_lowercase().as_str() {
|
||||
"fft" => "fft".to_string(),
|
||||
_ => "iir".to_string(),
|
||||
};
|
||||
config.fft_size = match config.fft_size {
|
||||
2048 | 4096 | 8192 | 16384 => config.fft_size,
|
||||
n if n < 3072 => 2048,
|
||||
n if n < 6144 => 4096,
|
||||
n if n < 12288 => 8192,
|
||||
_ => 16384,
|
||||
};
|
||||
config.mono_input = !!config.mono_input;
|
||||
config.lr_fractional_delay_enabled = !!config.lr_fractional_delay_enabled;
|
||||
let lr_delay = if config.lr_fractional_delay_samples.is_finite() {
|
||||
config.lr_fractional_delay_samples
|
||||
} else {
|
||||
0.05
|
||||
};
|
||||
config.lr_fractional_delay_samples = lr_delay.clamp(-1.5, 1.5);
|
||||
config.bpo = match config.bpo.trim().replace('/', "_").as_str() {
|
||||
"1_3" => "1_3".to_string(),
|
||||
"1_12" => "1_12".to_string(),
|
||||
_ => "1_6".to_string(),
|
||||
};
|
||||
config.freq_range = match config.freq_range.trim().to_ascii_lowercase().as_str() {
|
||||
"lf" => "lf".to_string(),
|
||||
_ => "norm".to_string(),
|
||||
};
|
||||
config.weighting = match config.weighting.trim().to_ascii_lowercase().as_str() {
|
||||
"a" => "a".to_string(),
|
||||
"c" => "c".to_string(),
|
||||
_ => "z".to_string(),
|
||||
};
|
||||
config.layout = match config.layout.trim().to_ascii_lowercase().as_str() {
|
||||
"iec" => "iec".to_string(),
|
||||
_ => "rtw".to_string(),
|
||||
};
|
||||
config.integration = match config.integration.trim().to_ascii_lowercase().as_str() {
|
||||
"impulse" => "impulse".to_string(),
|
||||
"slow" => "slow".to_string(),
|
||||
"peak" => "peak".to_string(),
|
||||
_ => "fast".to_string(),
|
||||
};
|
||||
config.order = config.order.clamp(2, 8);
|
||||
|
||||
let tau_fast = if config.tau_fast.is_finite() { config.tau_fast } else { 0.12 };
|
||||
let tau_slow = if config.tau_slow.is_finite() { config.tau_slow } else { 1.0 };
|
||||
config.tau_fast = tau_fast.clamp(0.01, 3.0);
|
||||
config.tau_slow = tau_slow.clamp(0.05, 10.0);
|
||||
if config.tau_slow < config.tau_fast {
|
||||
config.tau_slow = config.tau_fast;
|
||||
}
|
||||
let offset_l = if config.input_offset_db_l.is_finite() { config.input_offset_db_l } else { -5.0 };
|
||||
let offset_r = if config.input_offset_db_r.is_finite() { config.input_offset_db_r } else { -5.0 };
|
||||
config.input_offset_db_l = offset_l.clamp(-60.0, 20.0);
|
||||
config.input_offset_db_r = offset_r.clamp(-60.0, 20.0);
|
||||
let din_attack_ms = if config.ppm_din_attack_ms.is_finite() { config.ppm_din_attack_ms } else { 5.0 };
|
||||
let din_decay = if config.ppm_din_decay_db_per_s.is_finite() { config.ppm_din_decay_db_per_s } else { 20.0 / 1.7 };
|
||||
let ebu_attack_ms = if config.ppm_ebu_attack_ms.is_finite() { config.ppm_ebu_attack_ms } else { 10.0 };
|
||||
let ebu_decay = if config.ppm_ebu_decay_db_per_s.is_finite() { config.ppm_ebu_decay_db_per_s } else { 24.0 / 2.8 };
|
||||
config.ppm_din_attack_ms = din_attack_ms.clamp(0.1, 100.0);
|
||||
config.ppm_din_decay_db_per_s = din_decay.clamp(0.1, 120.0);
|
||||
config.ppm_din_fast_attack = !!config.ppm_din_fast_attack;
|
||||
config.ppm_ebu_attack_ms = ebu_attack_ms.clamp(0.1, 100.0);
|
||||
config.ppm_ebu_decay_db_per_s = ebu_decay.clamp(0.1, 120.0);
|
||||
config.lufs_i_window_min = config.lufs_i_window_min.clamp(1, 10);
|
||||
config.lufs_i_norm_enabled = !!config.lufs_i_norm_enabled;
|
||||
config
|
||||
}
|
||||
|
||||
fn normalize_ui_color(input: String, fallback: &str) -> String {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
fallback.to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_meter_slot(input: String, fallback: &str) -> String {
|
||||
match input.trim().to_ascii_lowercase().as_str() {
|
||||
"vu" => "vu".to_string(),
|
||||
"ppm-ebu" | "ppm_ebu" | "ppm" => "ppm-ebu".to_string(),
|
||||
"ppm-din" | "ppm_din" => "ppm-din".to_string(),
|
||||
"tp" => "tp".to_string(),
|
||||
"hifi-peak" | "hifi_peak" => "hifi-peak".to_string(),
|
||||
"rms" => "rms".to_string(),
|
||||
"lufs" => "lufs".to_string(),
|
||||
"stopwatch" => "stopwatch".to_string(),
|
||||
_ => fallback.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_global_config(mut config: PhoenixGlobalConfig, runtime: &PhoenixConfig) -> PhoenixGlobalConfig {
|
||||
config.fft_size = match config.fft_size {
|
||||
2048 | 4096 | 8192 | 16384 => config.fft_size,
|
||||
n if n < 3072 => 2048,
|
||||
n if n < 6144 => 4096,
|
||||
n if n < 12288 => 8192,
|
||||
_ => 16384,
|
||||
};
|
||||
config.input_source = InputSource::Line;
|
||||
config.rta_bpo_mode = match config.rta_bpo_mode.trim().replace('/', "_").as_str() {
|
||||
"1_3" => "1_3".to_string(),
|
||||
"1_12" => "1_12".to_string(),
|
||||
_ => "1_6".to_string(),
|
||||
};
|
||||
let offset_l = if config.input_offset_db_l.is_finite() { config.input_offset_db_l } else { -5.0 };
|
||||
let offset_r = if config.input_offset_db_r.is_finite() { config.input_offset_db_r } else { -5.0 };
|
||||
config.input_offset_db_l = offset_l.clamp(-10.0, 10.0);
|
||||
config.input_offset_db_r = offset_r.clamp(-10.0, 10.0);
|
||||
config.mono_input = !!config.mono_input;
|
||||
config.lr_fractional_delay_enabled = !!config.lr_fractional_delay_enabled;
|
||||
let delay = if config.lr_fractional_delay_samples.is_finite() {
|
||||
config.lr_fractional_delay_samples
|
||||
} else {
|
||||
runtime.lr_fractional_delay_samples
|
||||
};
|
||||
config.lr_fractional_delay_samples = delay.clamp(-1.5, 1.5);
|
||||
config.al_markers_enabled = !!config.al_markers_enabled;
|
||||
let thin = if config.meter_bar_thin.is_finite() { config.meter_bar_thin } else { 0.55 };
|
||||
config.meter_bar_thin = thin.clamp(0.35, 0.9);
|
||||
config.panel_dividers_enabled = !!config.panel_dividers_enabled;
|
||||
let din_attack_ms = if config.ppm_din_attack_ms.is_finite() { config.ppm_din_attack_ms } else { 5.0 };
|
||||
let din_decay = if config.ppm_din_decay_db_per_s.is_finite() { config.ppm_din_decay_db_per_s } else { 20.0 / 1.7 };
|
||||
let ebu_attack_ms = if config.ppm_ebu_attack_ms.is_finite() { config.ppm_ebu_attack_ms } else { 10.0 };
|
||||
let ebu_decay = if config.ppm_ebu_decay_db_per_s.is_finite() { config.ppm_ebu_decay_db_per_s } else { 24.0 / 2.8 };
|
||||
config.ppm_din_attack_ms = din_attack_ms.clamp(0.1, 100.0);
|
||||
config.ppm_din_decay_db_per_s = din_decay.clamp(0.1, 120.0);
|
||||
config.ppm_din_fast_attack = !!config.ppm_din_fast_attack;
|
||||
config.ppm_ebu_attack_ms = ebu_attack_ms.clamp(0.1, 100.0);
|
||||
config.ppm_ebu_decay_db_per_s = ebu_decay.clamp(0.1, 120.0);
|
||||
config.lufs_i_window_min = config.lufs_i_window_min.clamp(1, 10);
|
||||
config.lufs_i_norm_enabled = !!config.lufs_i_norm_enabled;
|
||||
config.ppm_din_loudness_boxes = !!config.ppm_din_loudness_boxes;
|
||||
let loudness_offset = if config.ppm_din_loudness_offset_db.is_finite() { config.ppm_din_loudness_offset_db } else { 0.0 };
|
||||
config.ppm_din_loudness_offset_db = loudness_offset.clamp(-7.0, 7.0);
|
||||
config.xy_points = match config.xy_points {
|
||||
128 | 256 | 512 | 1024 | 2048 => config.xy_points,
|
||||
n if n < 192 => 128,
|
||||
n if n < 384 => 256,
|
||||
n if n < 768 => 512,
|
||||
n if n < 1536 => 1024,
|
||||
_ => 2048,
|
||||
};
|
||||
let gonio_gain = if config.gonio_display_gain_db.is_finite() { config.gonio_display_gain_db } else { -5.0 };
|
||||
config.gonio_display_gain_db = gonio_gain.clamp(-35.0, 35.0);
|
||||
config.record_output_format = match config.record_output_format.trim().to_ascii_lowercase().as_str() {
|
||||
"mp3" => "mp3".to_string(),
|
||||
"webm" => "webm".to_string(),
|
||||
_ => "wav".to_string(),
|
||||
};
|
||||
config.record_mp3_bitrate_kbps = match config.record_mp3_bitrate_kbps {
|
||||
64 | 128 | 192 | 256 | 320 => config.record_mp3_bitrate_kbps,
|
||||
n if n < 96 => 64,
|
||||
n if n < 160 => 128,
|
||||
n if n < 224 => 192,
|
||||
n if n < 288 => 256,
|
||||
_ => 320,
|
||||
};
|
||||
config.record_target = match config.record_target.trim().to_ascii_lowercase().as_str() {
|
||||
"volumio" => "volumio".to_string(),
|
||||
_ => "analyzer".to_string(),
|
||||
};
|
||||
let auto_gap = if config.record_auto_split_gap_sec.is_finite() { config.record_auto_split_gap_sec } else { 1.5 };
|
||||
config.record_auto_split_gap_sec = (auto_gap.clamp(0.5, 3.0) * 2.0).round() / 2.0;
|
||||
let auto_threshold = if config.record_auto_threshold_dbfs.is_finite() { config.record_auto_threshold_dbfs } else { -50.0 };
|
||||
config.record_auto_threshold_dbfs = auto_threshold.clamp(-120.0, 20.0);
|
||||
config.clock_led_color = normalize_ui_color(config.clock_led_color, "#ff0000");
|
||||
config.header_text_color = normalize_ui_color(config.header_text_color, "#ffe066");
|
||||
config.rta_bar_base_color = normalize_ui_color(config.rta_bar_base_color, "#ffe066");
|
||||
config.peak_history_scroll_mode = match config.peak_history_scroll_mode {
|
||||
x if !x.is_finite() => 1.0,
|
||||
x if (x - 0.5).abs() < f32::EPSILON => 0.5,
|
||||
x if (x - 1.0).abs() < f32::EPSILON => 1.0,
|
||||
x if (x - 2.0).abs() < f32::EPSILON => 2.0,
|
||||
x if (x - 4.0).abs() < f32::EPSILON => 4.0,
|
||||
x if (x - 6.0).abs() < f32::EPSILON => 6.0,
|
||||
x if x < 0.75 => 0.5,
|
||||
x if x < 1.5 => 1.0,
|
||||
x if x < 3.0 => 2.0,
|
||||
x if x < 5.0 => 4.0,
|
||||
_ => 6.0,
|
||||
};
|
||||
config.peak_history_fill_enabled = !!config.peak_history_fill_enabled;
|
||||
config.peak_history_fill_invert = !!config.peak_history_fill_invert;
|
||||
config.peak_history_slot = normalize_meter_slot(config.peak_history_slot, "ppm-din");
|
||||
let phase_gain = if config.phase_display_gain_db.is_finite() { config.phase_display_gain_db } else { 0.0 };
|
||||
config.phase_display_gain_db = (phase_gain.clamp(-35.0, 35.0) / 5.0).round() * 5.0;
|
||||
config.phase_amplitude_mode = match config.phase_amplitude_mode.trim().to_ascii_lowercase().as_str() {
|
||||
"ppm-din" => "ppm-din".to_string(),
|
||||
_ => "bandpass".to_string(),
|
||||
};
|
||||
config.phase_agc_enabled = if config.phase_amplitude_mode == "ppm-din" {
|
||||
false
|
||||
} else {
|
||||
!!config.phase_agc_enabled
|
||||
};
|
||||
config.phase_trail_enabled = !!config.phase_trail_enabled;
|
||||
config.classic_needles_slot = normalize_meter_slot(config.classic_needles_slot, "vu");
|
||||
config.waveform_color_left = normalize_ui_color(config.waveform_color_left, "#00e7ff");
|
||||
config.waveform_color_right = normalize_ui_color(config.waveform_color_right, "#ff6b81");
|
||||
config.waveform_color_diff = normalize_ui_color(config.waveform_color_diff, "#00e7ff");
|
||||
config.vu_color_normal = normalize_ui_color(config.vu_color_normal, "#ffe066");
|
||||
config.vu_color_warn = normalize_ui_color(config.vu_color_warn, "#ff3b3b");
|
||||
config.ppm_din_color_normal = normalize_ui_color(config.ppm_din_color_normal, "#ffe066");
|
||||
config.ppm_din_color_warn = normalize_ui_color(config.ppm_din_color_warn, "#ff3b3b");
|
||||
config.ppm_ebu_color_normal = normalize_ui_color(config.ppm_ebu_color_normal, "#ffe066");
|
||||
config.ppm_ebu_color_warn = normalize_ui_color(config.ppm_ebu_color_warn, "#ff3b3b");
|
||||
config.tp_color_normal = normalize_ui_color(config.tp_color_normal, "#ffe066");
|
||||
config.tp_color_warn = normalize_ui_color(config.tp_color_warn, "#ff3b3b");
|
||||
config.rms_color_normal = normalize_ui_color(config.rms_color_normal, "#34d399");
|
||||
config.rms_color_warn = normalize_ui_color(config.rms_color_warn, "#ff3b3b");
|
||||
config.lufs_color_i = normalize_ui_color(config.lufs_color_i, "#34d399");
|
||||
config.lufs_color_m = normalize_ui_color(config.lufs_color_m, "#ffe066");
|
||||
config.lufs_color_s = normalize_ui_color(config.lufs_color_s, "#ff3b3b");
|
||||
config.lufs_scale_label_color = normalize_ui_color(config.lufs_scale_label_color, "#8fd3d4");
|
||||
config.screensaver_enabled = !!config.screensaver_enabled;
|
||||
let idle = if config.screensaver_idle_min.is_finite() { config.screensaver_idle_min } else { 5.0 };
|
||||
config.screensaver_idle_min = idle.clamp(0.0, 120.0);
|
||||
let activity = if config.screensaver_activity_db.is_finite() { config.screensaver_activity_db } else { -50.0 };
|
||||
config.screensaver_activity_db = activity.clamp(-120.0, 0.0);
|
||||
config.screensaver_mode = match config.screensaver_mode.trim().to_ascii_lowercase().as_str() {
|
||||
"starfield" => "starfield".to_string(),
|
||||
"black" => "black".to_string(),
|
||||
"dvd" => "dvd".to_string(),
|
||||
_ => "clock".to_string(),
|
||||
};
|
||||
config.screensaver_led_glow = !!config.screensaver_led_glow;
|
||||
let color = config.screensaver_led_color.trim();
|
||||
config.screensaver_led_color = if color.is_empty() { "#ff0000".to_string() } else { color.to_string() };
|
||||
config
|
||||
}
|
||||
|
||||
fn apply_global_to_rta(mut config: PhoenixRtaConfig, global: &PhoenixGlobalConfig) -> PhoenixRtaConfig {
|
||||
config.fft_size = global.fft_size;
|
||||
config.bpo = global.rta_bpo_mode.clone();
|
||||
config.input_offset_db_l = global.input_offset_db_l;
|
||||
config.input_offset_db_r = global.input_offset_db_r;
|
||||
config.mono_input = global.mono_input;
|
||||
config.lr_fractional_delay_enabled = global.lr_fractional_delay_enabled;
|
||||
config.lr_fractional_delay_samples = global.lr_fractional_delay_samples;
|
||||
config.ppm_din_attack_ms = global.ppm_din_attack_ms;
|
||||
config.ppm_din_decay_db_per_s = global.ppm_din_decay_db_per_s;
|
||||
config.ppm_din_fast_attack = global.ppm_din_fast_attack;
|
||||
config.ppm_ebu_attack_ms = global.ppm_ebu_attack_ms;
|
||||
config.ppm_ebu_decay_db_per_s = global.ppm_ebu_decay_db_per_s;
|
||||
config.lufs_i_window_min = global.lufs_i_window_min;
|
||||
config.lufs_i_norm_enabled = global.lufs_i_norm_enabled;
|
||||
normalize_rta_config(config)
|
||||
}
|
||||
|
||||
fn load_global_config(runtime: &PhoenixConfig) -> PhoenixGlobalConfig {
|
||||
let fallback = normalize_global_config(runtime.default_global_config(), runtime);
|
||||
let Ok(raw) = std::fs::read_to_string(&runtime.global_config_path) else {
|
||||
return fallback;
|
||||
};
|
||||
let Ok(parsed) = serde_json::from_str::<PhoenixGlobalConfig>(&raw) else {
|
||||
return fallback;
|
||||
};
|
||||
normalize_global_config(parsed, runtime)
|
||||
}
|
||||
|
||||
async fn persist_global_config(runtime: &PhoenixConfig, config: &PhoenixGlobalConfig) -> anyhow::Result<()> {
|
||||
let path = runtime.global_config_path.clone();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.with_context(|| format!("failed to create Phoenix config dir {}", parent.display()))?;
|
||||
}
|
||||
let json = serde_json::to_vec_pretty(config)?;
|
||||
fs::write(&path, json)
|
||||
.await
|
||||
.with_context(|| format!("failed to write Phoenix config {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Aktiviert den bestehenden Tab mit Ziel-URL (ohne Reload). Falls nicht offen, wird er geöffnet.
|
||||
function activateOrCreate(url) {
|
||||
chrome.tabs.query({}, tabs => {
|
||||
const t = tabs.find(tt => tt.url && tt.url.startsWith(url));
|
||||
if (t) { chrome.tabs.update(t.id, {active: true}); }
|
||||
else { chrome.tabs.create({url}); }
|
||||
});
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
||||
if (msg && msg.cmd === 'toggle') {
|
||||
if (msg.from === 'volumio') activateOrCreate('http://localhost:8088/');
|
||||
else activateOrCreate('http://localhost:3000/');
|
||||
sendResponse({ok:true});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
(function(){try{const e="3000"===location.port,t=document.createElement("button");function o(){chrome.runtime.sendMessage({cmd:"toggle",from:e?"volumio":"analyzer"},(()=>{}))}t.textContent=e?"Analyzer":"Volumio",Object.assign(t.style,{position:"fixed",right:"10px",top:"10px",zIndex:999999,font:"bold 14px ui-monospace,monospace",padding:"6px 10px",background:"#0b0b12",color:"#ddd",border:"1px solid #333",borderRadius:"8px",cursor:"pointer",opacity:"0.85"}),t.onmouseenter=()=>t.style.opacity="1",t.onmouseleave=()=>t.style.opacity="0.85",t.onclick=o,document.body.appendChild(t),window.addEventListener("keydown",(t=>{"t"===t.key.toLowerCase()&&(t.preventDefault(),o())}),{capture:!0})}catch(e){}})();
|
||||
@@ -0,0 +1,7 @@
|
||||
{ "manifest_version":3, "name":"DualTab Switch", "version":"1.2.0",
|
||||
"description":"Wechselt zwischen Volumio (3000) und Analyzer (8088) ohne Reload.",
|
||||
"permissions":["tabs"],
|
||||
"host_permissions":[ "http://localhost:3000/*", "http://localhost:8088/*", "http://127.0.0.1:3000/*", "http://127.0.0.1:8088/*" ],
|
||||
"background":{"service_worker":"bg.js"},
|
||||
"content_scripts":[{"matches":["http://localhost:3000/*","http://localhost:8088/*","http://127.0.0.1:3000/*","http://127.0.0.1:8088/*"],"js":["content.js"],"run_at":"document_end"}],
|
||||
"action":{"default_title":"DualTab Switch"} }
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 187.09 82.68"><defs><style>.cls-1{fill:#00feff;}</style></defs><title>08dvdlogo-</title><g id="Calque_2" data-name="Calque 2"><g id="Layer_1" data-name="Layer 1"><path class="cls-1" d="M128.81,10.16H147S169,9,168.45,20.32c-.87,17.47-27.65,16.22-27.65,16.22L146,13.83h-18.2L120.2,46.7h18.06s18,.8,32.88-6.35c15.8-7.62,15.94-21,15.94-21a15.3,15.3,0,0,0-7.76-13.4C170,.42,157.87,0,157.87,0H118.09L94.53,30.62,84.65,0H16.08L13.54,10.16h18.2S53.75,9,53.19,20.32c-.87,17.47-27.65,16.22-27.65,16.22l5.22-22.71H12.56L4.94,46.7H23s18,.8,32.87-6.35c15.8-7.62,15.94-21,15.94-21a35,35,0,0,0-.7-5.5c-.43-1.41-1-3.67-1-3.67H71L87.76,57.28l41.05-47.12Z"/><path class="cls-1" d="M88.32,57.28C39.54,57.28,0,63,0,70s39.54,12.7,88.32,12.7S176.64,77,176.64,70,137.1,57.28,88.32,57.28ZM45.54,76.92H41.82L34.06,63.73h5.21l4.46,8,4.48-8h5.22Zm20.93,0h-4.8V63.73h4.8Zm17,0h-6.8V63.73h6.8c5.15,0,9.38,2.89,9.38,6.59S88.58,76.92,83.46,76.92Zm29.16-10.28h-5.7v2.2h5.41v2.9h-5.41V74h5.7v2.9h-10.5V63.73h10.5Zm19.29,10.72c-5.93,0-10.21-3-10.21-7.28,0-4,4.89-6.78,10.21-6.78s10.21,2.79,10.21,6.78C142.12,74.35,137.83,77.36,131.91,77.36Z"/><path class="cls-1" d="M131.91,66.62c2.86,0,5.21,1.66,5.21,3.48,0,2.27-2.35,3.93-5.21,3.93s-5.22-1.66-5.22-3.93c0-1.82,2.35-3.48,5.22-3.48Z"/><path class="cls-1" d="M82.58,66.64H81.45V74h1.08c2.87,0,5.32-1.12,5.32-3.69C87.85,68,85.67,66.64,82.58,66.64Z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,920 @@
|
||||
// core/audio.js — Phoenix Audio-Init, Metrics-WS, Waveform/Spectrogram buffers
|
||||
// Wird von main.js mit `initAudio(env)` gestartet. Nutzt env.config (CONFIG) und env.meters.
|
||||
|
||||
import { applyPhoenixGlobalConfig, buildPhoenixGlobalConfigPayload, saveConfig } from './config.js';
|
||||
import { getRtwCenters } from './rtw_centers.js';
|
||||
|
||||
// interner Zustand (pro App-Instanz)
|
||||
let phoenixSocket = null;
|
||||
let envRef = null;
|
||||
let lifecycleHandlersBound = false;
|
||||
let recoverTimer = null;
|
||||
let lastHardRecoverAt = 0;
|
||||
|
||||
const RMS_RING = { L: new Float32Array(512), R: new Float32Array(512), i: 0, n: 0 };
|
||||
const WAVEFORM_RING_SECONDS = 20;
|
||||
const WAVEFORM_FALLBACK_SECONDS = 18;
|
||||
const WAVE_ENV_COLUMNS_PER_SEC = 9600;
|
||||
const PHOENIX_CONNECT_TIMEOUT_MS = 3000;
|
||||
|
||||
function isLoopbackHost(host) {
|
||||
const h = String(host || '').trim().toLowerCase();
|
||||
return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '[::1]';
|
||||
}
|
||||
|
||||
function defaultPhoenixBaseUrl() {
|
||||
try {
|
||||
const host = String(globalThis?.location?.hostname || '').trim();
|
||||
if (host) return `http://${host}:8789`;
|
||||
} catch (_) {}
|
||||
return 'http://127.0.0.1:8789';
|
||||
}
|
||||
|
||||
function normalizePhoenixBaseUrl(rawValue) {
|
||||
const fallback = defaultPhoenixBaseUrl();
|
||||
let value = String(rawValue || '').trim();
|
||||
if (!value) value = fallback;
|
||||
if (!/^[a-z]+:\/\//i.test(value)) value = `http://${value}`;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
const currentHost = String(globalThis?.location?.hostname || '').trim();
|
||||
if (currentHost && !isLoopbackHost(currentHost) && isLoopbackHost(url.hostname)) {
|
||||
url.hostname = currentHost;
|
||||
}
|
||||
url.pathname = '';
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
return url.toString().replace(/\/$/, '');
|
||||
} catch (_) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function buildPhoenixWsUrl(baseUrl) {
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
url.pathname = '/api/v1/metrics/ws';
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
return url.toString();
|
||||
} catch (_) {
|
||||
const fallback = defaultPhoenixBaseUrl();
|
||||
return fallback.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:') + '/api/v1/metrics/ws';
|
||||
}
|
||||
}
|
||||
|
||||
function closePhoenixSocket() {
|
||||
if (!phoenixSocket) return;
|
||||
try {
|
||||
phoenixSocket.onopen = null;
|
||||
phoenixSocket.onmessage = null;
|
||||
phoenixSocket.onerror = null;
|
||||
phoenixSocket.onclose = null;
|
||||
phoenixSocket.close();
|
||||
} catch (_) {}
|
||||
phoenixSocket = null;
|
||||
}
|
||||
|
||||
async function requestPhoenixRtaConfig(baseUrl, config) {
|
||||
const normalizedBase = normalizePhoenixBaseUrl(baseUrl);
|
||||
const url = `${normalizedBase}/api/v1/rta-config`;
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(config || {}),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Phoenix RTA config update failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestPhoenixGlobalConfig(baseUrl) {
|
||||
const normalizedBase = normalizePhoenixBaseUrl(baseUrl);
|
||||
const url = `${normalizedBase}/api/v1/global-config`;
|
||||
const res = await fetch(url, { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error(`Phoenix global config failed: ${res.status}`);
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async function requestPhoenixGlobalConfigUpdate(baseUrl, config) {
|
||||
const normalizedBase = normalizePhoenixBaseUrl(baseUrl);
|
||||
const url = `${normalizedBase}/api/v1/global-config`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(config || {}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Phoenix global config update failed: ${res.status}`);
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async function requestPhoenixWavCaptureStart(baseUrl, sessionId) {
|
||||
const normalizedBase = normalizePhoenixBaseUrl(baseUrl);
|
||||
const url = `${normalizedBase}/api/v1/recordings/wav/start/${encodeURIComponent(Number(sessionId) || 0)}`;
|
||||
const res = await fetch(url, { method: 'POST', cache: 'no-store' });
|
||||
if (!res.ok) throw new Error(`Phoenix WAV start failed: ${res.status}`);
|
||||
}
|
||||
|
||||
async function requestPhoenixWavCaptureStop(baseUrl, sessionId, format = 'wav', options = {}) {
|
||||
const normalizedBase = normalizePhoenixBaseUrl(baseUrl);
|
||||
const rawFormat = String(format || '').toLowerCase();
|
||||
const normalizedFormat = rawFormat === 'mp3' ? 'mp3' : (rawFormat === 'webm' ? 'webm' : 'wav');
|
||||
const url = new URL(`${normalizedBase}/api/v1/recordings/stop/${encodeURIComponent(Number(sessionId) || 0)}/${encodeURIComponent(normalizedFormat)}`);
|
||||
if (normalizedFormat === 'mp3') {
|
||||
const bitrate = Number(options?.mp3BitrateKbps);
|
||||
if (Number.isFinite(bitrate) && bitrate > 0) {
|
||||
url.searchParams.set('bitrate_kbps', String(Math.round(bitrate)));
|
||||
}
|
||||
}
|
||||
const res = await fetch(url.toString(), { method: 'POST', cache: 'no-store' });
|
||||
if (!res.ok) {
|
||||
let detail = '';
|
||||
try {
|
||||
const payload = await res.json();
|
||||
detail = String(payload?.error || '').trim();
|
||||
} catch (_) {}
|
||||
throw new Error(detail || `Phoenix capture stop failed: ${res.status}`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
return {
|
||||
blob,
|
||||
mimeType: blob.type || 'audio/wav',
|
||||
};
|
||||
}
|
||||
|
||||
function applyRmsActivity(env, rmsL, rmsR, sampleTs) {
|
||||
if (!env?.audio) return;
|
||||
const level = Math.max(
|
||||
Number.isFinite(rmsL) ? rmsL : -120,
|
||||
Number.isFinite(rmsR) ? rmsR : -120,
|
||||
);
|
||||
env.audio.rmsDb = {
|
||||
L: Number.isFinite(rmsL) ? rmsL : -120,
|
||||
R: Number.isFinite(rmsR) ? rmsR : -120,
|
||||
};
|
||||
const linL = Math.pow(10, (env.audio.rmsDb.L || -120) / 20);
|
||||
const linR = Math.pow(10, (env.audio.rmsDb.R || -120) / 20);
|
||||
const monoLin = Math.sqrt((linL * linL + linR * linR) / 2);
|
||||
env.audio.rmsDb.mono = 20 * Math.log10(Math.max(monoLin, 1e-12));
|
||||
const hit = env.screensaver?.markAudioActivity?.(level, sampleTs);
|
||||
if (hit) env.audio.lastSignalTs = sampleTs;
|
||||
}
|
||||
|
||||
function copyPhoenixSpectroBins(audioState, spectro) {
|
||||
if (!audioState) return;
|
||||
const src = Array.isArray(spectro?.bins) ? spectro.bins : null;
|
||||
if (!src || !src.length) {
|
||||
audioState.phoenixSpectroBuffer = null;
|
||||
audioState.phoenixSpectroMeta = null;
|
||||
return;
|
||||
}
|
||||
let target = audioState.phoenixSpectroBuffer;
|
||||
if (!(target instanceof Float32Array) || target.length !== src.length) {
|
||||
target = new Float32Array(src.length);
|
||||
audioState.phoenixSpectroBuffer = target;
|
||||
}
|
||||
for (let i = 0; i < src.length; i++) {
|
||||
const v = Number(src[i]);
|
||||
target[i] = Number.isFinite(v) ? v : -160;
|
||||
}
|
||||
const sampleRate = Number(spectro?.sampleRate) || audioState.sampleRate || 48000;
|
||||
audioState.sampleRate = sampleRate;
|
||||
audioState.nyq = sampleRate / 2;
|
||||
const fftSize = Number(spectro?.fftSize) || (target.length * 2);
|
||||
audioState.phoenixSpectroMeta = {
|
||||
sampleRate,
|
||||
fftSize,
|
||||
frequencyBinCount: target.length,
|
||||
};
|
||||
audioState.phoenixSpectroSeq = (audioState.phoenixSpectroSeq || 0) + 1;
|
||||
}
|
||||
|
||||
async function updateActiveMeters(env, packet, CONFIG) {
|
||||
try {
|
||||
const activeRaw = typeof env.getActiveMeterIds === 'function'
|
||||
? env.getActiveMeterIds()
|
||||
: env.slots?.();
|
||||
const normalized = Array.from(new Set(
|
||||
(Array.isArray(activeRaw) ? activeRaw : ['vu', 'ppm-ebu', 'tp'])
|
||||
.filter((id) => id && id !== 'none'),
|
||||
));
|
||||
const active = normalized.length ? normalized : ['vu', 'ppm-ebu', 'tp'];
|
||||
const maybePromise = env.meters.update(packet, CONFIG, active);
|
||||
if (maybePromise && typeof maybePromise.then === 'function') {
|
||||
await maybePromise;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Meter update error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncPhoenixGlobalConfig(env, revision) {
|
||||
if (!env?.audio || !Number.isFinite(revision) || revision <= 0) return;
|
||||
if ((env.audio.phoenixGlobalConfigRev || 0) >= revision) return;
|
||||
const baseUrl = normalizePhoenixBaseUrl(env.config?.PHOENIX_BASE_URL || env.audio.baseUrl);
|
||||
const payload = await requestPhoenixGlobalConfig(baseUrl);
|
||||
if (payload?.config) {
|
||||
applyPhoenixGlobalConfig(payload.config);
|
||||
saveConfig();
|
||||
try { env.syncConfigBackedSlots?.(); } catch (_) {}
|
||||
try { env.syncOptionsUI?.(); } catch (_) {}
|
||||
try { env.invalidateMeters?.(); } catch (_) {}
|
||||
}
|
||||
env.audio.phoenixGlobalConfigRev = Number(payload?.revision) || revision;
|
||||
}
|
||||
|
||||
async function applyIncomingAudioPacket(env, packet, CONFIG, sampleTs = performance.now()) {
|
||||
const d = packet || {};
|
||||
if (!env?.audio) return;
|
||||
env.requestRender?.('audio');
|
||||
env.audio.lastSampleTs = sampleTs;
|
||||
env.audio.alive = true;
|
||||
if (d.xyL && d.xyR) {
|
||||
env.audio.xyL = d.xyL;
|
||||
env.audio.xyR = d.xyR;
|
||||
env.audio.xySeq = Number.isFinite(d.seq) ? d.seq : (env.audio.xySeq || 0);
|
||||
}
|
||||
if (d.waveL && env.audio.pushWaveSamples) {
|
||||
const channelCount = d.waveChannels || (d.waveR ? 2 : 1);
|
||||
env.audio.sampleRate = d.sampleRate || env.audio.sampleRate || 48000;
|
||||
env.audio.pushWaveSamples(d.waveL, d.waveR || null, channelCount, d.sampleRate);
|
||||
}
|
||||
if (d.rta) env.audio.rtaData = d.rta;
|
||||
if (d.spectro) copyPhoenixSpectroBins(env.audio, d.spectro);
|
||||
if (typeof d.ppmDinL === 'number') env.audio.ppmDinL = d.ppmDinL;
|
||||
if (typeof d.ppmDinR === 'number') env.audio.ppmDinR = d.ppmDinR;
|
||||
if (typeof d.ppmEbuL === 'number') env.audio.ppmEbuL = d.ppmEbuL;
|
||||
if (typeof d.ppmEbuR === 'number') env.audio.ppmEbuR = d.ppmEbuR;
|
||||
if (typeof d.ppmL === 'number') env.audio.ppmL = d.ppmL;
|
||||
if (typeof d.ppmR === 'number') env.audio.ppmR = d.ppmR;
|
||||
if (typeof d.rmsL === 'number' || typeof d.rmsR === 'number') {
|
||||
applyRmsActivity(env, d.rmsL, d.rmsR, sampleTs);
|
||||
}
|
||||
if (d.waveEnv) {
|
||||
updateWaveformEnvelopeStore(env.audio, d.waveEnv);
|
||||
}
|
||||
if (Number.isFinite(d.globalConfigRev) && d.globalConfigRev > (env.audio.phoenixGlobalConfigRev || 0)) {
|
||||
try { await syncPhoenixGlobalConfig(env, d.globalConfigRev); } catch (err) {
|
||||
console.warn('Phoenix global config sync failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (d.rmsL !== undefined && d.rmsR !== undefined) {
|
||||
RMS_RING.L[RMS_RING.i] = d.rmsL;
|
||||
RMS_RING.R[RMS_RING.i] = d.rmsR;
|
||||
RMS_RING.i = (RMS_RING.i + 1) % RMS_RING.L.length;
|
||||
RMS_RING.n = Math.min(RMS_RING.L.length, RMS_RING.n + 1);
|
||||
}
|
||||
|
||||
await updateActiveMeters(env, d, CONFIG);
|
||||
}
|
||||
|
||||
function buildPhoenixMeterPacket(frame) {
|
||||
const rmsL = Number(frame?.rms_l);
|
||||
const rmsR = Number(frame?.rms_r);
|
||||
const vuL = Number(frame?.vu_l);
|
||||
const vuR = Number(frame?.vu_r);
|
||||
const tpL = Number(frame?.tp_l);
|
||||
const tpR = Number(frame?.tp_r);
|
||||
const ppmDinL = Number(frame?.ppm_din_l);
|
||||
const ppmDinR = Number(frame?.ppm_din_r);
|
||||
const ppmEbuL = Number(frame?.ppm_ebu_l);
|
||||
const ppmEbuR = Number(frame?.ppm_ebu_r);
|
||||
const lufsM = Number(frame?.lufs_m);
|
||||
const lufsS = Number(frame?.lufs_s);
|
||||
const lufsI = Number(frame?.lufs_i);
|
||||
const lra = Number(frame?.lra);
|
||||
const lufsML = Number(frame?.lufs_ml);
|
||||
const lufsMR = Number(frame?.lufs_mr);
|
||||
const lufsSL = Number(frame?.lufs_sl);
|
||||
const lufsSR = Number(frame?.lufs_sr);
|
||||
const ppmBoxL = Number(frame?.ppm_box_l);
|
||||
const ppmBoxR = Number(frame?.ppm_box_r);
|
||||
const xyL = Array.isArray(frame?.xy_l) ? frame.xy_l : null;
|
||||
const xyR = Array.isArray(frame?.xy_r) ? frame.xy_r : null;
|
||||
const waveL = Array.isArray(frame?.wave_l) && frame.wave_l.length ? frame.wave_l : null;
|
||||
const waveR = Array.isArray(frame?.wave_r) && frame.wave_r.length ? frame.wave_r : null;
|
||||
const rta = frame?.rta && typeof frame.rta === 'object' ? frame.rta : null;
|
||||
const spectro = frame?.spectro && typeof frame.spectro === 'object' ? frame.spectro : null;
|
||||
const waveEnv = frame?.wave_env && typeof frame.wave_env === 'object' ? frame.wave_env : null;
|
||||
return {
|
||||
sampleRate: 48000,
|
||||
rmsL: Number.isFinite(rmsL) ? rmsL : -120,
|
||||
rmsR: Number.isFinite(rmsR) ? rmsR : -120,
|
||||
tpL: Number.isFinite(tpL) ? tpL : -120,
|
||||
tpR: Number.isFinite(tpR) ? tpR : -120,
|
||||
ppmDinL: Number.isFinite(ppmDinL) ? ppmDinL : -120,
|
||||
ppmDinR: Number.isFinite(ppmDinR) ? ppmDinR : -120,
|
||||
ppmEbuL: Number.isFinite(ppmEbuL) ? ppmEbuL : (Number.isFinite(ppmDinL) ? ppmDinL : -120),
|
||||
ppmEbuR: Number.isFinite(ppmEbuR) ? ppmEbuR : (Number.isFinite(ppmDinR) ? ppmDinR : -120),
|
||||
ppmL: Number.isFinite(ppmEbuL) ? ppmEbuL : (Number.isFinite(ppmDinL) ? ppmDinL : -120),
|
||||
ppmR: Number.isFinite(ppmEbuR) ? ppmEbuR : (Number.isFinite(ppmDinR) ? ppmDinR : -120),
|
||||
vuL: Number.isFinite(vuL) ? vuL : (Number.isFinite(rmsL) ? rmsL : -120),
|
||||
vuR: Number.isFinite(vuR) ? vuR : (Number.isFinite(rmsR) ? rmsR : -120),
|
||||
lufsM: Number.isFinite(lufsM) ? lufsM : undefined,
|
||||
lufsS: Number.isFinite(lufsS) ? lufsS : undefined,
|
||||
lufsI: Number.isFinite(lufsI) ? lufsI : undefined,
|
||||
lra: Number.isFinite(lra) ? lra : undefined,
|
||||
lufsML: Number.isFinite(lufsML) ? lufsML : undefined,
|
||||
lufsMR: Number.isFinite(lufsMR) ? lufsMR : undefined,
|
||||
lufsSL: Number.isFinite(lufsSL) ? lufsSL : undefined,
|
||||
lufsSR: Number.isFinite(lufsSR) ? lufsSR : undefined,
|
||||
ppmBoxL: Number.isFinite(ppmBoxL) ? ppmBoxL : undefined,
|
||||
ppmBoxR: Number.isFinite(ppmBoxR) ? ppmBoxR : undefined,
|
||||
waveL,
|
||||
waveR,
|
||||
waveChannels: Number.isFinite(Number(frame?.wave_channels)) ? Number(frame.wave_channels) : (waveR ? 2 : (waveL ? 1 : 0)),
|
||||
xyL,
|
||||
xyR,
|
||||
rta: rta ? {
|
||||
engine: String(rta?.engine || 'iir'),
|
||||
bands_avg: Array.isArray(rta?.bands_avg) ? rta.bands_avg : (Array.isArray(rta?.bands) ? rta.bands : []),
|
||||
bands_peak: Array.isArray(rta?.bands_peak) ? rta.bands_peak : [],
|
||||
bands: Array.isArray(rta?.bands) ? rta.bands : (Array.isArray(rta?.bands_avg) ? rta.bands_avg : []),
|
||||
centers: Array.isArray(rta?.centers) ? rta.centers : [],
|
||||
freqMin: Number.isFinite(Number(rta?.freq_min)) ? Number(rta.freq_min) : 20,
|
||||
freqMax: Number.isFinite(Number(rta?.freq_max)) ? Number(rta.freq_max) : 20000,
|
||||
bpo: String(rta?.bpo || '1_6'),
|
||||
weighting: String(rta?.weighting || 'z'),
|
||||
layout: String(rta?.layout || 'rtw'),
|
||||
sampleRate: Number.isFinite(Number(rta?.sample_rate)) ? Number(rta.sample_rate) : 48000,
|
||||
} : null,
|
||||
spectro: spectro ? {
|
||||
bins: Array.isArray(spectro?.bins) ? spectro.bins : [],
|
||||
sampleRate: Number.isFinite(Number(spectro?.sample_rate)) ? Number(spectro.sample_rate) : 48000,
|
||||
fftSize: Number.isFinite(Number(spectro?.fft_size)) ? Number(spectro.fft_size) : 4096,
|
||||
} : null,
|
||||
waveEnv: waveEnv ? {
|
||||
data: Array.isArray(waveEnv?.data) ? waveEnv.data : [],
|
||||
columns: Number.isFinite(Number(waveEnv?.columns)) ? Number(waveEnv.columns) : 0,
|
||||
channels: Number.isFinite(Number(waveEnv?.channels)) ? Number(waveEnv.channels) : 1,
|
||||
columnSamples: Number.isFinite(Number(waveEnv?.column_samples)) ? Number(waveEnv.column_samples) : 0,
|
||||
sampleRate: Number.isFinite(Number(waveEnv?.sample_rate)) ? Number(waveEnv.sample_rate) : 48000,
|
||||
} : null,
|
||||
source: frame?.source || 'phoenix',
|
||||
input: 'line',
|
||||
globalConfigRev: Number(frame?.global_config_rev) || 0,
|
||||
seq: Number(frame?.seq) || 0,
|
||||
timestampMs: Number(frame?.timestamp_ms) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function bindLifecycleHandlers() {
|
||||
if (lifecycleHandlersBound) return;
|
||||
|
||||
const scheduleRecover = (reason) => {
|
||||
try {
|
||||
if (recoverTimer) clearTimeout(recoverTimer);
|
||||
} catch (_) {}
|
||||
recoverTimer = setTimeout(async () => {
|
||||
recoverTimer = null;
|
||||
if (document.hidden) return;
|
||||
const env = envRef;
|
||||
if (!env || !audioLost(env)) return;
|
||||
const now = performance.now();
|
||||
if (now - lastHardRecoverAt < 5000) return;
|
||||
lastHardRecoverAt = now;
|
||||
console.warn(`Audio stalled after ${reason}; reloading audio…`);
|
||||
try { await reloadAudio(env); } catch (e) { console.warn('Audio reload failed:', e); }
|
||||
}, 150);
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) scheduleRecover('visibility');
|
||||
});
|
||||
|
||||
window.addEventListener('focus', () => {
|
||||
scheduleRecover('focus');
|
||||
});
|
||||
|
||||
lifecycleHandlersBound = true;
|
||||
}
|
||||
|
||||
function createWaveformStateFallback(sampleRate, seconds = WAVEFORM_FALLBACK_SECONDS) {
|
||||
const maxSamples = Math.max(1, Math.round(sampleRate * seconds));
|
||||
return {
|
||||
sampleRate,
|
||||
maxSamples,
|
||||
bufferL: new Float32Array(maxSamples),
|
||||
bufferR: new Float32Array(maxSamples),
|
||||
write: 0,
|
||||
length: 0,
|
||||
channels: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function resetWaveformStateFallback(state) {
|
||||
if (!state) return;
|
||||
state.write = 0;
|
||||
state.length = 0;
|
||||
state.channels = 1;
|
||||
}
|
||||
|
||||
function pushWaveformSamplesFallback(state, chunkL, chunkR, channelCount = 2) {
|
||||
if (!state || !chunkL || !chunkL.length) return;
|
||||
const len = chunkL.length;
|
||||
const cap = state.maxSamples;
|
||||
let write = state.write;
|
||||
const useRight = channelCount > 1 && chunkR && chunkR.length === len;
|
||||
for (let i = 0; i < len; i++) {
|
||||
state.bufferL[write] = chunkL[i];
|
||||
state.bufferR[write] = useRight ? chunkR[i] : chunkL[i];
|
||||
write++;
|
||||
if (write >= cap) write = 0;
|
||||
}
|
||||
state.write = write;
|
||||
state.length = Math.min(cap, state.length + len);
|
||||
state.channels = useRight ? 2 : 1;
|
||||
}
|
||||
|
||||
function copyFromRing(buffer, capacity, writeIndex, count) {
|
||||
const out = new Float32Array(count);
|
||||
let start = writeIndex - count;
|
||||
if (start < 0) start += capacity;
|
||||
if (start + count <= capacity) {
|
||||
out.set(buffer.subarray(start, start + count), 0);
|
||||
} else {
|
||||
const first = capacity - start;
|
||||
out.set(buffer.subarray(start), 0);
|
||||
out.set(buffer.subarray(0, count - first), first);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function getWaveformSamplesFallback(state, requested) {
|
||||
if (!state || !state.length) return null;
|
||||
const count = Math.max(0, Math.min(state.length, Math.round(requested)));
|
||||
if (count <= 0) return null;
|
||||
return {
|
||||
sampleRate: state.sampleRate,
|
||||
L: copyFromRing(state.bufferL, state.maxSamples, state.write, count),
|
||||
R: state.channels > 1 ? copyFromRing(state.bufferR, state.maxSamples, state.write, count) : null,
|
||||
channels: state.channels,
|
||||
};
|
||||
}
|
||||
|
||||
function createWaveformEnvelopeStore(sampleRate, columnSamples) {
|
||||
const samplesPerColumn = Math.max(1, Math.round(columnSamples));
|
||||
const sr = Math.max(8000, Math.round(sampleRate) || 48000);
|
||||
const columnsPerSecond = sr / samplesPerColumn;
|
||||
const maxColumns = Math.max(
|
||||
64,
|
||||
Math.ceil(columnsPerSecond * WAVEFORM_RING_SECONDS) + 32,
|
||||
);
|
||||
return {
|
||||
minL: new Float32Array(maxColumns),
|
||||
maxL: new Float32Array(maxColumns),
|
||||
minR: new Float32Array(maxColumns),
|
||||
maxR: new Float32Array(maxColumns),
|
||||
write: 0,
|
||||
length: 0,
|
||||
capacity: maxColumns,
|
||||
sampleRate: sr,
|
||||
columnSamples: samplesPerColumn,
|
||||
columnsPerSecond,
|
||||
channels: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureWaveformEnvelopeStore(store, sampleRate, columnSamples) {
|
||||
const samplesPerColumn = Math.max(1, Math.round(columnSamples));
|
||||
const sr = Math.max(8000, Math.round(sampleRate) || 48000);
|
||||
const columnsPerSecond = sr / samplesPerColumn;
|
||||
const required = Math.max(
|
||||
64,
|
||||
Math.ceil(columnsPerSecond * WAVEFORM_RING_SECONDS) + 32,
|
||||
);
|
||||
if (!store || store.capacity < required) {
|
||||
return createWaveformEnvelopeStore(sr, samplesPerColumn);
|
||||
}
|
||||
store.sampleRate = sr;
|
||||
store.columnSamples = samplesPerColumn;
|
||||
store.columnsPerSecond = columnsPerSecond;
|
||||
if (store.length > store.capacity) {
|
||||
store.length = store.capacity;
|
||||
store.write = store.length % store.capacity;
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
function appendWaveformEnvelope(store, payload) {
|
||||
if (!store || !payload || !payload.data) return;
|
||||
const data = payload.data;
|
||||
const columns = Math.max(0, Math.min(payload.columns || 0, Math.floor(data.length / (payload.channels > 1 ? 4 : 2))));
|
||||
if (!columns) return;
|
||||
store.channels = payload.channels > 1 ? 2 : 1;
|
||||
const stride = store.channels > 1 ? 4 : 2;
|
||||
const cap = store.capacity;
|
||||
for (let i = 0; i < columns; i++) {
|
||||
const base = i * stride;
|
||||
const writeIndex = store.write;
|
||||
store.minL[writeIndex] = data[base];
|
||||
store.maxL[writeIndex] = data[base + 1];
|
||||
if (store.channels > 1) {
|
||||
store.minR[writeIndex] = data[base + 2];
|
||||
store.maxR[writeIndex] = data[base + 3];
|
||||
} else {
|
||||
store.minR[writeIndex] = data[base];
|
||||
store.maxR[writeIndex] = data[base + 1];
|
||||
}
|
||||
store.write = (store.write + 1) % cap;
|
||||
store.length = Math.min(cap, store.length + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function createWaveformScratch(width) {
|
||||
const size = Math.max(1, width);
|
||||
return {
|
||||
widthCapacity: size,
|
||||
minMaxL: new Float32Array(size * 2),
|
||||
minMaxR: new Float32Array(size * 2),
|
||||
tmpMinL: new Float32Array(size),
|
||||
tmpMaxL: new Float32Array(size),
|
||||
tmpMinR: new Float32Array(size),
|
||||
tmpMaxR: new Float32Array(size),
|
||||
};
|
||||
}
|
||||
|
||||
function ensureWaveformScratch(scratch, width) {
|
||||
if (!scratch || scratch.widthCapacity < width) {
|
||||
return createWaveformScratch(width);
|
||||
}
|
||||
return scratch;
|
||||
}
|
||||
|
||||
function sampleWaveformEnvelope(store, pixelWidth, windowSec, scratch) {
|
||||
if (!store || !store.length) return null;
|
||||
const width = Math.max(1, Math.floor(pixelWidth));
|
||||
const seconds = Math.max(0.01, Number(windowSec) || 1);
|
||||
const neededColumns = Math.max(1, Math.round(seconds * store.columnsPerSecond));
|
||||
const available = Math.min(store.length, neededColumns);
|
||||
if (available <= 0) return null;
|
||||
scratch = ensureWaveformScratch(scratch, width);
|
||||
const bucketMinL = scratch.tmpMinL;
|
||||
const bucketMaxL = scratch.tmpMaxL;
|
||||
const bucketMinR = scratch.tmpMinR;
|
||||
const bucketMaxR = scratch.tmpMaxR;
|
||||
for (let i = 0; i < width; i++) {
|
||||
bucketMinL[i] = Infinity;
|
||||
bucketMaxL[i] = -Infinity;
|
||||
bucketMinR[i] = Infinity;
|
||||
bucketMaxR[i] = -Infinity;
|
||||
}
|
||||
const cap = store.capacity;
|
||||
let idx = store.write - available;
|
||||
if (idx < 0) idx += cap;
|
||||
for (let i = 0; i < available; i++) {
|
||||
const bucket = Math.min(width - 1, Math.floor(i * width / available));
|
||||
const next = (idx + i) % cap;
|
||||
const minL = store.minL[next];
|
||||
const maxL = store.maxL[next];
|
||||
bucketMinL[bucket] = Math.min(bucketMinL[bucket], minL);
|
||||
bucketMaxL[bucket] = Math.max(bucketMaxL[bucket], maxL);
|
||||
if (store.channels > 1) {
|
||||
const minR = store.minR[next];
|
||||
const maxR = store.maxR[next];
|
||||
bucketMinR[bucket] = Math.min(bucketMinR[bucket], minR);
|
||||
bucketMaxR[bucket] = Math.max(bucketMaxR[bucket], maxR);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < width; i++) {
|
||||
if (!Number.isFinite(bucketMinL[i])) {
|
||||
const fallback = i > 0 ? i - 1 : -1;
|
||||
if (fallback >= 0 && Number.isFinite(bucketMinL[fallback])) {
|
||||
bucketMinL[i] = bucketMinL[fallback];
|
||||
bucketMaxL[i] = bucketMaxL[fallback];
|
||||
bucketMinR[i] = bucketMinR[fallback];
|
||||
bucketMaxR[i] = bucketMaxR[fallback];
|
||||
} else {
|
||||
bucketMinL[i] = 0;
|
||||
bucketMaxL[i] = 0;
|
||||
bucketMinR[i] = 0;
|
||||
bucketMaxR[i] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
const outL = scratch.minMaxL;
|
||||
const outR = scratch.minMaxR;
|
||||
for (let i = 0; i < width; i++) {
|
||||
const base = i * 2;
|
||||
outL[base] = bucketMinL[i];
|
||||
outL[base + 1] = bucketMaxL[i];
|
||||
if (store.channels > 1) {
|
||||
outR[base] = bucketMinR[i];
|
||||
outR[base + 1] = bucketMaxR[i];
|
||||
}
|
||||
}
|
||||
return {
|
||||
minMaxL: outL,
|
||||
minMaxR: store.channels > 1 ? outR : null,
|
||||
width,
|
||||
channels: store.channels,
|
||||
scratch,
|
||||
};
|
||||
}
|
||||
|
||||
function updateWaveformEnvelopeStore(audioState, payload) {
|
||||
if (!audioState || !payload || !payload.data) return;
|
||||
const sr = payload.sampleRate || audioState.sampleRate || 48000;
|
||||
const columnSamples = payload.columnSamples || Math.max(8, Math.round(sr / WAVE_ENV_COLUMNS_PER_SEC));
|
||||
audioState.waveEnvStore = ensureWaveformEnvelopeStore(audioState.waveEnvStore, sr, columnSamples);
|
||||
appendWaveformEnvelope(audioState.waveEnvStore, payload);
|
||||
}
|
||||
|
||||
function buildRtaRuntimeConfig(CONFIG = {}) {
|
||||
const bpoMode = CONFIG.RTA_BPO_MODE || '1_6';
|
||||
const layout = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'rtw' : 'iec';
|
||||
const runtimeBpoMode = layout === 'rtw' ? '1_12' : bpoMode;
|
||||
return {
|
||||
engine: CONFIG.RTA_ENGINE || 'fft',
|
||||
fftSize: CONFIG.FFT_SIZE || 4096,
|
||||
monoInput: !!CONFIG.MONO_INPUT,
|
||||
lrFractionalDelayEnabled: !!CONFIG.LR_FRACTIONAL_DELAY_ENABLED,
|
||||
lrFractionalDelaySamples: Number.isFinite(CONFIG.LR_FRACTIONAL_DELAY_SAMPLES) ? CONFIG.LR_FRACTIONAL_DELAY_SAMPLES : 0,
|
||||
bpo: runtimeBpoMode,
|
||||
freqRange: CONFIG.RTA_FREQ_RANGE || 'norm',
|
||||
weighting: CONFIG.RTA_WEIGHTING || 'z',
|
||||
order: CONFIG.RTA_IIR_ORDER || 4,
|
||||
tauFast: CONFIG.RTA_IIR_TAU_FAST || 0.12,
|
||||
tauSlow: CONFIG.RTA_IIR_TAU_SLOW || 1.0,
|
||||
integration: CONFIG.RTA_INTEGRATION || 'fast',
|
||||
layout,
|
||||
inputOffsetDbL: Number.isFinite(CONFIG.INPUT_OFFSET_DB_L) ? CONFIG.INPUT_OFFSET_DB_L : -5,
|
||||
inputOffsetDbR: Number.isFinite(CONFIG.INPUT_OFFSET_DB_R) ? CONFIG.INPUT_OFFSET_DB_R : -5,
|
||||
ppmDinAttackMs: Number.isFinite(CONFIG.PPM_DIN_ATTACK_MS) ? CONFIG.PPM_DIN_ATTACK_MS : 5,
|
||||
ppmDinDecayDbPerS: Number.isFinite(CONFIG.PPM_DIN_DECAY_DB_PER_S) ? CONFIG.PPM_DIN_DECAY_DB_PER_S : (20 / 1.7),
|
||||
ppmDinFastAttack: !!CONFIG.PPM_DIN_FAST_ATTACK,
|
||||
ppmEbuAttackMs: Number.isFinite(CONFIG.PPM_EBU_ATTACK_MS) ? CONFIG.PPM_EBU_ATTACK_MS : 10,
|
||||
ppmEbuDecayDbPerS: Number.isFinite(CONFIG.PPM_EBU_DECAY_DB_PER_S) ? CONFIG.PPM_EBU_DECAY_DB_PER_S : (24 / 2.8),
|
||||
lufsIWindowMin: Number.isFinite(CONFIG.LUFS_I_WINDOW_MIN) ? CONFIG.LUFS_I_WINDOW_MIN : 4,
|
||||
lufsINormEnabled: !!CONFIG.LUFS_I_NORM_ENABLED,
|
||||
rtwCenters: layout === 'rtw' ? getRtwCenters(runtimeBpoMode) : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function initPhoenixAudio(env) {
|
||||
const CONFIG = env?.config;
|
||||
const baseUrl = normalizePhoenixBaseUrl(CONFIG?.PHOENIX_BASE_URL);
|
||||
const wsUrl = buildPhoenixWsUrl(baseUrl);
|
||||
|
||||
try {
|
||||
const payload = await requestPhoenixGlobalConfig(baseUrl);
|
||||
if (payload?.config) {
|
||||
applyPhoenixGlobalConfig(payload.config);
|
||||
saveConfig();
|
||||
try { env.syncConfigBackedSlots?.(); } catch (_) {}
|
||||
try { env.syncOptionsUI?.(); } catch (_) {}
|
||||
try { env.invalidateMeters?.(); } catch (_) {}
|
||||
}
|
||||
env.audio.phoenixGlobalConfigRev = Number(payload?.revision) || 0;
|
||||
} catch (err) {
|
||||
console.warn('Phoenix global config bootstrap failed:', err);
|
||||
env.audio.phoenixGlobalConfigRev = 0;
|
||||
}
|
||||
|
||||
env.audio.backendMode = 'phoenix';
|
||||
env.audio.backendLabel = 'Phoenix';
|
||||
env.audio.baseUrl = baseUrl;
|
||||
env.audio.sampleRate = 48000;
|
||||
env.audio.nyq = 24000;
|
||||
env.audio.alive = false;
|
||||
env.audio.lastSampleTs = 0;
|
||||
env.audio.xyL = null;
|
||||
env.audio.xyR = null;
|
||||
env.audio.xySeq = 0;
|
||||
env.audio.rtaData = null;
|
||||
env.audio.ppmDinL = undefined;
|
||||
env.audio.ppmDinR = undefined;
|
||||
env.audio.ppmEbuL = undefined;
|
||||
env.audio.ppmEbuR = undefined;
|
||||
env.audio.ppmL = undefined;
|
||||
env.audio.ppmR = undefined;
|
||||
env.audio.waveEnvStore = null;
|
||||
env.audio.waveEnvScratch = null;
|
||||
env.audio.waveformFallback = createWaveformStateFallback(env.audio.sampleRate || 48000);
|
||||
env.audio.phoenixSpectroBuffer = null;
|
||||
env.audio.phoenixSpectroScratch = null;
|
||||
env.audio.phoenixSpectroMeta = null;
|
||||
env.audio.phoenixSpectroSeq = 0;
|
||||
env.audio.rmsDb = { L: -120, R: -120, mono: -120 };
|
||||
|
||||
const phoenixAnalyser = {
|
||||
frequencyBinCount: 0,
|
||||
smoothingTimeConstant: 0,
|
||||
context: { sampleRate: 48000 },
|
||||
getFloatFrequencyData(target) {
|
||||
const src = env.audio?.phoenixSpectroBuffer;
|
||||
const len = Math.max(0, Math.min(target?.length || 0, src?.length || 0));
|
||||
if (!target || !src || !len) {
|
||||
if (target?.fill) target.fill(-160);
|
||||
return;
|
||||
}
|
||||
if (target !== src) target.fill(-160);
|
||||
for (let i = 0; i < len; i++) target[i] = src[i];
|
||||
for (let i = len; i < target.length; i++) target[i] = -160;
|
||||
},
|
||||
};
|
||||
|
||||
env.audio.getAnalyser = () => {
|
||||
const meta = env.audio?.phoenixSpectroMeta;
|
||||
const buf = env.audio?.phoenixSpectroBuffer;
|
||||
if (!meta || !buf) return null;
|
||||
phoenixAnalyser.frequencyBinCount = meta.frequencyBinCount || buf.length || 0;
|
||||
phoenixAnalyser.context.sampleRate = meta.sampleRate || env.audio.sampleRate || 48000;
|
||||
return phoenixAnalyser;
|
||||
};
|
||||
|
||||
env.audio.getFreqBuffer = () => {
|
||||
const src = env.audio?.phoenixSpectroBuffer;
|
||||
if (!src) return null;
|
||||
let scratch = env.audio?.phoenixSpectroScratch;
|
||||
if (!(scratch instanceof Float32Array) || scratch.length !== src.length) {
|
||||
scratch = new Float32Array(src.length);
|
||||
env.audio.phoenixSpectroScratch = scratch;
|
||||
}
|
||||
return scratch;
|
||||
};
|
||||
|
||||
env.audio.getWaveformSamples = (count) => getWaveformSamplesFallback(env.audio.waveformFallback, count);
|
||||
env.audio.pushWaveSamples = (chunkL, chunkR, channelCount = 2, sr) => {
|
||||
if (!env.audio.waveformFallback) {
|
||||
env.audio.waveformFallback = createWaveformStateFallback(Number(sr) || env.audio.sampleRate || 48000);
|
||||
}
|
||||
if (sr && Number.isFinite(sr)) {
|
||||
env.audio.waveformFallback.sampleRate = sr;
|
||||
env.audio.sampleRate = sr;
|
||||
}
|
||||
pushWaveformSamplesFallback(env.audio.waveformFallback, chunkL, chunkR, channelCount);
|
||||
};
|
||||
env.audio.getWaveformEnvelope = (pixelWidth, windowSec) => {
|
||||
const sample = sampleWaveformEnvelope(
|
||||
env.audio.waveEnvStore,
|
||||
pixelWidth,
|
||||
windowSec,
|
||||
env.audio.waveEnvScratch,
|
||||
);
|
||||
if (!sample) return null;
|
||||
env.audio.waveEnvScratch = sample.scratch;
|
||||
return {
|
||||
minMaxL: sample.minMaxL,
|
||||
minMaxR: sample.minMaxR,
|
||||
pixelWidth: sample.width,
|
||||
width: sample.width,
|
||||
channels: sample.channels,
|
||||
};
|
||||
};
|
||||
|
||||
env.audio.startWavCapture = async (sessionId) => {
|
||||
const id = Number(sessionId) || 0;
|
||||
if (!id) throw new Error('Ungültige WAV-Session');
|
||||
await requestPhoenixWavCaptureStart(baseUrl, id);
|
||||
};
|
||||
|
||||
env.audio.stopWavCapture = async (sessionId, format = 'wav', options = {}) => {
|
||||
const id = Number(sessionId) || 0;
|
||||
if (!id) throw new Error('Ungültige WAV-Session');
|
||||
return await requestPhoenixWavCaptureStop(baseUrl, id, format, options);
|
||||
};
|
||||
|
||||
env.audio.abortWavCaptures = () => {};
|
||||
env.audio.updateInputOffset = () => { void pushPhoenixGlobalConfig(); };
|
||||
env.audio.updateMonoMode = () => { void pushPhoenixGlobalConfig(); };
|
||||
env.audio.updateLrDelay = () => { void pushPhoenixGlobalConfig(); };
|
||||
|
||||
env.audio.updateProcessingConfig = (cfg) => {
|
||||
const rawProfile = (cfg && typeof cfg === 'object')
|
||||
? cfg
|
||||
: ((typeof env?.getProcessingProfile === 'function') ? env.getProcessingProfile() : null);
|
||||
const profile = rawProfile || {};
|
||||
if (!profile.needXy) {
|
||||
env.audio.xyL = null;
|
||||
env.audio.xyR = null;
|
||||
env.audio.xySeq = 0;
|
||||
}
|
||||
if (!profile.needRta) {
|
||||
env.audio.rtaData = null;
|
||||
}
|
||||
if (!profile.needWaveform) {
|
||||
env.audio.waveEnvStore = null;
|
||||
env.audio.waveEnvScratch = null;
|
||||
resetWaveformStateFallback(env.audio.waveformFallback);
|
||||
}
|
||||
};
|
||||
|
||||
const pushPhoenixGlobalConfig = async () => {
|
||||
const response = await requestPhoenixGlobalConfigUpdate(baseUrl, buildPhoenixGlobalConfigPayload());
|
||||
if (response?.config) {
|
||||
applyPhoenixGlobalConfig(response.config);
|
||||
saveConfig();
|
||||
try { env.syncConfigBackedSlots?.(); } catch (_) {}
|
||||
try { env.syncOptionsUI?.(); } catch (_) {}
|
||||
}
|
||||
env.audio.phoenixGlobalConfigRev = Number(response?.revision) || env.audio.phoenixGlobalConfigRev || 0;
|
||||
if (env?.audio) env.audio.rtaData = null;
|
||||
await requestPhoenixRtaConfig(baseUrl, buildRtaRuntimeConfig(CONFIG));
|
||||
};
|
||||
|
||||
const pushPhoenixRtaConfig = async () => {
|
||||
if (env?.audio) env.audio.rtaData = null;
|
||||
await requestPhoenixRtaConfig(baseUrl, buildRtaRuntimeConfig(CONFIG));
|
||||
};
|
||||
|
||||
env.audio.updateRtaConfig = pushPhoenixRtaConfig;
|
||||
env.audio.updatePpmConfig = pushPhoenixRtaConfig;
|
||||
env.audio.updatePhoenixGlobalConfig = pushPhoenixGlobalConfig;
|
||||
|
||||
await pushPhoenixRtaConfig();
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const socket = new WebSocket(wsUrl);
|
||||
phoenixSocket = socket;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try { socket.close(); } catch (_) {}
|
||||
if (phoenixSocket === socket) phoenixSocket = null;
|
||||
env.audio.alive = false;
|
||||
env.utils?.showErr?.(`Phoenix connect timeout: ${wsUrl}`);
|
||||
resolve(false);
|
||||
}, PHOENIX_CONNECT_TIMEOUT_MS);
|
||||
|
||||
const finish = (ok) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
resolve(ok);
|
||||
};
|
||||
|
||||
socket.onopen = () => {
|
||||
finish(true);
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const frame = JSON.parse(event.data);
|
||||
const packet = buildPhoenixMeterPacket(frame);
|
||||
applyIncomingAudioPacket(env, packet, CONFIG, performance.now()).catch((err) => {
|
||||
console.warn('Phoenix packet error:', err);
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Phoenix metrics parse error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
env.audio.alive = false;
|
||||
if (!settled) {
|
||||
clearTimeout(timeout);
|
||||
if (phoenixSocket === socket) phoenixSocket = null;
|
||||
env.utils?.showErr?.(`Phoenix connect error: ${wsUrl}`);
|
||||
finish(false);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
env.audio.alive = false;
|
||||
if (phoenixSocket === socket) phoenixSocket = null;
|
||||
if (!settled) {
|
||||
clearTimeout(timeout);
|
||||
env.utils?.showErr?.(`Phoenix socket closed: ${wsUrl}`);
|
||||
finish(false);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function initAudio(env) {
|
||||
try {
|
||||
envRef = env;
|
||||
bindLifecycleHandlers();
|
||||
closePhoenixSocket();
|
||||
return await initPhoenixAudio(env);
|
||||
} catch (e) {
|
||||
try { env?.audio?.abortWavCaptures?.('Audio init error'); } catch (_) {}
|
||||
env.audio.alive = false;
|
||||
env.utils?.showErr?.('Audio init error: ' + (e?.message || String(e)));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function audioLost(env) {
|
||||
const timeoutMs = 2500;
|
||||
const lost = (!env.audio.alive) || (performance.now() - (env.audio.lastSampleTs || 0)) > timeoutMs;
|
||||
return !!lost;
|
||||
}
|
||||
|
||||
export async function reloadAudio(env) {
|
||||
try {
|
||||
try { env?.audio?.abortWavCaptures?.('Audio neu initialisiert'); } catch (_) {}
|
||||
closePhoenixSocket();
|
||||
} catch (e) {
|
||||
console.warn('Audio cleanup error:', e);
|
||||
}
|
||||
return initAudio(env);
|
||||
}
|
||||
+1252
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,228 @@
|
||||
// core/registry.js — Lazy Loader + Safe Runner für Meter
|
||||
// Lädt Meter-Module on-demand (oder via registerMeter) und kapselt Fehler.
|
||||
|
||||
const cache = {
|
||||
meters: new Map(), // id -> module
|
||||
};
|
||||
|
||||
const meterRenderCache = new Map(); // key -> surface
|
||||
const METER_CACHE_MARGIN = 32;
|
||||
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;
|
||||
}
|
||||
|
||||
function cacheKeyForMeter(id, rect) {
|
||||
const x = Math.round(Number(rect?.x) || 0);
|
||||
const y = Math.round(Number(rect?.y) || 0);
|
||||
const w = Math.max(1, Math.round(Number(rect?.w) || 0));
|
||||
const h = Math.max(1, Math.round(Number(rect?.h) || 0));
|
||||
return `${id}::${x},${y},${w}x${h}`;
|
||||
}
|
||||
|
||||
function ensureCacheSurface(id, rect) {
|
||||
const key = cacheKeyForMeter(id, rect);
|
||||
let surface = meterRenderCache.get(key);
|
||||
if (!surface) {
|
||||
surface = createCacheSurface(rect.w + METER_CACHE_MARGIN * 2, rect.h + METER_CACHE_MARGIN * 2);
|
||||
if (!surface) return null;
|
||||
meterRenderCache.set(key, surface);
|
||||
}
|
||||
if (!surface.canvas || !surface.ctx) return null;
|
||||
const w = Math.max(1, Math.ceil(rect.w + METER_CACHE_MARGIN * 2));
|
||||
const h = Math.max(1, Math.ceil(rect.h + METER_CACHE_MARGIN * 2));
|
||||
if (surface.canvas.width !== w || surface.canvas.height !== h) {
|
||||
surface.canvas.width = w;
|
||||
surface.canvas.height = h;
|
||||
surface.stamp = -1;
|
||||
}
|
||||
surface.width = w;
|
||||
surface.height = h;
|
||||
surface.margin = METER_CACHE_MARGIN;
|
||||
return surface;
|
||||
}
|
||||
|
||||
export async function loadMeter(id) {
|
||||
if (!id) throw new Error('loadMeter: leere Meter-ID');
|
||||
if (cache.meters.has(id)) return cache.meters.get(id);
|
||||
const mod = await import(`../meters/${id}.js`);
|
||||
cache.meters.set(id, mod);
|
||||
return mod;
|
||||
}
|
||||
|
||||
// --- Meter-Facade -----------------------------------------------------------
|
||||
// Views rufen nur diese Fassade, kennen also die Module/States nicht direkt.
|
||||
const meterStates = new Map(); // instanceKey -> { id, shared }
|
||||
const DEFAULT_INSTANCE_SUFFIX = '::default';
|
||||
|
||||
function makeMeterInstanceKey(id, rect) {
|
||||
if (!rect) return `${id}::default`;
|
||||
const x = Math.round(Number(rect.x) || 0);
|
||||
const y = Math.round(Number(rect.y) || 0);
|
||||
const w = Math.round(Number(rect.w) || 0);
|
||||
const h = Math.round(Number(rect.h) || 0);
|
||||
return `${id}::${x},${y},${w},${h}`;
|
||||
}
|
||||
|
||||
function makeDefaultMeterInstanceKey(id) {
|
||||
return `${id}${DEFAULT_INSTANCE_SUFFIX}`;
|
||||
}
|
||||
|
||||
function getMeterSharedById(id) {
|
||||
const matches = [];
|
||||
for (const entry of meterStates.values()) {
|
||||
if (entry?.id === id && entry.shared) matches.push(entry.shared);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
function getPreferredMeterShared(id) {
|
||||
const defaultEntry = meterStates.get(makeDefaultMeterInstanceKey(id));
|
||||
if (defaultEntry?.shared) return defaultEntry.shared;
|
||||
for (const entry of meterStates.values()) {
|
||||
if (entry?.id === id && entry.shared) return entry.shared;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ensureMeter(id, config, rect) {
|
||||
const mod = await loadMeter(id);
|
||||
const instanceKey = makeMeterInstanceKey(id, rect);
|
||||
if (!meterStates.has(instanceKey)) {
|
||||
const shared = mod.initShared?.(config) || {};
|
||||
meterStates.set(instanceKey, { id, shared });
|
||||
}
|
||||
return { mod, instanceKey, shared: meterStates.get(instanceKey)?.shared || null };
|
||||
}
|
||||
|
||||
async function ensureDefaultMeterShared(id, config) {
|
||||
const mod = await loadMeter(id);
|
||||
const instanceKey = makeDefaultMeterInstanceKey(id);
|
||||
if (!meterStates.has(instanceKey)) {
|
||||
const shared = mod.initShared?.(config) || {};
|
||||
meterStates.set(instanceKey, { id, shared });
|
||||
}
|
||||
return { mod, instanceKey, shared: meterStates.get(instanceKey)?.shared || null };
|
||||
}
|
||||
|
||||
export const meterFacade = {
|
||||
async update(packet, config, activeIds) {
|
||||
const seen = new Set();
|
||||
for (const rawId of activeIds || []) {
|
||||
if (!rawId || seen.has(rawId)) continue;
|
||||
seen.add(rawId);
|
||||
const id = rawId;
|
||||
try {
|
||||
const { mod } = await ensureDefaultMeterShared(id, config);
|
||||
const sharedList = getMeterSharedById(id);
|
||||
for (const shared of sharedList) {
|
||||
mod.update?.(packet, shared);
|
||||
}
|
||||
} catch (e) {
|
||||
// ein defektes Meter wird übersprungen, andere laufen weiter
|
||||
console.warn(`Meter ${id} update error:`, e);
|
||||
}
|
||||
}
|
||||
},
|
||||
async pointer(evt, rect, id, config) {
|
||||
if (!id) return false;
|
||||
try {
|
||||
const { mod, shared } = await ensureMeter(id, config, rect);
|
||||
if (typeof mod.pointer === 'function') {
|
||||
const handled = await mod.pointer(evt, rect, config, shared);
|
||||
return !!handled;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Meter ${id} pointer error:`, e);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
async draw(ctx, rect, id, config) {
|
||||
try {
|
||||
const { mod, shared } = await ensureMeter(id, config, rect);
|
||||
const allowCache = !(mod && mod.disableCache);
|
||||
if (!allowCache) {
|
||||
await mod.draw?.(ctx, rect, config, shared);
|
||||
return;
|
||||
}
|
||||
const cacheSurface = ensureCacheSurface(id, rect);
|
||||
const stamp = activeFrameStamp;
|
||||
const margin = cacheSurface ? cacheSurface.margin || METER_CACHE_MARGIN : 0;
|
||||
const drawX = rect.x - margin;
|
||||
const drawY = rect.y - margin;
|
||||
if (cacheSurface && cacheSurface.stamp === stamp) {
|
||||
ctx.drawImage(cacheSurface.canvas, drawX, drawY);
|
||||
return;
|
||||
}
|
||||
if (cacheSurface && cacheSurface.ctx) {
|
||||
cacheSurface.ctx.save();
|
||||
try {
|
||||
cacheSurface.ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
cacheSurface.ctx.clearRect(0, 0, cacheSurface.canvas.width, cacheSurface.canvas.height);
|
||||
cacheSurface.ctx.font = ctx.font;
|
||||
cacheSurface.ctx.textAlign = ctx.textAlign;
|
||||
cacheSurface.ctx.textBaseline = ctx.textBaseline;
|
||||
cacheSurface.ctx.translate(-drawX, -drawY);
|
||||
await mod.draw?.(cacheSurface.ctx, rect, config, shared);
|
||||
} finally {
|
||||
cacheSurface.ctx.restore();
|
||||
}
|
||||
cacheSurface.stamp = stamp;
|
||||
ctx.drawImage(cacheSurface.canvas, drawX, drawY);
|
||||
return;
|
||||
}
|
||||
await mod.draw?.(ctx, rect, config, shared);
|
||||
} catch (e) {
|
||||
// Slot neutral darstellen
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(200,80,80,.8)';
|
||||
ctx.setLineDash([6,4]);
|
||||
ctx.strokeRect(rect.x+0.5, rect.y+0.5, rect.w-1, rect.h-1);
|
||||
ctx.setLineDash([]);
|
||||
ctx.fillStyle = '#ffdddd';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(`Meter "${id}" defekt`, rect.x + rect.w/2, rect.y + rect.h/2);
|
||||
ctx.textAlign = 'start';
|
||||
ctx.restore();
|
||||
}
|
||||
},
|
||||
setFrameStamp(stamp) {
|
||||
activeFrameStamp = stamp || 0;
|
||||
},
|
||||
getState(id) {
|
||||
return getPreferredMeterShared(id);
|
||||
},
|
||||
invalidateAll() {
|
||||
meterStates.clear();
|
||||
meterRenderCache.clear();
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__AN_REGISTRY__ = window.__AN_REGISTRY__ || {};
|
||||
window.__AN_REGISTRY__.invalidateAll = () => meterFacade.invalidateAll();
|
||||
}
|
||||
|
||||
// --- Meter Registration System ---
|
||||
export function registerMeter(meterModule) {
|
||||
if (meterModule && meterModule.id) {
|
||||
cache.meters.set(meterModule.id, Promise.resolve(meterModule));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export const RTW_CENTER_MAP = {
|
||||
'1_3': [
|
||||
20, 25, 31.5, 40, 50, 63, 80, 100, 125, 160,
|
||||
200, 250, 315, 400, 500, 630, 800, 1000, 1250, 1600,
|
||||
2000, 2500, 3150, 4000, 5000, 6300, 8000, 10000, 12500, 16000, 20000,
|
||||
],
|
||||
'1_6': [
|
||||
20, 22.4, 25, 28, 31.5, 35.5, 40, 45, 50, 56,
|
||||
63, 70.8, 80, 90, 100, 112, 125, 141, 160, 180,
|
||||
200, 224, 250, 282, 315, 355, 400, 450, 500, 560,
|
||||
630, 708, 800, 900, 1000, 1120, 1250, 1410, 1600, 1800,
|
||||
2000, 2240, 2500, 2820, 3150, 3550, 4000, 4500, 5000, 5600,
|
||||
6300, 7080, 8000, 9000, 10000, 11200, 12500, 14100, 16000, 18000, 20000,
|
||||
],
|
||||
'1_12': [
|
||||
20, 21.2, 22.4, 23.8, 25, 26.5, 28, 29.8, 31.5, 33.4,
|
||||
35.5, 37.6, 40, 42.4, 45, 47.5, 50, 53, 56, 59.5,
|
||||
63, 67, 71, 75, 80, 85, 90, 95, 100, 106,
|
||||
112, 118, 125, 132, 140, 150, 160, 170, 180, 190,
|
||||
200, 212, 224, 238, 250, 265, 280, 298, 315, 334,
|
||||
355, 376, 400, 424, 450, 475, 500, 530, 560, 595,
|
||||
630, 670, 710, 750, 800, 850, 900, 950, 1000, 1060,
|
||||
1120, 1180, 1250, 1320, 1400, 1500, 1600, 1700, 1800, 1900,
|
||||
2000, 2120, 2240, 2380, 2500, 2650, 2800, 2980, 3150, 3340,
|
||||
3550, 3760, 4000, 4240, 4500, 4750, 5000, 5300, 5600, 5950,
|
||||
6300, 6700, 7100, 7500, 8000, 8500, 9000, 9500, 10000, 10600,
|
||||
11200, 11800, 12500, 13200, 14000, 15000, 16000, 17000, 18000, 19000, 20000,
|
||||
],
|
||||
};
|
||||
|
||||
export function getRtwCenters(mode = '1_3') {
|
||||
const key = String(mode || '1_3').replace('/', '_');
|
||||
if (RTW_CENTER_MAP[key]) return RTW_CENTER_MAP[key].slice();
|
||||
return RTW_CENTER_MAP['1_3'].slice();
|
||||
}
|
||||
|
||||
export function resolveRtwBpoValue(mode) {
|
||||
if (typeof mode === 'number' && Number.isFinite(mode)) return mode;
|
||||
const key = String(mode || '').replace('/', '_');
|
||||
if (key === '1_12') return 12;
|
||||
if (key === '1_6') return 6;
|
||||
return 3;
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
// core/screensaver.js — handles idle-activated screensaver overlay
|
||||
|
||||
export function createScreensaver({ config, body = (typeof document !== 'undefined' ? document.body : null) } = {}) {
|
||||
const state = {
|
||||
active: false,
|
||||
force: false,
|
||||
lastSignalTs: now(),
|
||||
ball: { x: 120, y: 120, vx: 180, vy: 140, w: 140, h: 80, color: '#ff0080', tint: '#ff0080', hue: 0 },
|
||||
logo: null,
|
||||
logoReady: false,
|
||||
enabled: true,
|
||||
mode: (config?.SCREENSAVER_MODE === 'starfield' || config?.SCREENSAVER_MODE === 'clock' || config?.SCREENSAVER_MODE === 'black') ? config.SCREENSAVER_MODE : 'clock',
|
||||
stars: [],
|
||||
};
|
||||
|
||||
function now() {
|
||||
return (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||||
}
|
||||
|
||||
function getThreshold() {
|
||||
const t = config?.SCREENSAVER_ACTIVITY_DB;
|
||||
return Number.isFinite(t) ? t : -50;
|
||||
}
|
||||
|
||||
function markActivity(ts) {
|
||||
const t = ts || now();
|
||||
state.lastSignalTs = t;
|
||||
state.force = false;
|
||||
if (state.active) setActive(false);
|
||||
}
|
||||
|
||||
function markAudioActivity(levelDb, ts) {
|
||||
if (!Number.isFinite(levelDb)) return false;
|
||||
if (levelDb > getThreshold()) {
|
||||
// Pegel-Trigger nur, wenn kein forcierter Testlauf aktiv ist
|
||||
if (!state.force) {
|
||||
markActivity(ts);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function ensureLogo() {
|
||||
if (state.logo !== null) return;
|
||||
const img = new Image();
|
||||
img.src = 'assets/dvdlogo.svg';
|
||||
img.onload = () => { state.logoReady = true; };
|
||||
img.onerror = () => { state.logoReady = false; };
|
||||
state.logo = img;
|
||||
}
|
||||
|
||||
function setActive(next) {
|
||||
state.active = !!next;
|
||||
if (body) {
|
||||
body.classList.toggle('screensaver-active', state.active);
|
||||
}
|
||||
}
|
||||
|
||||
function nextLogoColor(prev = '#ff0080') {
|
||||
const palette = ['#ff0080', '#00d8ff', '#ffd400', '#00ff88', '#ff6600', '#9b59ff', '#ff2d55'];
|
||||
let c = prev;
|
||||
for (let i = 0; i < 4 && c === prev; i++) {
|
||||
c = palette[Math.floor(Math.random() * palette.length)];
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
function randomizeBall(rect) {
|
||||
const { ball } = state;
|
||||
if (!rect) return;
|
||||
const base = Math.min(rect.w, rect.h) * 0.22;
|
||||
if (state.logo && state.logoReady && state.logo.width && state.logo.height) {
|
||||
const aspect = state.logo.width / state.logo.height;
|
||||
ball.w = base;
|
||||
ball.h = base / aspect;
|
||||
} else {
|
||||
ball.w = base;
|
||||
ball.h = base * 0.55;
|
||||
}
|
||||
const halfW = ball.w / 2;
|
||||
const halfH = ball.h / 2;
|
||||
ball.x = rect.x + halfW + Math.random() * Math.max(1, rect.w - 2 * halfW);
|
||||
ball.y = rect.y + halfH + Math.random() * Math.max(1, rect.h - 2 * halfH);
|
||||
const speed = 160 + Math.random() * 80;
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
ball.vx = Math.cos(angle) * speed;
|
||||
ball.vy = Math.sin(angle) * speed;
|
||||
ball.color = nextLogoColor();
|
||||
ball.tint = ball.color;
|
||||
}
|
||||
|
||||
function updateBall(rect, dtMs) {
|
||||
const { ball } = state;
|
||||
const dt = Math.max(0.001, dtMs / 1000);
|
||||
ball.x += ball.vx * dt;
|
||||
ball.y += ball.vy * dt;
|
||||
const halfW = ball.w / 2;
|
||||
const halfH = ball.h / 2;
|
||||
const minX = rect.x + halfW;
|
||||
const maxX = rect.x + rect.w - halfW;
|
||||
const minY = rect.y + halfH;
|
||||
const maxY = rect.y + rect.h - halfH;
|
||||
let bounced = false;
|
||||
if (ball.x < minX) { ball.x = minX; ball.vx *= -1; bounced = true; }
|
||||
if (ball.x > maxX) { ball.x = maxX; ball.vx *= -1; bounced = true; }
|
||||
if (ball.y < minY) { ball.y = minY; ball.vy *= -1; bounced = true; }
|
||||
if (ball.y > maxY) { ball.y = maxY; ball.vy *= -1; bounced = true; }
|
||||
if (bounced) {
|
||||
const nxt = nextLogoColor(ball.color);
|
||||
ball.color = nxt;
|
||||
ball.tint = nxt;
|
||||
ball.hue = Math.floor(Math.random() * 360);
|
||||
}
|
||||
}
|
||||
|
||||
function draw(ctx, rect) {
|
||||
if (state.mode === 'starfield') {
|
||||
drawStarfield(ctx, rect);
|
||||
return;
|
||||
}
|
||||
const { ball } = state;
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(5,5,10,0.9)';
|
||||
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
|
||||
const radius = Math.min(ball.w, ball.h) * 0.18;
|
||||
const x = ball.x - ball.w / 2;
|
||||
const y = ball.y - ball.h / 2;
|
||||
const col = ball.color || '#ff0080';
|
||||
// Logo body
|
||||
ctx.fillStyle = col;
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.35)';
|
||||
ctx.lineWidth = 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.lineTo(x + ball.w - radius, y);
|
||||
ctx.quadraticCurveTo(x + ball.w, y, x + ball.w, y + radius);
|
||||
ctx.lineTo(x + ball.w, y + ball.h - radius);
|
||||
ctx.quadraticCurveTo(x + ball.w, y + ball.h, x + ball.w - radius, y + ball.h);
|
||||
ctx.lineTo(x + radius, y + ball.h);
|
||||
ctx.quadraticCurveTo(x, y + ball.h, x, y + ball.h - radius);
|
||||
ctx.lineTo(x, y + radius);
|
||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// DVD text
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.font = `${Math.max(18, ball.h * 0.4)}px 'Arial Black', ui-sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('DVD', ball.x, ball.y);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function updateAndRender(ctx, rect, nowTs, dtMs) {
|
||||
const enabled = state.enabled !== false && config?.SCREENSAVER_ENABLED !== false;
|
||||
if (!enabled && !state.force) {
|
||||
if (state.active) setActive(false);
|
||||
return false;
|
||||
}
|
||||
const idleMin = Math.max(0, Number(config?.SCREENSAVER_IDLE_MIN ?? 0));
|
||||
const idleMs = idleMin * 60 * 1000;
|
||||
const shouldActivate = state.force || (idleMs > 0 && (nowTs - state.lastSignalTs) >= idleMs);
|
||||
if (shouldActivate && !state.active) {
|
||||
randomizeBall(rect);
|
||||
setActive(true);
|
||||
} else if (!shouldActivate && state.active) {
|
||||
setActive(false);
|
||||
}
|
||||
if (!state.active) return false;
|
||||
|
||||
if (state.mode === 'starfield') {
|
||||
updateStars(rect, dtMs);
|
||||
drawStarfield(ctx, rect);
|
||||
return true;
|
||||
}
|
||||
if (state.mode === 'black') {
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
|
||||
ctx.restore();
|
||||
return true;
|
||||
}
|
||||
if (state.mode === 'clock') {
|
||||
drawClock(ctx, rect, nowTs);
|
||||
return true;
|
||||
}
|
||||
updateBall(rect, dtMs);
|
||||
drawDvd(ctx, rect);
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureStars(rect, count = 160) {
|
||||
if (!rect) return;
|
||||
if (!state.stars || state.stars.length !== count) {
|
||||
state.stars = new Array(count).fill(0).map(() => makeStar(rect));
|
||||
}
|
||||
}
|
||||
|
||||
function makeStar(rect) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const speed = 0.15 + Math.random() * 0.25;
|
||||
const hue = 180 + Math.random() * 120;
|
||||
return {
|
||||
x: (Math.random() * 2 - 1) * rect.w * 0.35,
|
||||
y: (Math.random() * 2 - 1) * rect.h * 0.35,
|
||||
z: Math.random() * 1 + 0.3,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
hue,
|
||||
};
|
||||
}
|
||||
|
||||
function updateStars(rect, dtMs) {
|
||||
ensureStars(rect);
|
||||
const dt = Math.max(0.001, dtMs / 1000);
|
||||
for (let i = 0; i < state.stars.length; i++) {
|
||||
const s = state.stars[i];
|
||||
s.x += s.vx * rect.w * dt;
|
||||
s.y += s.vy * rect.h * dt;
|
||||
s.z -= dt * 0.35;
|
||||
if (s.z <= 0.05 || Math.abs(s.x) > rect.w || Math.abs(s.y) > rect.h) {
|
||||
state.stars[i] = makeStar(rect);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function drawStarfield(ctx, rect) {
|
||||
ensureStars(rect);
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(3,4,8,0.92)';
|
||||
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
|
||||
const cx = rect.x + rect.w / 2;
|
||||
const cy = rect.y + rect.h / 2;
|
||||
for (const s of state.stars) {
|
||||
const scale = 280 / (s.z * rect.w);
|
||||
const x = cx + s.x * scale;
|
||||
const y = cy + s.y * scale;
|
||||
const len = Math.max(4, 18 * (1 - s.z));
|
||||
const dx = (s.x * scale) * 0.02;
|
||||
const dy = (s.y * scale) * 0.02;
|
||||
ctx.strokeStyle = `hsl(${s.hue}, 90%, 65%)`;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x - dx, y - dy);
|
||||
ctx.lineTo(x + dx, y + dy);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = `hsl(${s.hue}, 90%, 70%)`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, Math.max(1, len * 0.08), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawDvd(ctx, rect) {
|
||||
ensureLogo();
|
||||
const { ball } = state;
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(5,5,10,0.9)';
|
||||
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
|
||||
|
||||
if (state.logo && state.logoReady) {
|
||||
const x = ball.x - ball.w / 2;
|
||||
const y = ball.y - ball.h / 2;
|
||||
ctx.filter = `hue-rotate(${ball.hue || 0}deg)`;
|
||||
ctx.drawImage(state.logo, x, y, ball.w, ball.h);
|
||||
ctx.filter = 'none';
|
||||
} else {
|
||||
// Fallback: simples Logo
|
||||
const radius = Math.min(ball.w, ball.h) * 0.18;
|
||||
const x = ball.x - ball.w / 2;
|
||||
const y = ball.y - ball.h / 2;
|
||||
const col = ball.color || '#ff0080';
|
||||
ctx.fillStyle = col;
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.35)';
|
||||
ctx.lineWidth = 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.lineTo(x + ball.w - radius, y);
|
||||
ctx.quadraticCurveTo(x + ball.w, y, x + ball.w, y + radius);
|
||||
ctx.lineTo(x + ball.w, y + ball.h - radius);
|
||||
ctx.quadraticCurveTo(x + ball.w, y + ball.h, x + ball.w - radius, y + ball.h);
|
||||
ctx.lineTo(x + radius, y + ball.h);
|
||||
ctx.quadraticCurveTo(x, y + ball.h, x, y + ball.h - radius);
|
||||
ctx.lineTo(x, y + radius);
|
||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.font = `${Math.max(18, ball.h * 0.4)}px 'Arial Black', ui-sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('DVD', ball.x, ball.y);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawClock(ctx, rect, nowTs) {
|
||||
const d = new Date();
|
||||
const hours = d.getHours();
|
||||
const mins = d.getMinutes();
|
||||
const secs = d.getSeconds() + d.getMilliseconds() / 1000;
|
||||
const centerX = rect.x + rect.w / 2;
|
||||
const centerY = rect.y + rect.h / 2;
|
||||
const radius = Math.min(rect.w, rect.h) * 0.48;
|
||||
const baseDot = Math.max(1.5, Math.round(radius * 0.03));
|
||||
const ringDot = baseDot * 0.7;
|
||||
const glyphDot = baseDot * 1.3;
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(8,8,12,0.9)';
|
||||
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
|
||||
ctx.translate(centerX, centerY);
|
||||
|
||||
// seconds ring + 5s markers
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const ang = (Math.PI * 2 * i) / 60 - Math.PI / 2;
|
||||
const r = radius * 0.88;
|
||||
const filled = i <= secs;
|
||||
const cx = Math.cos(ang) * r;
|
||||
const cy = Math.sin(ang) * r;
|
||||
const glow = config?.SCREENSAVER_LED_GLOW !== false;
|
||||
const color = normalizeColor(config?.SCREENSAVER_LED_COLOR);
|
||||
if (filled) drawLed(ctx, cx, cy, ringDot, glow, color);
|
||||
else {
|
||||
ctx.fillStyle = 'rgba(120,120,120,0.25)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, ringDot, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
if (i % 5 === 0) {
|
||||
const outerR = r + ringDot * 3.2;
|
||||
const ocx = Math.cos(ang) * outerR;
|
||||
const ocy = Math.sin(ang) * outerR;
|
||||
drawLed(ctx, ocx, ocy, ringDot, glow, color);
|
||||
}
|
||||
}
|
||||
|
||||
// center time (HH:MM) as dot-matrix
|
||||
const timeStr = `${pad2(hours)}:${pad2(mins)}`;
|
||||
const glyphSize = radius * 0.32;
|
||||
const timeSpacing = 0.9;
|
||||
const timeY = -(glyphSize * 1.2) / 2; // vertikal zentriert auf der Mittelachse
|
||||
const blinkOn = isSecondPulseActive(d);
|
||||
const extraGaps = [0, 3]; // zusätzliche Spalte zwischen den Ziffern in jedem Block
|
||||
const metrics = measureTextLayout(timeStr, glyphSize, timeSpacing, extraGaps);
|
||||
const timeX = -metrics.colonCenter; // Doppelpunkt auf Mittelpunkt legen
|
||||
const glow = config?.SCREENSAVER_LED_GLOW !== false;
|
||||
const color = normalizeColor(config?.SCREENSAVER_LED_COLOR);
|
||||
drawDotText(ctx, timeStr, timeX, timeY, glyphSize, glyphDot * 1.1, timeSpacing, blinkOn ? (glyphDot * 1.1) : 0, extraGaps, glow, color);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawClockDigital(ctx, rect) {
|
||||
const d = new Date();
|
||||
const hours = d.getHours();
|
||||
const mins = d.getMinutes();
|
||||
const secs = d.getSeconds() + d.getMilliseconds() / 1000;
|
||||
const day = d.getDate();
|
||||
const month = d.getMonth() + 1;
|
||||
const year = d.getFullYear();
|
||||
const centerX = rect.x + rect.w / 2;
|
||||
const centerY = rect.y + rect.h / 2;
|
||||
const radius = Math.min(rect.w, rect.h) * 0.45;
|
||||
const baseDot = Math.max(1.5, Math.round(radius * 0.028));
|
||||
const glyphDot = baseDot * 1.3;
|
||||
const glyphSize = radius * 0.32;
|
||||
const timeSpacing = 0.9;
|
||||
const blinkOn = isSecondPulseActive(d);
|
||||
const extraGaps = [0, 3];
|
||||
const color = normalizeColor(config?.SCREENSAVER_LED_COLOR);
|
||||
const glow = config?.SCREENSAVER_LED_GLOW !== false;
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(8,8,12,0.9)';
|
||||
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
|
||||
ctx.translate(centerX, centerY);
|
||||
|
||||
const timeStr = `${pad2(hours)}:${pad2(mins)}:${pad2(Math.floor(secs))}`;
|
||||
const dateStr = `${pad2(day)}.${pad2(month)}.${year}`;
|
||||
const dateSize = glyphSize * 0.8;
|
||||
const dateGaps = [1, 4]; // nach DD und MM
|
||||
const timeMetrics = measureTextLayout(timeStr, glyphSize, timeSpacing, extraGaps);
|
||||
const dateMetrics = measureTextLayout(dateStr, dateSize, timeSpacing, dateGaps);
|
||||
const totalH = glyphSize * 1.2 + dateSize * 1.2 + glyphSize * 0.25;
|
||||
const startY = -totalH / 2;
|
||||
const timeX = -timeMetrics.colonCenter;
|
||||
const timeY = startY;
|
||||
drawDotText(ctx, timeStr, timeX, timeY, glyphSize, glyphDot, timeSpacing, blinkOn ? glyphDot : 0, extraGaps, glow, color);
|
||||
|
||||
const dateX = -dateMetrics.width / 2;
|
||||
const dateY = startY + glyphSize * 1.2 + glyphSize * 0.25;
|
||||
drawDotText(ctx, dateStr, dateX, dateY, dateSize, glyphDot * 0.9, timeSpacing, 0, dateGaps, glow, color);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
return {
|
||||
markActivity,
|
||||
markAudioActivity,
|
||||
updateAndRender,
|
||||
triggerTest() { state.force = true; state.lastSignalTs = now(); },
|
||||
setIdleMinutes(min) { if (Number.isFinite(min)) config.SCREENSAVER_IDLE_MIN = min; },
|
||||
setActivityThreshold(db) { if (Number.isFinite(db)) config.SCREENSAVER_ACTIVITY_DB = db; },
|
||||
setEnabled(val) {
|
||||
state.enabled = !!val;
|
||||
config.SCREENSAVER_ENABLED = state.enabled;
|
||||
if (!state.enabled && state.active) setActive(false);
|
||||
},
|
||||
setColor(val) {
|
||||
if (typeof val === 'string' && val.trim()) {
|
||||
config.SCREENSAVER_LED_COLOR = val;
|
||||
}
|
||||
},
|
||||
setMode(mode) {
|
||||
const m = mode === 'starfield'
|
||||
? 'starfield'
|
||||
: (mode === 'clock'
|
||||
? 'clock'
|
||||
: (mode === 'black'
|
||||
? 'black'
|
||||
: (mode === 'lines' ? 'lines' : 'dvd')));
|
||||
state.mode = m;
|
||||
config.SCREENSAVER_MODE = m;
|
||||
if (m === 'starfield') {
|
||||
state.stars = [];
|
||||
} else if (m === 'clock') {
|
||||
// no special init needed
|
||||
} else if (m === 'lines') {
|
||||
state.lines = [];
|
||||
} else if (m === 'dvd') {
|
||||
randomizeBall({ x: 0, y: 0, w: 1, h: 1 });
|
||||
}
|
||||
},
|
||||
isEnabled: () => state.enabled !== false && config?.SCREENSAVER_ENABLED !== false,
|
||||
isActive: () => state.active,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Dot glyph rendering for clock -----------------------------------------
|
||||
const DOT_FONT = {
|
||||
'0': [
|
||||
'01110',
|
||||
'10001',
|
||||
'10011',
|
||||
'10101',
|
||||
'11001',
|
||||
'10001',
|
||||
'01110',
|
||||
],
|
||||
'1': [
|
||||
'00100',
|
||||
'01100',
|
||||
'00100',
|
||||
'00100',
|
||||
'00100',
|
||||
'00100',
|
||||
'01110',
|
||||
],
|
||||
'2': [
|
||||
'01110',
|
||||
'10001',
|
||||
'00001',
|
||||
'00110',
|
||||
'01000',
|
||||
'10000',
|
||||
'11111',
|
||||
],
|
||||
'3': [
|
||||
'11110',
|
||||
'00001',
|
||||
'00001',
|
||||
'01110',
|
||||
'00001',
|
||||
'00001',
|
||||
'11110',
|
||||
],
|
||||
'4': [
|
||||
'00010',
|
||||
'00110',
|
||||
'01010',
|
||||
'10010',
|
||||
'11111',
|
||||
'00010',
|
||||
'00010',
|
||||
],
|
||||
'5': [
|
||||
'11111',
|
||||
'10000',
|
||||
'11110',
|
||||
'00001',
|
||||
'00001',
|
||||
'10001',
|
||||
'01110',
|
||||
],
|
||||
'6': [
|
||||
'00110',
|
||||
'01000',
|
||||
'10000',
|
||||
'11110',
|
||||
'10001',
|
||||
'10001',
|
||||
'01110',
|
||||
],
|
||||
'7': [
|
||||
'11111',
|
||||
'00001',
|
||||
'00010',
|
||||
'00100',
|
||||
'01000',
|
||||
'01000',
|
||||
'01000',
|
||||
],
|
||||
'8': [
|
||||
'01110',
|
||||
'10001',
|
||||
'10001',
|
||||
'01110',
|
||||
'10001',
|
||||
'10001',
|
||||
'01110',
|
||||
],
|
||||
'9': [
|
||||
'01110',
|
||||
'10001',
|
||||
'10001',
|
||||
'01111',
|
||||
'00001',
|
||||
'00010',
|
||||
'01100',
|
||||
],
|
||||
':': [
|
||||
'0',
|
||||
'1',
|
||||
'0',
|
||||
'0',
|
||||
'1',
|
||||
'0',
|
||||
'0',
|
||||
],
|
||||
};
|
||||
const DOT_COLS = DOT_FONT['0'][0].length;
|
||||
|
||||
function drawDotText(ctx, text, x, y, glyphSize, dotRadius, spacingFactor = 0.9, colonDotRadiusOverride = null, extraGapIndices = [], glow = true, color = 'rgb(255,0,0)') {
|
||||
let cursorX = x;
|
||||
const glyphW = glyphSize;
|
||||
const glyphH = glyphSize * 1.2;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
const useDot = (ch === ':' && colonDotRadiusOverride !== null) ? colonDotRadiusOverride : dotRadius;
|
||||
drawDotChar(ctx, ch, cursorX, y, glyphW, glyphH, useDot, glow, color);
|
||||
cursorX += glyphW * spacingFactor;
|
||||
if (extraGapIndices.includes(i)) {
|
||||
cursorX += glyphW / DOT_COLS;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function measureTextLayout(text, glyphSize, spacingFactor, extraGapIndices = []) {
|
||||
const glyphW = glyphSize;
|
||||
const colonIndex = text.indexOf(':');
|
||||
let cursor = 0;
|
||||
let colonCenter = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (i === colonIndex) {
|
||||
colonCenter = cursor + glyphW * 0.5;
|
||||
}
|
||||
cursor += glyphW * spacingFactor;
|
||||
if (extraGapIndices.includes(i)) cursor += glyphW / DOT_COLS;
|
||||
}
|
||||
return { width: cursor, colonCenter };
|
||||
}
|
||||
|
||||
function drawDotChar(ctx, ch, x, y, w, h, dotRadius, glow = true, color = 'rgb(255,0,0)') {
|
||||
const rows = DOT_FONT[ch] || DOT_FONT['0'];
|
||||
const cols = rows[0].length;
|
||||
const r = Math.min(dotRadius, Math.max(1, Math.min(w, h) * 0.04));
|
||||
const cellW = w / cols;
|
||||
const cellH = h / rows.length;
|
||||
for (let row = 0; row < rows.length; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
if (rows[row][col] === '1') {
|
||||
const cx = x + col * cellW + cellW / 2;
|
||||
const cy = y + row * cellH + cellH / 2;
|
||||
drawLed(ctx, cx, cy, r, glow, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawLed(ctx, cx, cy, r, glow = true, color = 'rgb(255,0,0)') {
|
||||
if (r <= 0) return;
|
||||
if (glow) {
|
||||
const haloR = r * 2.2;
|
||||
const midR = r * 1.4;
|
||||
ctx.fillStyle = colorWithAlpha(color, 0.08);
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, haloR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = colorWithAlpha(color, 0.35);
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, midR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function pad2(n) {
|
||||
return n < 10 ? `0${n}` : String(n);
|
||||
}
|
||||
|
||||
function isSecondPulseActive(date, pulseMs = 500) {
|
||||
return date.getMilliseconds() < pulseMs;
|
||||
}
|
||||
|
||||
function normalizeColor(val) {
|
||||
if (typeof val !== 'string' || !val.trim()) return '#ff0000';
|
||||
return val.trim();
|
||||
}
|
||||
|
||||
function colorWithAlpha(color, alpha) {
|
||||
const c = normalizeColor(color);
|
||||
if (/^#([0-9a-fA-F]{6})$/.test(c)) {
|
||||
const r = parseInt(c.slice(1, 3), 16);
|
||||
const g = parseInt(c.slice(3, 5), 16);
|
||||
const b = parseInt(c.slice(5, 7), 16);
|
||||
return `rgba(${r},${g},${b},${alpha})`;
|
||||
}
|
||||
if (/^rgb\(/i.test(c)) {
|
||||
const nums = c.match(/(\d+\.?\d*)/g)?.slice(0, 3) || [255, 0, 0];
|
||||
return `rgba(${nums[0]},${nums[1]},${nums[2]},${alpha})`;
|
||||
}
|
||||
return `rgba(255,0,0,${alpha})`;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export const FRAME_COLOR = '#00e7ff';
|
||||
export const FRAME_COLOR_DIM = 'rgba(0,231,255,0.4)';
|
||||
export const PANEL_BG = 'rgba(5,5,11,0.75)';
|
||||
export const HEADER_BG = 'rgba(5,5,11,0.9)';
|
||||
export const GRID_MAJOR_COLOR = '#1e2a35';
|
||||
export const GRID_MINOR_COLOR = '#131b24';
|
||||
export const LABEL_COLOR = '#8fd3d4';
|
||||
export const MID_COLOR = '#ffe066';
|
||||
export const WARN_COLOR = '#ff3b3b';
|
||||
export const OK_COLOR = '#34d399';
|
||||
export const DEFAULT_TOP_INSET = 70;
|
||||
@@ -0,0 +1,318 @@
|
||||
// core/utils.js — generische Helfer (ohne Seiteneffekte)
|
||||
// Hinweis: Viele Funktionen nehmen CONFIG/Nyquist als Parameter, damit utils keine
|
||||
// harte Abhängigkeit auf config.js haben. So bleiben die Module austauschbar.
|
||||
|
||||
// --- Math & Guards -----------------------------------------------------------
|
||||
export const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
|
||||
|
||||
export function clampPow2(v, min = 2048, max = 16384) {
|
||||
const allowed = [2048, 4096, 8192, 16384].filter(n => n >= min && n <= max);
|
||||
return allowed.includes(v) ? v : Math.min(max, Math.max(min, v));
|
||||
}
|
||||
|
||||
const nowTime = () => {
|
||||
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
|
||||
return performance.now();
|
||||
}
|
||||
return Date.now();
|
||||
};
|
||||
|
||||
export function createPeakHoldState(initial = -160, now = nowTime(), holdMs = 0) {
|
||||
const startValue = Number.isFinite(initial) ? initial : -160;
|
||||
const tNow = Number.isFinite(now) ? now : nowTime();
|
||||
const span = Math.max(0, Number.isFinite(holdMs) ? holdMs : 0);
|
||||
return {
|
||||
value: startValue,
|
||||
holdUntil: tNow + span,
|
||||
lastTs: tNow,
|
||||
};
|
||||
}
|
||||
|
||||
export function stepPeakHold(current, state, now = nowTime(), opts = {}) {
|
||||
const holdMs = Math.max(0, Number.isFinite(opts.holdMs) ? opts.holdMs : 0);
|
||||
const decayDbPerS = Math.max(0, Number.isFinite(opts.decayDbPerS) ? opts.decayDbPerS : 0);
|
||||
const floor = Number.isFinite(opts.floor) ? opts.floor : -160;
|
||||
const riseThreshold = Number.isFinite(opts.riseThreshold) ? opts.riseThreshold : 0.2;
|
||||
const tNow = Number.isFinite(now) ? now : nowTime();
|
||||
const sample = Number.isFinite(current) ? current : floor;
|
||||
|
||||
if (!state || typeof state !== 'object') {
|
||||
state = createPeakHoldState(floor, tNow, holdMs);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(state.value)) state.value = floor;
|
||||
if (!Number.isFinite(state.lastTs)) state.lastTs = tNow;
|
||||
if (!Number.isFinite(state.holdUntil)) state.holdUntil = tNow + holdMs;
|
||||
|
||||
const holdEnd = tNow + holdMs;
|
||||
if (state.holdUntil > holdEnd) state.holdUntil = holdEnd;
|
||||
|
||||
if (sample >= state.value + riseThreshold) {
|
||||
state.value = sample;
|
||||
state.holdUntil = tNow + holdMs;
|
||||
state.lastTs = tNow;
|
||||
} else if (tNow <= state.holdUntil) {
|
||||
state.lastTs = tNow;
|
||||
} else {
|
||||
const dt = Math.max(0, (tNow - state.lastTs) / 1000);
|
||||
if (dt > 0 && decayDbPerS > 0) {
|
||||
state.value = Math.max(floor, state.value - decayDbPerS * dt);
|
||||
}
|
||||
state.lastTs = tNow;
|
||||
}
|
||||
|
||||
return state.value;
|
||||
}
|
||||
|
||||
// --- dBFS Mapping ------------------------------------------------------------
|
||||
export function yFromDbfs(db, yTop, yBot, CONFIG) {
|
||||
const top = CONFIG.DBFS_TOP, bot = CONFIG.DBFS_BOTTOM;
|
||||
const d = Math.max(bot, Math.min(top, db));
|
||||
const t = (d - bot) / (top - bot);
|
||||
return yBot - t * (yBot - yTop);
|
||||
}
|
||||
|
||||
export function mapFRect(f, x0, w, nyq) {
|
||||
const f0 = 20, f1 = nyq, fx = Math.max(f, f0);
|
||||
const lx = (Math.log10(fx) - Math.log10(f0)) / (Math.log10(f1) - Math.log10(f0));
|
||||
return x0 + lx * w;
|
||||
}
|
||||
|
||||
// --- Bänder & Aggregation ----------------------------------------------------
|
||||
export function makeIECThirdOctCenters(nyq) {
|
||||
const base = [20,25,31.5,40,50,63,80,100,125,160,200,250,315,400,500,630,800,1000,1250,1600,2000,2500,3150,4000,5000,6300,8000,10000,12500,16000,20000,21000];
|
||||
return base.filter(f => f < nyq * 0.999);
|
||||
}
|
||||
|
||||
export function avgBandDB(fLo, fHi, data, nyq) {
|
||||
const n = data.length;
|
||||
const binLo = Math.max(0, Math.min(n - 1, Math.round((fLo / nyq) * n)));
|
||||
const binHi = Math.max(0, Math.min(n - 1, Math.round((fHi / nyq) * n)));
|
||||
let sum = 0, count = 0;
|
||||
for (let i = binLo; i <= binHi; i++) {
|
||||
sum += Math.pow(10, data[i] / 10);
|
||||
count++;
|
||||
}
|
||||
return count > 0 ? 10 * Math.log10(sum / count) : -160;
|
||||
}
|
||||
|
||||
// ----- Fractional octave helpers (IEC 61260-1) ------------------------------
|
||||
const BPO_VALUE_MAP = { '1_3': 3, '1_6': 6, '1_12': 12 };
|
||||
|
||||
export function resolveBpoValue(mode) {
|
||||
if (typeof mode === 'number' && Number.isFinite(mode)) return mode;
|
||||
return BPO_VALUE_MAP[String(mode)] || 6;
|
||||
}
|
||||
|
||||
export function makeFractionalOctaveBands(nyq = 24000, mode = '1_6', opts = {}) {
|
||||
const bpo = resolveBpoValue(mode);
|
||||
const fMin = Math.max(5, opts.fMin ?? 20);
|
||||
const fMax = Math.max(fMin * 1.001, Math.min(opts.fMax ?? nyq, nyq));
|
||||
const centerRef = opts.reference ?? 1000;
|
||||
const kMin = Math.ceil(bpo * Math.log2(fMin / centerRef));
|
||||
const kMax = Math.floor(bpo * Math.log2(fMax / centerRef));
|
||||
const bands = [];
|
||||
const factor = Math.pow(2, 1 / (2 * bpo));
|
||||
for (let k = kMin; k <= kMax; k++) {
|
||||
const center = centerRef * Math.pow(2, k / bpo);
|
||||
const lo = center / factor;
|
||||
const hi = center * factor;
|
||||
if (hi < fMin) continue;
|
||||
if (lo > fMax) break;
|
||||
bands.push({
|
||||
center,
|
||||
fLo: Math.max(lo, fMin),
|
||||
fHi: Math.min(hi, fMax),
|
||||
});
|
||||
}
|
||||
return bands;
|
||||
}
|
||||
|
||||
export function buildBandBinMapping(bands, nyq, binCount) {
|
||||
const result = [];
|
||||
if (!Array.isArray(bands) || !Number.isFinite(nyq) || !Number.isFinite(binCount) || binCount <= 0) {
|
||||
return result;
|
||||
}
|
||||
const binWidth = nyq / binCount;
|
||||
for (const band of bands) {
|
||||
const bins = [];
|
||||
const start = Math.max(0, Math.floor((band.fLo / nyq) * binCount));
|
||||
const end = Math.min(binCount - 1, Math.ceil((band.fHi / nyq) * binCount));
|
||||
let weightSum = 0;
|
||||
for (let i = start; i <= end; i++) {
|
||||
const binStart = i * binWidth;
|
||||
const binEnd = binStart + binWidth;
|
||||
const overlap = Math.max(0, Math.min(binEnd, band.fHi) - Math.max(binStart, band.fLo));
|
||||
if (overlap > 0) {
|
||||
bins.push({ index: i, weight: overlap });
|
||||
weightSum += overlap;
|
||||
}
|
||||
}
|
||||
if (!bins.length) {
|
||||
const idx = Math.max(0, Math.min(binCount - 1, Math.round((band.center / nyq) * binCount)));
|
||||
bins.push({ index: idx, weight: 1 });
|
||||
weightSum = 1;
|
||||
}
|
||||
bins.forEach((b) => { b.weight /= weightSum || 1; });
|
||||
result.push({
|
||||
center: band.center,
|
||||
fLo: band.fLo,
|
||||
fHi: band.fHi,
|
||||
bins,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function computeBandLevels(mappedBands, freqData, opts = {}) {
|
||||
if (!Array.isArray(mappedBands) || !freqData) return [];
|
||||
const floor = Number.isFinite(opts.floor) ? opts.floor : -160;
|
||||
const weightingFn = typeof opts.weightingFn === 'function' ? opts.weightingFn : null;
|
||||
const out = new Array(mappedBands.length);
|
||||
for (let i = 0; i < mappedBands.length; i++) {
|
||||
const band = mappedBands[i];
|
||||
let energy = 0;
|
||||
for (const seg of band.bins) {
|
||||
const val = freqData[seg.index];
|
||||
if (!Number.isFinite(val)) continue;
|
||||
energy += Math.pow(10, val / 10) * seg.weight;
|
||||
}
|
||||
const weightedDb = weightingFn ? weightingFn(band.center) : 0;
|
||||
const lin = Math.max(1e-12, energy * Math.pow(10, weightedDb / 10));
|
||||
out[i] = 10 * Math.log10(lin);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function weightingDb(freq, mode = 'z') {
|
||||
if (!Number.isFinite(freq) || freq <= 0) return 0;
|
||||
const f2 = freq * freq;
|
||||
if (mode === 'a') {
|
||||
const raNum = (12194 ** 2) * f2 * f2;
|
||||
const raDen = (f2 + 20.6 ** 2) * Math.sqrt((f2 + 107.7 ** 2) * (f2 + 737.9 ** 2)) * (f2 + 12194 ** 2);
|
||||
const ra = raNum / raDen;
|
||||
return 20 * Math.log10(ra) + 2.0;
|
||||
}
|
||||
if (mode === 'c') {
|
||||
const rcNum = (12194 ** 2) * f2;
|
||||
const rcDen = (f2 + 20.6 ** 2) * (f2 + 12194 ** 2);
|
||||
const rc = rcNum / rcDen;
|
||||
return 20 * Math.log10(rc) + 0.06;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --- Korrelation -------------------------------------------------------------
|
||||
export function correlation(xyL, xyR, smoothPrev = 0, smooth = 0.85) {
|
||||
const n = Math.min(xyL?.length || 0, xyR?.length || 0);
|
||||
if (!n) return 0;
|
||||
let sLL = 0, sRR = 0, sLR = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = xyL[i], b = xyR[i];
|
||||
sLL += a * a; sRR += b * b; sLR += a * b;
|
||||
}
|
||||
const denom = Math.sqrt((sLL + 1e-12) * (sRR + 1e-12));
|
||||
const r = denom > 0 ? sLR / denom : 0;
|
||||
return smooth * smoothPrev + (1 - smooth) * Math.max(-1, Math.min(1, r));
|
||||
}
|
||||
|
||||
// --- Labels & Ticks ----------------------------------------------------------
|
||||
export function freqTickLabels(nyq, fMin = 20) {
|
||||
const ticks = [
|
||||
5, 10, 20, 25, 31.5, 40, 50, 63, 80, 100, 125, 160,
|
||||
200, 250, 315, 400, 500, 630, 800, 1000, 1250, 1600,
|
||||
2000, 2500, 3150, 4000, 5000, 6300, 8000, 10000, 12500, 16000, 20000,
|
||||
];
|
||||
const min = Math.max(5, fMin);
|
||||
return ticks.filter((f) => f >= min && f < nyq);
|
||||
}
|
||||
|
||||
export function fmtFreq(f) {
|
||||
if (f >= 1000) return (f / 1000) + 'k';
|
||||
return (f % 1 ? f : Math.round(f)).toString();
|
||||
}
|
||||
|
||||
// --- Rechteck-Helper ---------------------------------------------------------
|
||||
// --- Zeichnen: Rahmen (ruhig hier, damit Views sich nicht wiederholen) -------
|
||||
export function strokeRect(ctx, rect, color = '#00e7ff', lw = 2) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = color; ctx.lineWidth = lw;
|
||||
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export function fillRect(ctx, rect, color) {
|
||||
ctx.save();
|
||||
ctx.fillStyle = color; ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// dBFS Raster als Utility, damit Bars/Line denselben Look teilen
|
||||
export function drawDbfsGridRect(ctx, x0, y0, w, h, CONFIG, nyq, opts = {}) {
|
||||
const {
|
||||
colorMajor = '#1e2a35',
|
||||
colorMinor = 'rgba(30,42,53,.55)',
|
||||
labelColor = '#8fd3d4',
|
||||
freqTicks = null,
|
||||
freqMin = 20,
|
||||
refDb = null,
|
||||
refStyle = 'rgba(0,231,255,0.35)',
|
||||
refDash = [4, 3],
|
||||
} = opts;
|
||||
const yTicks = (CONFIG.Y_TICKS && CONFIG.Y_TICKS.length)
|
||||
? CONFIG.Y_TICKS
|
||||
: [9, 0, -9, -18, -27, -36, -45, -54, -63];
|
||||
|
||||
const _g = Number(CONFIG && CONFIG.AXIS_GUTTER_LEFT);
|
||||
const gutterL = Number.isFinite(_g) ? Math.max(8, _g) : 14;
|
||||
|
||||
ctx.save();
|
||||
ctx.strokeStyle = '#00e7ff'; ctx.lineWidth = 2; ctx.strokeRect(x0 - gutterL, y0, w + gutterL, h);
|
||||
ctx.font = 'bold 14px ui-monospace, monospace'; ctx.fillStyle = labelColor;
|
||||
|
||||
// Grid + Labels at fixed ticks
|
||||
for (const v of yTicks) {
|
||||
if (v > CONFIG.DBFS_TOP || v < CONFIG.DBFS_BOTTOM) continue;
|
||||
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.textAlign = 'right'; ctx.fillText(String(v), (x0 - gutterL) - 6, labelY);
|
||||
}
|
||||
|
||||
// Reference line (e.g. alignment level)
|
||||
if (Number.isFinite(refDb) && refDb <= CONFIG.DBFS_TOP && refDb >= CONFIG.DBFS_BOTTOM) {
|
||||
const yRef = yFromDbfs(refDb, y0, y0 + h, CONFIG);
|
||||
ctx.strokeStyle = refStyle;
|
||||
ctx.setLineDash(Array.isArray(refDash) ? refDash : [4, 3]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x0, yRef);
|
||||
ctx.lineTo(x0 + w, yRef);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Frequenz-Ticks (x-Achse)
|
||||
ctx.textAlign = 'center'; ctx.fillStyle = labelColor;
|
||||
const ticks = Array.isArray(freqTicks) && freqTicks.length
|
||||
? freqTicks.filter((f) => Number.isFinite(f) && f > 0 && f < nyq)
|
||||
: freqTickLabels(nyq, freqMin);
|
||||
const minFreq = Math.max(5, freqMin);
|
||||
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;
|
||||
// Linken Rand nicht doppeln: keine Vertikal-Linie auf dem linken Plot-Rand
|
||||
if (x > x0 + 0.75) {
|
||||
ctx.strokeStyle = '#131b24';
|
||||
ctx.beginPath(); ctx.moveTo(x, y0); ctx.lineTo(x, y0 + h); ctx.stroke();
|
||||
}
|
||||
ctx.fillText(fmtFreq(f), x, y0 + h + 18);
|
||||
}
|
||||
ctx.textAlign = 'start';
|
||||
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
+1892
File diff suppressed because it is too large
Load Diff
+2740
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,334 @@
|
||||
import { createPeakHoldState, stepPeakHold } from '../core/utils.js';
|
||||
import { drawHairlineGrid, METER_HEADER_FONT } from './scale_helpers.js';
|
||||
import { HEADER_BG, LABEL_COLOR, MID_COLOR, WARN_COLOR } from '../core/theme.js';
|
||||
|
||||
export const id = 'hifi-peak';
|
||||
|
||||
const HIFI_BOTTOM = -20;
|
||||
const HIFI_TOP = 6;
|
||||
const HIFI_RED_START = 3;
|
||||
const DEFAULT_ATTACK_MS = 8;
|
||||
const DEFAULT_RELEASE_DB_PER_S = 26;
|
||||
const DEFAULT_HOLD_MS = 320;
|
||||
const DEFAULT_HOLD_DECAY_DB_PER_S = 40;
|
||||
const MAJOR_TICKS = [-20, -10, -5, 0, 3, 6];
|
||||
const MINOR_TICKS = [-15, -8, -7, -6, -4, -3, -2, -1, 1, 2, 4, 5];
|
||||
|
||||
export function initShared(CONFIG = {}) {
|
||||
const now = performance.now();
|
||||
const refDbfsFor0 = resolveHifiRefDbfsFor0(CONFIG);
|
||||
return {
|
||||
target: { L: HIFI_BOTTOM, R: HIFI_BOTTOM },
|
||||
values: { L: HIFI_BOTTOM, R: HIFI_BOTTOM },
|
||||
hold: { L: HIFI_BOTTOM, R: HIFI_BOTTOM },
|
||||
refDbfsFor0,
|
||||
lastTs: now,
|
||||
_holdState: {
|
||||
L: createPeakHoldState(HIFI_BOTTOM, now, DEFAULT_HOLD_MS),
|
||||
R: createPeakHoldState(HIFI_BOTTOM, now, DEFAULT_HOLD_MS),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function update(packet, shared) {
|
||||
if (!packet || !shared) return;
|
||||
const now = performance.now();
|
||||
const rawL = Number.isFinite(packet.tpL) ? packet.tpL : -120;
|
||||
const rawR = Number.isFinite(packet.tpR) ? packet.tpR : -120;
|
||||
const refDbfsFor0 = Number.isFinite(shared.refDbfsFor0) ? shared.refDbfsFor0 : -14;
|
||||
const deckL = rawL - refDbfsFor0;
|
||||
const deckR = rawR - refDbfsFor0;
|
||||
const targetL = clamp(deckL, HIFI_BOTTOM, HIFI_TOP);
|
||||
const targetR = clamp(deckR, HIFI_BOTTOM, HIFI_TOP);
|
||||
const dt = Math.max(1 / 240, (now - (shared.lastTs || now)) / 1000);
|
||||
shared.lastTs = now;
|
||||
|
||||
const attackTau = Math.max(0.001, DEFAULT_ATTACK_MS / 1000);
|
||||
const attackAlpha = 1 - Math.exp(-dt / attackTau);
|
||||
const releaseStep = DEFAULT_RELEASE_DB_PER_S * dt;
|
||||
|
||||
shared.target.L = targetL;
|
||||
shared.target.R = targetR;
|
||||
shared.values.L = applyBallistics(shared.values.L, targetL, attackAlpha, releaseStep);
|
||||
shared.values.R = applyBallistics(shared.values.R, targetR, attackAlpha, releaseStep);
|
||||
}
|
||||
|
||||
export function draw(g, rect, CONFIG = {}, shared) {
|
||||
if (!g || !rect || !shared) return;
|
||||
|
||||
syncAlignment(shared, CONFIG);
|
||||
|
||||
const colNorm = CONFIG.TP_COLOR_NORMAL || MID_COLOR;
|
||||
const colWarn = CONFIG.TP_COLOR_WARN || WARN_COLOR;
|
||||
const redOnly = CONFIG.TP_RED_BAR_ONLY !== false;
|
||||
const mapY = (db) => rect.y + (1 - norm(db)) * rect.h;
|
||||
|
||||
const innerPad = 8;
|
||||
const scaleW = 28;
|
||||
const gap = 8;
|
||||
const avail = rect.w - innerPad * 2 - scaleW - gap * 2;
|
||||
let barW = Math.max(10, Math.floor(avail / 2));
|
||||
barW = Math.floor(barW * (CONFIG.METER_BAR_THIN || 0.55));
|
||||
|
||||
const used = 2 * barW + scaleW + 2 * gap;
|
||||
const extra = Math.max(0, (rect.w - innerPad * 2) - used);
|
||||
const leftX = rect.x + innerPad + extra / 2;
|
||||
const scaleX = leftX + barW + gap;
|
||||
const rightX = scaleX + scaleW + gap;
|
||||
const centerX = scaleX + scaleW / 2;
|
||||
const centerLeft = leftX + barW / 2;
|
||||
const centerRight = rightX + barW / 2;
|
||||
const innerW = Math.max(2, barW - 2);
|
||||
const rawL = clamp(shared.values.L, HIFI_BOTTOM, HIFI_TOP);
|
||||
const rawR = clamp(shared.values.R, HIFI_BOTTOM, HIFI_TOP);
|
||||
const smooth = smoothHeader(shared, rawL, rawR);
|
||||
|
||||
g.save();
|
||||
const prevFont = g.font;
|
||||
g.font = 'bold 12px ui-monospace, monospace';
|
||||
g.fillStyle = HEADER_BG;
|
||||
g.fillRect(rect.x, rect.y - 24, rect.w, 24);
|
||||
g.textAlign = 'center';
|
||||
g.fillStyle = (rawL > HIFI_RED_START || rawR > HIFI_RED_START) ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
||||
const yText = rect.y - 12;
|
||||
g.fillStyle = rawL > HIFI_RED_START ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
||||
g.fillText(formatHeaderValue(smooth.L), centerLeft, yText);
|
||||
g.fillStyle = colNorm;
|
||||
g.fillText('|', centerX, yText);
|
||||
g.fillStyle = rawR > HIFI_RED_START ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
||||
g.fillText(formatHeaderValue(smooth.R), centerRight, yText);
|
||||
g.font = prevFont;
|
||||
g.restore();
|
||||
|
||||
drawBar(g, leftX, innerW, rawL, mapY, colNorm, colWarn, redOnly);
|
||||
drawBar(g, rightX, innerW, rawR, mapY, colNorm, colWarn, redOnly);
|
||||
|
||||
const now = performance.now();
|
||||
const holdOpts = {
|
||||
holdMs: DEFAULT_HOLD_MS,
|
||||
decayDbPerS: DEFAULT_HOLD_DECAY_DB_PER_S,
|
||||
floor: HIFI_BOTTOM,
|
||||
riseThreshold: 0.15,
|
||||
};
|
||||
shared.hold.L = stepPeakHold(shared.values.L, shared._holdState.L, now, holdOpts);
|
||||
shared.hold.R = stepPeakHold(shared.values.R, shared._holdState.R, now, holdOpts);
|
||||
|
||||
g.save();
|
||||
g.fillStyle = '#ffffff';
|
||||
g.fillRect(leftX + 1, mapY(shared.hold.L) - 1, innerW, 2);
|
||||
g.fillRect(rightX + 1, mapY(shared.hold.R) - 1, innerW, 2);
|
||||
g.restore();
|
||||
|
||||
const yTop = mapY(HIFI_TOP);
|
||||
const yRed = mapY(HIFI_RED_START);
|
||||
drawWarningEdges(g, leftX, barW, rightX, centerX, yRed, yTop, colWarn);
|
||||
drawBarTicks(g, leftX, rightX, innerW, mapY, colWarn);
|
||||
drawScale(g, { x: scaleX, y: rect.y, w: scaleW, h: rect.h }, centerX, mapY);
|
||||
|
||||
g.save();
|
||||
g.fillStyle = LABEL_COLOR;
|
||||
g.textAlign = 'center';
|
||||
const baseY = rect.y + rect.h + 16;
|
||||
g.fillText('L', leftX + barW / 2, baseY);
|
||||
g.fillText('R', rightX + barW / 2, baseY);
|
||||
const prevFooterFont = g.font;
|
||||
g.fillStyle = '#ffffff';
|
||||
g.font = 'bold 8.4px ui-monospace, monospace';
|
||||
g.fillText('dB', centerX, baseY);
|
||||
g.font = prevFooterFont;
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function applyBallistics(current, target, attackAlpha, releaseStep) {
|
||||
const prev = Number.isFinite(current) ? current : HIFI_BOTTOM;
|
||||
if (target >= prev) {
|
||||
return clamp(prev + (target - prev) * attackAlpha, HIFI_BOTTOM, HIFI_TOP);
|
||||
}
|
||||
return clamp(Math.max(target, prev - releaseStep), HIFI_BOTTOM, HIFI_TOP);
|
||||
}
|
||||
|
||||
function drawBar(g, x, width, value, mapY, colNorm, colWarn, redOnly) {
|
||||
const yVal = mapY(value);
|
||||
const yFloor = mapY(HIFI_BOTTOM);
|
||||
const yRed = mapY(HIFI_RED_START);
|
||||
|
||||
if (value > HIFI_RED_START) {
|
||||
if (redOnly) {
|
||||
const normTop = Math.min(yRed, yFloor);
|
||||
if (yFloor - normTop > 0) {
|
||||
g.fillStyle = colNorm;
|
||||
g.fillRect(x + 1, normTop, width, yFloor - normTop);
|
||||
}
|
||||
const warnTop = Math.min(yVal, yRed);
|
||||
if (yRed - warnTop > 0) {
|
||||
g.fillStyle = colWarn;
|
||||
g.fillRect(x + 1, warnTop, width, yRed - warnTop);
|
||||
}
|
||||
} else {
|
||||
g.fillStyle = colWarn;
|
||||
g.fillRect(x + 1, yVal, width, Math.max(0, yFloor - yVal));
|
||||
}
|
||||
} else {
|
||||
g.fillStyle = colNorm;
|
||||
g.fillRect(x + 1, yVal, width, Math.max(0, yFloor - yVal));
|
||||
}
|
||||
|
||||
g.globalAlpha = 0.12;
|
||||
g.fillStyle = '#ffffff';
|
||||
g.fillRect(x + 1, yVal, width, 2);
|
||||
g.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawScale(g, rect, centerX, mapY) {
|
||||
drawHairlineGrid(g, rect, mapY, HIFI_TOP, HIFI_BOTTOM, 1);
|
||||
|
||||
g.save();
|
||||
const prevFont = g.font;
|
||||
g.font = METER_HEADER_FONT;
|
||||
g.strokeStyle = 'rgba(143, 211, 212, 0.22)';
|
||||
g.lineWidth = 1;
|
||||
for (const db of MINOR_TICKS) {
|
||||
const y = Math.round(mapY(db)) + 0.5;
|
||||
g.beginPath();
|
||||
g.moveTo(centerX - 4, y);
|
||||
g.lineTo(centerX + 4, y);
|
||||
g.stroke();
|
||||
}
|
||||
for (const db of MAJOR_TICKS) {
|
||||
const y = Math.round(mapY(db)) + 0.5;
|
||||
g.beginPath();
|
||||
g.moveTo(centerX - 8, y);
|
||||
g.lineTo(centerX + 8, y);
|
||||
g.stroke();
|
||||
}
|
||||
g.fillStyle = LABEL_COLOR;
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'alphabetic';
|
||||
for (const db of MAJOR_TICKS) {
|
||||
const y = mapY(db);
|
||||
const label = db > 0 ? `+${db}` : `${db}`;
|
||||
g.fillText(label, centerX, y + 5);
|
||||
}
|
||||
g.font = prevFont;
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawWarningEdges(g, leftX, barW, rightX, centerX, yWarn, yTop, color) {
|
||||
const innerRight = leftX + barW;
|
||||
const gapL = Math.abs(centerX - innerRight);
|
||||
const insetL = Math.max(1, Math.floor(gapL * 0.35));
|
||||
const xL = Math.round(innerRight + insetL) + 0.5;
|
||||
|
||||
const innerLeft = rightX;
|
||||
const gapR = Math.abs(centerX - innerLeft);
|
||||
const insetR = Math.max(1, Math.floor(gapR * 0.35));
|
||||
const xR = Math.round(innerLeft - insetR) + 0.5;
|
||||
|
||||
const y1 = Math.min(yWarn, yTop);
|
||||
const y2 = Math.max(yWarn, yTop);
|
||||
|
||||
g.save();
|
||||
g.strokeStyle = color;
|
||||
g.lineWidth = 1;
|
||||
g.beginPath(); g.moveTo(xL, y1); g.lineTo(xL, y2); g.stroke();
|
||||
g.beginPath(); g.moveTo(xR, y1); g.lineTo(xR, y2); g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawBarTicks(g, leftX, rightX, widthPx, mapY, warnColor) {
|
||||
if (!g) return;
|
||||
|
||||
g.save();
|
||||
g.lineWidth = 1;
|
||||
const defaultColor = 'rgb(0,0,255)';
|
||||
const majorInset = 2;
|
||||
const minorFrac = 0.45;
|
||||
const majorWidth = Math.max(1, widthPx - 4 * majorInset);
|
||||
const minorWidth = Math.max(1, Math.floor(widthPx * minorFrac));
|
||||
const cxL = leftX + 1 + widthPx / 2;
|
||||
const cxR = rightX + 1 + widthPx / 2;
|
||||
|
||||
const drawPair = (yPix, width, color = defaultColor) => {
|
||||
g.strokeStyle = color;
|
||||
const x1L = Math.round(cxL - width / 2) + 0.5;
|
||||
const x2L = Math.round(cxL + width / 2) + 0.5;
|
||||
g.beginPath(); g.moveTo(x1L, yPix); g.lineTo(x2L, yPix); g.stroke();
|
||||
const x1R = Math.round(cxR - width / 2) + 0.5;
|
||||
const x2R = Math.round(cxR + width / 2) + 0.5;
|
||||
g.beginPath(); g.moveTo(x1R, yPix); g.lineTo(x2R, yPix); g.stroke();
|
||||
};
|
||||
|
||||
for (const db of MAJOR_TICKS) {
|
||||
const y = Math.round(mapY(db)) + 0.5;
|
||||
const color = db === HIFI_RED_START ? warnColor : defaultColor;
|
||||
drawPair(y, majorWidth, color);
|
||||
}
|
||||
for (const db of MINOR_TICKS) {
|
||||
const y = Math.round(mapY(db)) + 0.5;
|
||||
drawPair(y, minorWidth, defaultColor);
|
||||
}
|
||||
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function formatHeaderValue(v) {
|
||||
const n = Number.isFinite(v) ? v : HIFI_BOTTOM;
|
||||
const sign = n >= 0 ? '+' : '-';
|
||||
return `${sign}${Math.abs(n).toFixed(1)}`;
|
||||
}
|
||||
|
||||
function resolveHifiRefDbfsFor0(CONFIG = {}) {
|
||||
const base = Number.isFinite(CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU)
|
||||
? CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU
|
||||
: -15;
|
||||
const modeOffset = (CONFIG.PPM_DIN_MODE === 'al_minus6') ? -6 : -9;
|
||||
const trim = Number(CONFIG.PPM_DIN_TRIM_DB) || 0;
|
||||
const effOff = modeOffset + trim;
|
||||
const ppmDin0Dbfs = base - effOff;
|
||||
return (CONFIG.HIFI_PEAK_ALIGNMENT === 'ppm_din_zero')
|
||||
? ppmDin0Dbfs
|
||||
: (ppmDin0Dbfs - 5);
|
||||
}
|
||||
|
||||
function syncAlignment(shared, CONFIG = {}) {
|
||||
const nextRef = resolveHifiRefDbfsFor0(CONFIG);
|
||||
const prevRef = Number.isFinite(shared.refDbfsFor0) ? shared.refDbfsFor0 : nextRef;
|
||||
const shift = prevRef - nextRef;
|
||||
if (!Number.isFinite(shift) || Math.abs(shift) < 1e-6) {
|
||||
shared.refDbfsFor0 = nextRef;
|
||||
return;
|
||||
}
|
||||
|
||||
shared.refDbfsFor0 = nextRef;
|
||||
shared.target.L = clamp((shared.target.L ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP);
|
||||
shared.target.R = clamp((shared.target.R ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP);
|
||||
shared.values.L = clamp((shared.values.L ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP);
|
||||
shared.values.R = clamp((shared.values.R ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP);
|
||||
shared.hold.L = clamp((shared.hold.L ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP);
|
||||
shared.hold.R = clamp((shared.hold.R ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP);
|
||||
if (shared._holdState?.L) shared._holdState.L.value = clamp((shared._holdState.L.value ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP);
|
||||
if (shared._holdState?.R) shared._holdState.R.value = clamp((shared._holdState.R.value ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP);
|
||||
if (shared._header) {
|
||||
shared._header.L = clamp((shared._header.L ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP);
|
||||
shared._header.R = clamp((shared._header.R ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP);
|
||||
}
|
||||
}
|
||||
|
||||
function smoothHeader(shared, rawL, rawR, alpha = 0.2) {
|
||||
if (!shared._header) {
|
||||
shared._header = { L: rawL, R: rawR };
|
||||
return shared._header;
|
||||
}
|
||||
shared._header.L += alpha * (rawL - shared._header.L);
|
||||
shared._header.R += alpha * (rawR - shared._header.R);
|
||||
return shared._header;
|
||||
}
|
||||
|
||||
function norm(db) {
|
||||
const clamped = clamp(db, HIFI_BOTTOM, HIFI_TOP);
|
||||
return (clamped - HIFI_BOTTOM) / (HIFI_TOP - HIFI_BOTTOM);
|
||||
}
|
||||
|
||||
function clamp(v, lo, hi) {
|
||||
return Math.max(lo, Math.min(hi, v));
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { createPeakHoldState, stepPeakHold } from '../core/utils.js';
|
||||
|
||||
// meters/lufs.js — vereinfachtes LUFS-Display (Integrated, Momentary, Short-term) + LRA & TP
|
||||
// Hinweis: Der Audio-Worklet liefert aktuell keine echten LUFS-Filterausgänge.
|
||||
// Wir nähern LUFS über dBFS (RMS) an und integrieren per EMA:
|
||||
// - Momentary ≈ 400 ms
|
||||
// - Short-term ≈ 3 s
|
||||
// - Integrated = langsame EMA (ohne Gate)
|
||||
// TruePeak nehmen wir aus packet.tpL/tpR (dBFS), LRA aus Short-term vs. Momentary.
|
||||
|
||||
import { drawCachedStaticLayer } from './static_layer.js';
|
||||
import { LABEL_COLOR, OK_COLOR } from '../core/theme.js';
|
||||
|
||||
export const id = 'lufs';
|
||||
|
||||
export function initShared(CONFIG) {
|
||||
const now = performance.now();
|
||||
return {
|
||||
integ: -23.0,
|
||||
momentary: -23.0,
|
||||
shortTerm: -23.0,
|
||||
lra: 0.0,
|
||||
truePeak: -60.0,
|
||||
tpInstant: -60.0,
|
||||
_tpHoldState: createPeakHoldState(-60, now, CONFIG?.TP_HOLD_MS ?? CONFIG?.VU_HOLD_MS ?? 1000),
|
||||
_lastTs: now,
|
||||
};
|
||||
}
|
||||
|
||||
export function update(packet, shared) {
|
||||
// Prefer true LUFS from packet (computed in audio.js via K-weighted analysers)
|
||||
if (Number.isFinite(packet.lufsM)) shared.momentary = packet.lufsM;
|
||||
if (Number.isFinite(packet.lufsS)) shared.shortTerm = packet.lufsS;
|
||||
if (Number.isFinite(packet.lufsI)) shared.integ = packet.lufsI;
|
||||
if (Number.isFinite(packet.lra)) shared.lra = packet.lra;
|
||||
|
||||
// True Peak hold from packet
|
||||
if (Number.isFinite(packet.tpL) || Number.isFinite(packet.tpR)) {
|
||||
const tpL = Number.isFinite(packet.tpL) ? packet.tpL : -60;
|
||||
const tpR = Number.isFinite(packet.tpR) ? packet.tpR : -60;
|
||||
shared.tpInstant = Math.max(tpL, tpR);
|
||||
}
|
||||
}
|
||||
|
||||
export function draw(g, rect, CONFIG, shared) {
|
||||
// Skala: LUFS (negativ). Wir verwenden Bereich [-50 .. -5]
|
||||
const LUFS_TOP = -5, LUFS_BOTTOM = -50;
|
||||
const mapY = (lufs) => {
|
||||
const v = Math.max(LUFS_BOTTOM, Math.min(LUFS_TOP, lufs));
|
||||
const t = (v - LUFS_BOTTOM) / (LUFS_TOP - LUFS_BOTTOM);
|
||||
return rect.y + (1 - t) * rect.h;
|
||||
};
|
||||
|
||||
// 3 Spalten (Momentary, Short-term, Integrated)
|
||||
const innerPad = 8, gap = 10;
|
||||
const colW = Math.floor((rect.w - innerPad * 2 - gap * 2) / 3);
|
||||
const xM = rect.x + innerPad;
|
||||
const xS = xM + colW + gap;
|
||||
const xI = xS + colW + gap;
|
||||
|
||||
// Bars zeichnen
|
||||
drawLufsBar(g, { x: xM, y: rect.y, w: colW, h: rect.h }, shared.momentary, 'M', CONFIG, mapY);
|
||||
drawLufsBar(g, { x: xS, y: rect.y, w: colW, h: rect.h }, shared.shortTerm, 'S', CONFIG, mapY);
|
||||
drawLufsBar(g, { x: xI, y: rect.y, w: colW, h: rect.h }, shared.integ, 'I', CONFIG, mapY);
|
||||
|
||||
// True Peak hold smoothing
|
||||
const now = performance.now();
|
||||
const holdMs = Number.isFinite(CONFIG.TP_HOLD_MS) ? CONFIG.TP_HOLD_MS : (CONFIG.VU_HOLD_MS ?? 1000);
|
||||
const decay = Number.isFinite(CONFIG.TP_DECAY_DB_PER_S) ? CONFIG.TP_DECAY_DB_PER_S : 20;
|
||||
if (!shared._tpHoldState) {
|
||||
shared._tpHoldState = createPeakHoldState(shared.truePeak ?? -60, now, holdMs);
|
||||
}
|
||||
const tpInstant = Number.isFinite(shared.tpInstant) ? shared.tpInstant : -60;
|
||||
const tpValue = stepPeakHold(tpInstant, shared._tpHoldState, now, {
|
||||
holdMs,
|
||||
decayDbPerS: decay,
|
||||
floor: -60,
|
||||
riseThreshold: 0.1,
|
||||
});
|
||||
shared.truePeak = tpValue;
|
||||
|
||||
// Zusatz-Infos unterhalb
|
||||
// g.save();
|
||||
// g.fillStyle = '#8fd3d4';
|
||||
// g.textAlign = 'left';
|
||||
// g.fillText(`LRA: ${shared.lra.toFixed(1)} LU`, rect.x, rect.y + rect.h + 20);
|
||||
// g.fillText(`TP: ${shared.truePeak.toFixed(1)} dBFS`, rect.x, rect.y + rect.h + 38);
|
||||
// g.restore();
|
||||
|
||||
drawLufsStaticOverlay(g, shared, rect, CONFIG, { xM, xS, xI, colW }, mapY);
|
||||
}
|
||||
|
||||
function drawLufsStaticOverlay(g, shared, rect, CONFIG, geom, mapY) {
|
||||
const { xM, xS, xI, colW } = geom;
|
||||
const topPad = 10;
|
||||
const layerX = rect.x;
|
||||
const layerY = rect.y - topPad;
|
||||
const layerW = rect.w;
|
||||
const layerH = rect.h + topPad;
|
||||
const scaleCol = CONFIG?.LUFS_SCALE_LABEL_COLOR || LABEL_COLOR;
|
||||
const key = [
|
||||
'scale-font-header-v1-top-pad',
|
||||
Math.round(rect.w),
|
||||
Math.round(rect.h),
|
||||
topPad,
|
||||
scaleCol,
|
||||
].join('|');
|
||||
drawCachedStaticLayer(g, shared, 'lufs-static', key, layerX, layerY, layerW, layerH, (cg) => {
|
||||
cg.save();
|
||||
cg.translate(-layerX, -layerY);
|
||||
const drawRefs = (xBase) => {
|
||||
const col = { x: xBase, y: rect.y, w: colW, h: rect.h };
|
||||
for (const v of [-23, -18, -10, -5]) {
|
||||
const y = mapY(v);
|
||||
const isMajor = (v === -23 || v === -18 || v === -10);
|
||||
cg.save();
|
||||
cg.strokeStyle = scaleCol;
|
||||
cg.globalAlpha = isMajor ? 1 : 0.28;
|
||||
cg.setLineDash(isMajor ? [] : [2,2]);
|
||||
cg.beginPath(); cg.moveTo(col.x, y); cg.lineTo(col.x + col.w, y); cg.stroke();
|
||||
if (isMajor) {
|
||||
cg.globalAlpha = 1;
|
||||
cg.fillStyle = scaleCol; cg.textAlign = 'center';
|
||||
cg.fillText(`${v}`, col.x + col.w / 2, y - 4);
|
||||
}
|
||||
cg.restore();
|
||||
}
|
||||
};
|
||||
drawRefs(xM); drawRefs(xS); drawRefs(xI);
|
||||
cg.restore();
|
||||
});
|
||||
}
|
||||
|
||||
function drawLufsBar(g, rect, value, label, CONFIG, mapY) {
|
||||
const yVal = mapY(value);
|
||||
const yFloor = mapY(-50);
|
||||
const color = getLufsBarColor(label, CONFIG);
|
||||
|
||||
g.save();
|
||||
g.fillStyle = color;
|
||||
g.fillRect(rect.x + 1, yVal, Math.max(2, rect.w - 2), Math.max(0, yFloor - yVal));
|
||||
|
||||
g.fillStyle = '#ffffff';
|
||||
g.textAlign = 'center';
|
||||
g.fillText(label, rect.x + rect.w / 2, rect.y - 10);
|
||||
g.fillText(value.toFixed(1), rect.x + rect.w / 2, rect.y + rect.h + 18);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function getLufsBarColor(label, CONFIG = {}) {
|
||||
const fallback = CONFIG.LUFS_COLOR_GREEN || OK_COLOR;
|
||||
if (label === 'I') return CONFIG.LUFS_COLOR_I || CONFIG.LUFS_COLOR_I_GREEN || fallback;
|
||||
if (label === 'M') return CONFIG.LUFS_COLOR_M || CONFIG.LUFS_COLOR_M_GREEN || fallback;
|
||||
if (label === 'S') return CONFIG.LUFS_COLOR_S || CONFIG.LUFS_COLOR_S_GREEN || fallback;
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
import { createPeakHoldState, stepPeakHold } from '../core/utils.js';
|
||||
import { drawHairlineGrid, METER_HEADER_FONT } from './scale_helpers.js';
|
||||
import { drawCachedStaticLayer } from './static_layer.js';
|
||||
import { HEADER_BG, LABEL_COLOR, MID_COLOR, WARN_COLOR } from '../core/theme.js';
|
||||
|
||||
// meters/ppm_din.js — Peak Programme Meter, DIN Scale (IEC 60268-10 Type I)
|
||||
// Quasi-PPM mit optionalem Peak-Hold, DIN-Skala und bestehendem Farbschema.
|
||||
|
||||
export const id = 'ppm-din';
|
||||
|
||||
const DEFAULT_DECAY_DB_PER_S = 20 / 1.7; // 20 dB in ca. 1.7 s → ~11.76 dB/s
|
||||
const DEFAULT_HOLD_MS = 1000;
|
||||
|
||||
const DIN_SCALE = [
|
||||
{ db: -50, pos: 0.0205 },
|
||||
{ db: -40, pos: 0.0564 },
|
||||
{ db: -35, pos: 0.0974 },
|
||||
{ db: -30, pos: 0.1538 },
|
||||
{ db: -25, pos: 0.2308 },
|
||||
{ db: -20, pos: 0.3179 },
|
||||
{ db: -15, pos: 0.4359 },
|
||||
{ db: -10, pos: 0.5538 },
|
||||
{ db: -5, pos: 0.7026 },
|
||||
{ db: 0, pos: 0.8513 },
|
||||
{ db: +5, pos: 1.0000 },
|
||||
];
|
||||
|
||||
const PERCENT_MARKS = [
|
||||
{ label: '1', db: -40 },
|
||||
{ label: '10', db: -20 },
|
||||
{ label: '50', db: -6 },
|
||||
{ label: '100', db: 0 },
|
||||
{ label: '180', db: +5 },
|
||||
];
|
||||
|
||||
const MAJOR_TICKS_DB = [-50, -40, -30, -20, -10, -5, 0, +5];
|
||||
const MINOR_TICKS = [
|
||||
{ db: -35 },
|
||||
{ db: -25 },
|
||||
{ db: -21, color: 'warn' },
|
||||
{ db: -15 },
|
||||
{ db: -9 },
|
||||
{ db: -8 },
|
||||
{ db: -7 },
|
||||
{ db: -6 },
|
||||
{ db: -4 },
|
||||
{ db: -3 },
|
||||
{ db: -2 },
|
||||
{ db: -1 },
|
||||
{ db: +1 },
|
||||
{ db: +2 },
|
||||
{ db: +3 },
|
||||
{ db: +4 },
|
||||
];
|
||||
|
||||
export function initShared(CONFIG = {}) {
|
||||
const now = performance.now();
|
||||
const bottom = Number.isFinite(CONFIG.PPM_DIN_BOTTOM) ? CONFIG.PPM_DIN_BOTTOM : -50;
|
||||
const holdMs = Number.isFinite(CONFIG.PPM_DIN_HOLD_MS) ? CONFIG.PPM_DIN_HOLD_MS : DEFAULT_HOLD_MS;
|
||||
return {
|
||||
values: { L: bottom, R: bottom },
|
||||
hold: { L: bottom, R: bottom },
|
||||
loudnessDbfs: { L: null, R: null },
|
||||
_loudSmooth: null,
|
||||
_env: {
|
||||
L_dbfs: -90,
|
||||
R_dbfs: -90,
|
||||
lastTs: now,
|
||||
},
|
||||
_holdState: {
|
||||
L: createPeakHoldState(bottom, now, holdMs),
|
||||
R: createPeakHoldState(bottom, now, holdMs),
|
||||
},
|
||||
_holdCfg: {
|
||||
holdMs,
|
||||
decayDbPerS: Number.isFinite(CONFIG.PPM_DIN_HOLD_DECAY_DB_PER_S)
|
||||
? CONFIG.PPM_DIN_HOLD_DECAY_DB_PER_S
|
||||
: (Number.isFinite(CONFIG.PPM_DIN_DECAY_DB_PER_S) ? CONFIG.PPM_DIN_DECAY_DB_PER_S : DEFAULT_DECAY_DB_PER_S),
|
||||
},
|
||||
// Offset for DIN uses PPM_DIN_OFFSET so that 0 dB on the meter aligns
|
||||
// properly with the Permitted Maximum Level (PML). According to the
|
||||
// DIN Type I specification, 0 dBu (≈−15 dBFS peak) should read −9 dB.
|
||||
offset: Number(CONFIG.PPM_DIN_OFFSET) || 0,
|
||||
refDbfsFor0: Number.isFinite(CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU)
|
||||
? CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU
|
||||
: -15,
|
||||
};
|
||||
}
|
||||
|
||||
export function update(packet, shared) {
|
||||
const inL = Number.isFinite(packet?.ppmDinL) ? packet.ppmDinL : (Number.isFinite(packet?.ppmL) ? packet.ppmL : -90);
|
||||
const inR = Number.isFinite(packet?.ppmDinR) ? packet.ppmDinR : (Number.isFinite(packet?.ppmR) ? packet.ppmR : -90);
|
||||
const ppmBoxL = Number.isFinite(packet?.ppmBoxL) ? packet.ppmBoxL : null;
|
||||
const ppmBoxR = Number.isFinite(packet?.ppmBoxR) ? packet.ppmBoxR : null;
|
||||
shared._loudIsBox = Number.isFinite(ppmBoxL) || Number.isFinite(ppmBoxR);
|
||||
const fallbackLoud = Number.isFinite(packet?.lufsM)
|
||||
? packet.lufsM
|
||||
: Number.isFinite(packet?.lufsS)
|
||||
? packet.lufsS
|
||||
: Number.isFinite(packet?.lufsI)
|
||||
? packet.lufsI
|
||||
: null;
|
||||
const loudL = Number.isFinite(ppmBoxL)
|
||||
? ppmBoxL
|
||||
: (Number.isFinite(packet?.lufsML) ? packet.lufsML : fallbackLoud);
|
||||
const loudR = Number.isFinite(ppmBoxR)
|
||||
? ppmBoxR
|
||||
: (Number.isFinite(packet?.lufsMR) ? packet.lufsMR : fallbackLoud);
|
||||
|
||||
const base = Number.isFinite(shared.refDbfsFor0) ? shared.refDbfsFor0 : -15;
|
||||
const off = shared.offset || 0;
|
||||
// Verwende Worklet-Pegel direkt; Ballistik liegt bereits im AudioWorklet.
|
||||
shared._env.L_dbfs = inL;
|
||||
shared._env.R_dbfs = inR;
|
||||
const now = performance.now();
|
||||
shared._env.lastTs = now;
|
||||
shared.values.L = (inL - base) + off;
|
||||
shared.values.R = (inR - base) + off;
|
||||
|
||||
// Smoothe Loudness pro Kanal (LUFS M bevorzugt) für ruhige Overlay-Bewegung.
|
||||
if (!shared._loudSmooth) {
|
||||
shared._loudSmooth = {
|
||||
L: Number.isFinite(loudL) ? loudL : null,
|
||||
R: Number.isFinite(loudR) ? loudR : null,
|
||||
lastTs: now,
|
||||
};
|
||||
}
|
||||
const dtL = Math.max(1e-3, (now - (shared._loudSmooth.lastTs || now)) / 1000);
|
||||
const tau = 0.18; // ~180 ms Gleitzeit
|
||||
const alphaL = 1 - Math.exp(-dtL / tau);
|
||||
|
||||
const smoothVal = (prev, target) => {
|
||||
if (!Number.isFinite(target)) return { out: null, prev: prev };
|
||||
const baseVal = Number.isFinite(prev) ? prev : target;
|
||||
const out = baseVal + alphaL * (target - baseVal);
|
||||
return { out, prev: out };
|
||||
};
|
||||
|
||||
const resL = smoothVal(shared._loudSmooth.L, loudL);
|
||||
const resR = smoothVal(shared._loudSmooth.R, loudR);
|
||||
shared._loudSmooth.L = resL.prev;
|
||||
shared._loudSmooth.R = resR.prev;
|
||||
shared._loudSmooth.lastTs = now;
|
||||
shared.loudnessDbfs = { L: resL.out, R: resR.out };
|
||||
}
|
||||
|
||||
export function draw(g, rect, CONFIG = {}, shared) {
|
||||
// Use the DIN-specific offset for display. This ensures that DIN and EBU
|
||||
// scales do not influence each other. The effective offset is the user-
|
||||
// adjustable correction on top of the meter's built-in offset.
|
||||
const baseMode = (CONFIG.PPM_DIN_MODE === 'al_minus6') ? -6 : -9;
|
||||
const __effOff = baseMode + (Number(CONFIG.PPM_DIN_TRIM_DB) || 0);
|
||||
const __offCorr = __effOff - (shared.offset || 0);
|
||||
|
||||
const PPM_TOP = Number.isFinite(CONFIG.PPM_DIN_TOP) ? CONFIG.PPM_DIN_TOP : +5;
|
||||
const PPM_BOTTOM = Number.isFinite(CONFIG.PPM_DIN_BOTTOM) ? CONFIG.PPM_DIN_BOTTOM : -50;
|
||||
const EXT_BOTTOM = PPM_BOTTOM - 20; // Virtuelle Skalenverlängerung für Loudness-Boxen
|
||||
const RED_START = Number.isFinite(CONFIG.PPM_DIN_RED_START) ? CONFIG.PPM_DIN_RED_START : 0;
|
||||
const redOnly = CONFIG.PPM_RED_BAR_ONLY !== false;
|
||||
|
||||
const colNorm = CONFIG.PPM_DIN_COLOR_NORMAL || MID_COLOR;
|
||||
const colWarn = CONFIG.PPM_DIN_COLOR_WARN || WARN_COLOR;
|
||||
|
||||
const mapNorm = (db) => {
|
||||
const clamped = Math.max(PPM_BOTTOM, Math.min(PPM_TOP, db));
|
||||
const table = DIN_SCALE;
|
||||
let prev = table[0];
|
||||
for (let i = 1; i < table.length; i++) {
|
||||
const curr = table[i];
|
||||
if (clamped <= curr.db) {
|
||||
const span = curr.db - prev.db || 1;
|
||||
const t = (clamped - prev.db) / span;
|
||||
return prev.pos + t * (curr.pos - prev.pos);
|
||||
}
|
||||
prev = curr;
|
||||
}
|
||||
return table[table.length - 1].pos;
|
||||
};
|
||||
// DIN-Skala auf volle Höhe normalisieren: -50 dB sitzt am Rect-Boden, Proportionen bleiben.
|
||||
const normBottom = mapNorm(PPM_BOTTOM);
|
||||
const normSpan = Math.max(1e-6, 1 - normBottom);
|
||||
const mapY = (db) => {
|
||||
const n = mapNorm(db);
|
||||
const t = Math.max(0, Math.min(1, (n - normBottom) / normSpan));
|
||||
return rect.y + (1 - t) * rect.h;
|
||||
};
|
||||
|
||||
const innerPad = 8;
|
||||
const scaleW = 28;
|
||||
const gap = 8;
|
||||
const avail = rect.w - innerPad * 2 - scaleW - gap * 2;
|
||||
let barW = Math.max(10, Math.floor(avail / 2));
|
||||
barW = Math.floor(barW * (CONFIG.METER_BAR_THIN || 0.55));
|
||||
|
||||
const used = 2 * barW + scaleW + 2 * gap;
|
||||
const extra = Math.max(0, (rect.w - innerPad * 2) - used);
|
||||
const leftX = rect.x + innerPad + extra / 2;
|
||||
const scaleX = leftX + barW + gap;
|
||||
const rightX = scaleX + scaleW + gap;
|
||||
const centerX = scaleX + scaleW / 2;
|
||||
|
||||
const bottomLoud = PPM_BOTTOM - 20; // Loudness-Boxen dürfen 20 dB tiefer fallen als die Skala
|
||||
const clampDb = (v) => Math.max(PPM_BOTTOM, Math.min(PPM_TOP, v));
|
||||
const rawL = shared.values.L + __offCorr;
|
||||
const rawR = shared.values.R + __offCorr;
|
||||
const dbValL = clampDb(rawL);
|
||||
const dbValR = clampDb(rawR);
|
||||
const smooth = smoothHeader(shared, rawL, rawR);
|
||||
g.save();
|
||||
const prevFont = g.font;
|
||||
g.font = 'bold 12px ui-monospace, monospace';
|
||||
g.fillStyle = HEADER_BG;
|
||||
g.fillRect(rect.x, rect.y - 24, rect.w, 24);
|
||||
const headerWarn = CONFIG.PPM_DIN_HEADER_SHOW_VALUE && (rawL > RED_START || rawR > RED_START);
|
||||
g.fillStyle = headerWarn ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
||||
g.textAlign = 'center';
|
||||
if (CONFIG.PPM_DIN_HEADER_SHOW_VALUE) {
|
||||
const yText = rect.y - 12;
|
||||
const fmt = (v) => {
|
||||
const sign = v >= 0 ? '+' : '-';
|
||||
return `${sign}${Math.abs(v).toFixed(1)}`;
|
||||
};
|
||||
const centerLeft = leftX + barW / 2;
|
||||
const centerRight = rightX + barW / 2;
|
||||
const isRedL = rawL > RED_START;
|
||||
const isRedR = rawR > RED_START;
|
||||
g.textAlign = 'center';
|
||||
g.fillStyle = isRedL ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
||||
g.fillText(fmt(smooth.L), centerLeft, yText);
|
||||
g.fillStyle = colNorm;
|
||||
g.fillText('|', centerX, yText);
|
||||
g.fillStyle = isRedR ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
||||
g.fillText(fmt(smooth.R), centerRight, yText);
|
||||
} else {
|
||||
g.fillText('PPM (DIN)', centerX, rect.y - 12);
|
||||
}
|
||||
g.font = prevFont;
|
||||
g.restore();
|
||||
|
||||
const ppmL = mapClamp(dbValL, PPM_BOTTOM, PPM_TOP);
|
||||
const ppmR = mapClamp(dbValR, PPM_BOTTOM, PPM_TOP);
|
||||
const baseDbfs = Number.isFinite(shared.refDbfsFor0) ? shared.refDbfsFor0 : -15;
|
||||
const loudOff = Number(CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB) || 0;
|
||||
const loudnessDisplayL = (CONFIG.PPM_DIN_LOUDNESS_BOXES && Number.isFinite(shared.loudnessDbfs?.L))
|
||||
? (shared.loudnessDbfs.L - baseDbfs) + (shared.offset || 0) + __offCorr + loudOff
|
||||
: null;
|
||||
const loudnessDisplayR = (CONFIG.PPM_DIN_LOUDNESS_BOXES && Number.isFinite(shared.loudnessDbfs?.R))
|
||||
? (shared.loudnessDbfs.R - baseDbfs) + (shared.offset || 0) + __offCorr + loudOff
|
||||
: null;
|
||||
|
||||
const yBottom = mapY(PPM_BOTTOM);
|
||||
const yRed = mapY(RED_START);
|
||||
const yTop = mapY(PPM_TOP);
|
||||
const innerW = Math.max(10, barW - 2);
|
||||
const drawBar = (x0, dbVal) => {
|
||||
const yVal = mapY(dbVal);
|
||||
|
||||
if (dbVal > RED_START) {
|
||||
if (redOnly) {
|
||||
const yNormTop = Math.min(yRed, yBottom);
|
||||
if (yBottom - yNormTop > 0) {
|
||||
g.fillStyle = colNorm;
|
||||
g.fillRect(x0 + 1, yNormTop, innerW, yBottom - yNormTop);
|
||||
}
|
||||
const yWarnTop = Math.min(yVal, yRed);
|
||||
if (yRed - yWarnTop > 0) {
|
||||
g.fillStyle = colWarn;
|
||||
g.fillRect(x0 + 1, yWarnTop, innerW, yRed - yWarnTop);
|
||||
}
|
||||
} else {
|
||||
if (yBottom - yVal > 0) {
|
||||
g.fillStyle = colWarn;
|
||||
g.fillRect(x0 + 1, yVal, innerW, yBottom - yVal);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (yBottom - yVal > 0) {
|
||||
g.fillStyle = colNorm;
|
||||
g.fillRect(x0 + 1, yVal, innerW, yBottom - yVal);
|
||||
}
|
||||
}
|
||||
g.globalAlpha = 0.12;
|
||||
g.fillStyle = '#ffffff';
|
||||
g.fillRect(x0 + 1, yVal, innerW, 2);
|
||||
g.globalAlpha = 1;
|
||||
};
|
||||
|
||||
drawBar(leftX, ppmL);
|
||||
drawBar(rightX, ppmR);
|
||||
|
||||
// Peak-Hold (optional, rein visuell; eigentliche Ballistik kommt aus dem Worklet)
|
||||
const holdMs = Number.isFinite(CONFIG.PPM_DIN_HOLD_MS)
|
||||
? Math.max(0, CONFIG.PPM_DIN_HOLD_MS)
|
||||
: (shared?._holdCfg?.holdMs ?? DEFAULT_HOLD_MS);
|
||||
const holdDecay = Number.isFinite(CONFIG.PPM_DIN_HOLD_DECAY_DB_PER_S)
|
||||
? CONFIG.PPM_DIN_HOLD_DECAY_DB_PER_S
|
||||
: (Number.isFinite(CONFIG.PPM_DIN_DECAY_DB_PER_S) ? CONFIG.PPM_DIN_DECAY_DB_PER_S : DEFAULT_DECAY_DB_PER_S);
|
||||
if (holdMs > 0) {
|
||||
if (!shared.hold) shared.hold = { L: PPM_BOTTOM, R: PPM_BOTTOM };
|
||||
const cfgChanged = !shared._holdState
|
||||
|| !shared._holdCfg
|
||||
|| shared._holdCfg.holdMs !== holdMs
|
||||
|| shared._holdCfg.decayDbPerS !== holdDecay;
|
||||
if (cfgChanged) {
|
||||
const resetNow = performance.now();
|
||||
shared._holdState = {
|
||||
L: createPeakHoldState(shared.hold.L ?? PPM_BOTTOM, resetNow, holdMs),
|
||||
R: createPeakHoldState(shared.hold.R ?? PPM_BOTTOM, resetNow, holdMs),
|
||||
};
|
||||
shared._holdCfg = { holdMs, decayDbPerS: holdDecay };
|
||||
}
|
||||
const nowHold = performance.now();
|
||||
const holdOpts = {
|
||||
holdMs,
|
||||
decayDbPerS: holdDecay,
|
||||
floor: PPM_BOTTOM,
|
||||
riseThreshold: 0.2,
|
||||
};
|
||||
shared.hold.L = stepPeakHold(ppmL, shared._holdState.L, nowHold, holdOpts);
|
||||
shared.hold.R = stepPeakHold(ppmR, shared._holdState.R, nowHold, holdOpts);
|
||||
g.save();
|
||||
g.fillStyle = '#ffffff';
|
||||
g.fillRect(leftX + 1, mapY(shared.hold.L) - 1, innerW, 2);
|
||||
g.fillRect(rightX + 1, mapY(shared.hold.R) - 1, innerW, 2);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
const drawLoudBox = (x0, loudVal) => {
|
||||
if (!Number.isFinite(loudVal)) return;
|
||||
const boxSize = barW; // exakt so breit wie der sichtbare Balken (keine 1px-Ränder)
|
||||
// Messwert sitzt an der oberen Kante; Skala wird nach unten verlängert.
|
||||
// Bei -50 dB (Bottom) liegt die Oberkante im sichtbaren Bereich,
|
||||
// bei -70 dB ist sie eine volle Meterhöhe weiter unten (außerhalb des Sichtfelds).
|
||||
const spanExt = Math.max(1e-6, PPM_BOTTOM - EXT_BOTTOM);
|
||||
const yBottom = mapY(PPM_BOTTOM);
|
||||
const yLoud = (loudVal < PPM_BOTTOM)
|
||||
? yBottom + ((PPM_BOTTOM - loudVal) / spanExt) * rect.h
|
||||
: mapY(loudVal);
|
||||
// Zeichne nur den sichtbaren Teil: Box-Oberkante = Pegel, Unterkante nicht unter yBottom.
|
||||
const boxY = yLoud;
|
||||
const visibleHeight = Math.max(0, Math.min(boxSize, yBottom - boxY));
|
||||
if (visibleHeight <= 0) return;
|
||||
const x = x0;
|
||||
g.save();
|
||||
g.fillStyle = '#0b6ea8';
|
||||
g.globalAlpha = 0.92;
|
||||
g.fillRect(x, boxY, boxSize, visibleHeight);
|
||||
g.globalAlpha = 1;
|
||||
g.strokeStyle = '#094b73';
|
||||
g.lineWidth = 1;
|
||||
g.strokeRect(x + 0.5, boxY + 0.5, boxSize - 1, visibleHeight - 1);
|
||||
g.restore();
|
||||
};
|
||||
|
||||
drawLoudBox(leftX, loudnessDisplayL);
|
||||
drawLoudBox(rightX, loudnessDisplayR);
|
||||
|
||||
drawPpmDinStaticOverlay(g, shared, rect, CONFIG, {
|
||||
leftX,
|
||||
rightX,
|
||||
barW,
|
||||
innerW,
|
||||
scaleX,
|
||||
scaleW,
|
||||
centerX,
|
||||
yRed,
|
||||
yTop,
|
||||
colWarn,
|
||||
}, mapY, PPM_TOP, PPM_BOTTOM);
|
||||
}
|
||||
|
||||
function drawPpmDinStaticOverlay(g, shared, rect, CONFIG, geom, mapY, PPM_TOP, PPM_BOTTOM) {
|
||||
const {
|
||||
leftX,
|
||||
rightX,
|
||||
barW,
|
||||
innerW,
|
||||
scaleX,
|
||||
scaleW,
|
||||
centerX,
|
||||
yRed,
|
||||
yTop,
|
||||
colWarn,
|
||||
} = geom;
|
||||
const topPad = 10;
|
||||
const sidePad = 0;
|
||||
const layerX = rect.x - sidePad;
|
||||
const layerY = rect.y - topPad;
|
||||
const layerW = rect.w + sidePad * 2;
|
||||
const layerH = rect.h + 24 + topPad;
|
||||
const key = [
|
||||
'scale-font-header-v1-top-pad',
|
||||
Math.round(rect.w),
|
||||
Math.round(rect.h),
|
||||
topPad,
|
||||
sidePad,
|
||||
CONFIG.METER_BAR_THIN || 0.55,
|
||||
CONFIG.PPM_DIN_MODE || 'al_minus9',
|
||||
CONFIG.AL_MARKERS_ENABLED === false ? 0 : 1,
|
||||
CONFIG.PPM_DIN_TOP ?? 5,
|
||||
CONFIG.PPM_DIN_BOTTOM ?? -50,
|
||||
CONFIG.PPM_DIN_RED_START ?? 0,
|
||||
METER_HEADER_FONT,
|
||||
colWarn,
|
||||
].join('|');
|
||||
drawCachedStaticLayer(g, shared, 'ppm-din-static', key, layerX, layerY, layerW, layerH, (cg) => {
|
||||
cg.save();
|
||||
cg.translate(-layerX, -layerY);
|
||||
drawWarningEdges(cg, leftX, barW, rightX, centerX, yRed, yTop, colWarn);
|
||||
drawBarTicks(cg, leftX, rightX, innerW, mapY, PPM_TOP, PPM_BOTTOM, colWarn, CONFIG);
|
||||
drawCenterScale(cg, { x: scaleX, y: rect.y, w: scaleW, h: rect.h }, centerX, mapY, PPM_TOP, PPM_BOTTOM);
|
||||
drawPercentScale(cg, leftX, rightX, barW, mapY, PPM_BOTTOM);
|
||||
cg.fillStyle = LABEL_COLOR;
|
||||
cg.textAlign = 'center';
|
||||
const baseY = rect.y + rect.h + 16;
|
||||
cg.fillText('L', leftX + barW / 2, baseY);
|
||||
cg.fillText('R', rightX + barW / 2, baseY);
|
||||
const prevFontLabels = cg.font;
|
||||
cg.fillStyle = '#ffffff';
|
||||
cg.font = 'bold 8.4px ui-monospace, monospace';
|
||||
cg.fillText('dB', centerX, baseY);
|
||||
cg.font = prevFontLabels;
|
||||
cg.restore();
|
||||
});
|
||||
}
|
||||
|
||||
function mapClamp(db, bottom, top) {
|
||||
return Math.max(bottom, Math.min(top, db));
|
||||
}
|
||||
|
||||
function drawWarningEdges(g, leftX, barW, rightX, centerX, yWarn, yTop, color) {
|
||||
const innerRight = leftX + barW;
|
||||
const gapL = Math.abs(centerX - innerRight);
|
||||
const insetL = Math.max(1, Math.floor(gapL * 0.35));
|
||||
const xL = Math.round(innerRight + insetL) + 0.5;
|
||||
|
||||
const innerLeft = rightX;
|
||||
const gapR = Math.abs(centerX - innerLeft);
|
||||
const insetR = Math.max(1, Math.floor(gapR * 0.35));
|
||||
const xR = Math.round(innerLeft - insetR) + 0.5;
|
||||
|
||||
const y1 = Math.min(yWarn, yTop);
|
||||
const y2 = Math.max(yWarn, yTop);
|
||||
|
||||
g.save();
|
||||
g.strokeStyle = color;
|
||||
g.lineWidth = 1;
|
||||
g.beginPath(); g.moveTo(xL, y1); g.lineTo(xL, y2); g.stroke();
|
||||
g.beginPath(); g.moveTo(xR, y1); g.lineTo(xR, y2); g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawBarTicks(g, leftX, rightX, widthPx, mapY, top, bottom, warnColor, CONFIG) {
|
||||
g.save();
|
||||
g.lineWidth = 1;
|
||||
|
||||
const DEFAULT_COLOR = 'rgb(0,0,255)';
|
||||
const MAJOR_INSET = 2;
|
||||
const MINOR_FRAC = 0.45;
|
||||
|
||||
const cxL = leftX + 1 + widthPx / 2;
|
||||
const cxR = rightX + 1 + widthPx / 2;
|
||||
|
||||
const drawPair = (yPix, width, color = DEFAULT_COLOR) => {
|
||||
g.save();
|
||||
g.strokeStyle = color;
|
||||
const span = Math.max(1, width);
|
||||
const x1L = Math.round(cxL - span / 2) + 0.5;
|
||||
const x2L = Math.round(cxL + span / 2) + 0.5;
|
||||
g.beginPath(); g.moveTo(x1L, yPix); g.lineTo(x2L, yPix); g.stroke();
|
||||
const x1R = Math.round(cxR - span / 2) + 0.5;
|
||||
const x2R = Math.round(cxR + span / 2) + 0.5;
|
||||
g.beginPath(); g.moveTo(x1R, yPix); g.lineTo(x2R, yPix); g.stroke();
|
||||
g.restore();
|
||||
};
|
||||
|
||||
const minorWidth = Math.max(1, Math.floor(widthPx * MINOR_FRAC));
|
||||
const majorWidth = Math.max(1, widthPx - 4 * MAJOR_INSET);
|
||||
|
||||
const majors = MAJOR_TICKS_DB
|
||||
.filter((db) => db >= bottom && db <= top)
|
||||
.slice()
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
for (const db of majors) {
|
||||
const y = Math.round(mapY(db)) + 0.5;
|
||||
drawPair(y, majorWidth);
|
||||
}
|
||||
|
||||
const warn = warnColor || DEFAULT_COLOR;
|
||||
const showAl = CONFIG?.AL_MARKERS_ENABLED !== false;
|
||||
const minors = MINOR_TICKS
|
||||
.filter((tick) => tick.db >= bottom && tick.db <= top)
|
||||
.slice()
|
||||
.sort((a, b) => a.db - b.db);
|
||||
|
||||
const dynWarnDb = showAl
|
||||
? ((CONFIG?.PPM_DIN_MODE === 'al_minus6') ? -6 : -9)
|
||||
: null;
|
||||
|
||||
for (const tick of minors) {
|
||||
const y = Math.round(mapY(tick.db)) + 0.5;
|
||||
const isDynWarn = dynWarnDb !== null && tick.db === dynWarnDb;
|
||||
const color = (tick.color === 'warn' || isDynWarn) ? warn : DEFAULT_COLOR;
|
||||
drawPair(y, minorWidth, color);
|
||||
}
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawCenterScale(g, rect, centerX, mapY, top, bottom) {
|
||||
drawHairlineGrid(g, rect, mapY, top, bottom, 1);
|
||||
|
||||
g.save();
|
||||
const prevFont = g.font;
|
||||
g.font = METER_HEADER_FONT;
|
||||
g.fillStyle = LABEL_COLOR;
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'alphabetic';
|
||||
|
||||
const majors = MAJOR_TICKS_DB
|
||||
.filter((db) => db >= bottom && db <= top)
|
||||
.slice()
|
||||
.sort((a, b) => b - a);
|
||||
|
||||
for (const db of majors) {
|
||||
const y = mapY(db);
|
||||
if (y < rect.y || y > rect.y + rect.h) continue;
|
||||
const label = db > 0 ? `+${db}` : `${db}`;
|
||||
g.fillText(label, centerX, y + 5);
|
||||
}
|
||||
|
||||
g.font = prevFont;
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawPercentScale(g, leftX, rightX, barW, mapY, bottomDb) {
|
||||
g.save();
|
||||
g.fillStyle = '#ffb347';
|
||||
const prevFont = g.font;
|
||||
g.font = 'bold 8px ui-monospace, monospace';
|
||||
const marks = PERCENT_MARKS.slice().sort((a, b) => a.db - b.db);
|
||||
|
||||
g.textAlign = 'right';
|
||||
for (const mark of marks) {
|
||||
g.fillText(mark.label, leftX - 1, mapY(mark.db) + 4);
|
||||
}
|
||||
|
||||
g.textAlign = 'left';
|
||||
const rightTextX = rightX + barW + 1;
|
||||
for (const mark of marks) {
|
||||
g.fillText(mark.label, rightTextX, mapY(mark.db) + 4);
|
||||
}
|
||||
|
||||
const percentYOffset = 12;
|
||||
g.textAlign = 'right';
|
||||
g.fillText('%', leftX - 1, mapY(bottomDb) + percentYOffset);
|
||||
g.textAlign = 'left';
|
||||
g.fillText('%', rightTextX, mapY(bottomDb) + percentYOffset);
|
||||
g.font = prevFont;
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function smoothHeader(shared, rawL, rawR, alpha = 0.2) {
|
||||
if (!shared._header) {
|
||||
shared._header = { L: rawL, R: rawR };
|
||||
return shared._header;
|
||||
}
|
||||
shared._header.L += alpha * (rawL - shared._header.L);
|
||||
shared._header.R += alpha * (rawR - shared._header.R);
|
||||
return shared._header;
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
import { createPeakHoldState, stepPeakHold } from '../core/utils.js';
|
||||
import { drawHairlineGrid, METER_HEADER_FONT } from './scale_helpers.js';
|
||||
import { drawCachedStaticLayer } from './static_layer.js';
|
||||
import { HEADER_BG, LABEL_COLOR, MID_COLOR, WARN_COLOR } from '../core/theme.js';
|
||||
|
||||
// meters/ppm_ebu.js — PPM (EBU) mit linearer Skala
|
||||
// - Messbereich: -14 … +14 dB (Clamping/Anzeigegrenzen)
|
||||
// - Rote Zone ab +9 dB
|
||||
// - Ticks: große Marken bei -12/-8/-4/0/+4/+8/+12, kleine u. a. bei -10/-6/…/+10
|
||||
// - Labels: nur für die großen Ticks; bei 0 statt "0" → "TEST"
|
||||
|
||||
export const id = 'ppm-ebu';
|
||||
|
||||
const DEFAULT_ATTACK_MS = 10; // Type IIb typisch ~10 ms
|
||||
const DEFAULT_DECAY_DB_PER_S = 8.6; // ~24 dB in 2.8 s
|
||||
const DEFAULT_HOLD_MS = 750;
|
||||
|
||||
const EBU_MAJOR_TICKS = [-12, -8, -4, 0, +4, +8, +12];
|
||||
const EBU_MINOR_TICKS = [-10, -6, -2, +2, +6, +9, +10];
|
||||
|
||||
export function initShared(CONFIG = {}) {
|
||||
const now = performance.now();
|
||||
// Use EBU-specific configuration values. PPM_EBU_BOTTOM defines the
|
||||
// bottom of the EBU scale (default -14 dB) and PPM_EBU_HOLD_MS defines
|
||||
// the peak-hold duration. This ensures the EBU meter aligns with
|
||||
// the Type IIb specification (0 dB at AL, +9 dB red start).
|
||||
const bottom = Number.isFinite(CONFIG.PPM_EBU_BOTTOM) ? CONFIG.PPM_EBU_BOTTOM : -14;
|
||||
const holdMs = Number.isFinite(CONFIG.PPM_EBU_HOLD_MS) ? CONFIG.PPM_EBU_HOLD_MS : DEFAULT_HOLD_MS;
|
||||
const decayCfg = Number.isFinite(CONFIG.PPM_EBU_DECAY_DB_PER_S)
|
||||
? CONFIG.PPM_EBU_DECAY_DB_PER_S
|
||||
: DEFAULT_DECAY_DB_PER_S;
|
||||
|
||||
return {
|
||||
values: { L: bottom, R: bottom },
|
||||
hold: { L: bottom, R: bottom },
|
||||
_env: {
|
||||
L_dbfs: -90,
|
||||
R_dbfs: -90,
|
||||
lastTs: now,
|
||||
},
|
||||
_ballistics: {
|
||||
// The EBU (Type IIb) PPM uses a ~10 ms attack and a decay of 24 dB
|
||||
// in 2.8 s (≈8.6 dB/s).
|
||||
attackMs: Number.isFinite(CONFIG.PPM_EBU_ATTACK_MS) ? CONFIG.PPM_EBU_ATTACK_MS : DEFAULT_ATTACK_MS,
|
||||
decayDbPerS: decayCfg,
|
||||
},
|
||||
_holdState: {
|
||||
L: createPeakHoldState(bottom, now, holdMs),
|
||||
R: createPeakHoldState(bottom, now, holdMs),
|
||||
},
|
||||
_holdCfg: {
|
||||
holdMs,
|
||||
decayDbPerS: Number.isFinite(CONFIG.PPM_EBU_HOLD_DECAY_DB_PER_S)
|
||||
? CONFIG.PPM_EBU_HOLD_DECAY_DB_PER_S
|
||||
: decayCfg,
|
||||
},
|
||||
// Use the EBU-specific offset so that 0 dBu (alignment level) reads 0 dB
|
||||
// on the EBU meter. This avoids the global PPM_OFFSET interfering.
|
||||
offset: Number(CONFIG.PPM_EBU_OFFSET) || 0,
|
||||
// Referenz: 0 dB Anzeige entspricht typ. ~-15 dBFS Peak
|
||||
refDbfsFor0: Number.isFinite(CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU)
|
||||
? CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU
|
||||
: -15,
|
||||
};
|
||||
}
|
||||
|
||||
export function update(packet, shared) {
|
||||
const inL = Number.isFinite(packet?.ppmEbuL) ? packet.ppmEbuL : (Number.isFinite(packet?.ppmL) ? packet.ppmL : -90);
|
||||
const inR = Number.isFinite(packet?.ppmEbuR) ? packet.ppmEbuR : (Number.isFinite(packet?.ppmR) ? packet.ppmR : -90);
|
||||
|
||||
const base = Number.isFinite(shared.refDbfsFor0) ? shared.refDbfsFor0 : -15;
|
||||
const off = shared.offset || 0;
|
||||
// Ballistik liegt im Worklet; hier nur Offset anwenden, um Timer-Jitter im UI zu vermeiden.
|
||||
shared._env.L_dbfs = inL;
|
||||
shared._env.R_dbfs = inR;
|
||||
shared._env.lastTs = performance.now();
|
||||
shared.values.L = (inL - base) + off;
|
||||
shared.values.R = (inR - base) + off;
|
||||
}
|
||||
|
||||
export function draw(g, rect, CONFIG = {}, shared) {
|
||||
// Effective user-correction offset for EBU: use PPM_EBU_OFFSET instead of global PPM_OFFSET.
|
||||
const __effOff = Number(CONFIG.PPM_EBU_OFFSET) || 0;
|
||||
const __offCorr = __effOff - (shared.offset || 0);
|
||||
|
||||
const PPM_TOP = Number.isFinite(CONFIG.PPM_EBU_TOP) ? CONFIG.PPM_EBU_TOP : +14;
|
||||
const PPM_BOTTOM = Number.isFinite(CONFIG.PPM_EBU_BOTTOM) ? CONFIG.PPM_EBU_BOTTOM : -14;
|
||||
const RED_START = Number.isFinite(CONFIG.PPM_EBU_RED_START) ? CONFIG.PPM_EBU_RED_START : +9;
|
||||
|
||||
const redOnly = CONFIG.PPM_RED_BAR_ONLY !== false;
|
||||
const colNorm = CONFIG.PPM_EBU_COLOR_NORMAL || MID_COLOR;
|
||||
const colWarn = CONFIG.PPM_EBU_COLOR_WARN || WARN_COLOR;
|
||||
|
||||
// LINEARE Skalenabbildung
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
const mapNorm = (db) => {
|
||||
const d = clamp(db, PPM_BOTTOM, PPM_TOP);
|
||||
return (d - PPM_BOTTOM) / (PPM_TOP - PPM_BOTTOM); // 0..1
|
||||
};
|
||||
const mapY = (db) => rect.y + (1 - mapNorm(db)) * rect.h;
|
||||
|
||||
// Layout
|
||||
const innerPad = 8;
|
||||
const scaleW = 28;
|
||||
const gap = 8;
|
||||
const avail = rect.w - innerPad * 2 - scaleW - gap * 2;
|
||||
let barW = Math.max(10, Math.floor(avail / 2));
|
||||
barW = Math.floor(barW * (CONFIG.METER_BAR_THIN || 0.55));
|
||||
|
||||
const used = 2 * barW + scaleW + 2 * gap;
|
||||
const extra = Math.max(0, (rect.w - innerPad * 2) - used);
|
||||
const leftX = rect.x + innerPad + extra / 2;
|
||||
const scaleX = leftX + barW + gap;
|
||||
const rightX = scaleX + scaleW + gap;
|
||||
const centerX = scaleX + scaleW / 2;
|
||||
const centerLeft = leftX + barW / 2;
|
||||
const centerRight = rightX + barW / 2;
|
||||
|
||||
// Werte + Clamping
|
||||
const rawL = shared.values.L + __offCorr;
|
||||
const rawR = shared.values.R + __offCorr;
|
||||
const ppmL = clamp(rawL, PPM_BOTTOM, PPM_TOP);
|
||||
const ppmR = clamp(rawR, PPM_BOTTOM, PPM_TOP);
|
||||
const smooth = smoothHeader(shared, rawL, rawR);
|
||||
g.save();
|
||||
const prevFont = g.font;
|
||||
g.font = 'bold 12px ui-monospace, monospace';
|
||||
g.fillStyle = HEADER_BG;
|
||||
g.fillRect(rect.x, rect.y - 24, rect.w, 24);
|
||||
const headerWarn = CONFIG.PPM_EBU_HEADER_SHOW_VALUE && (rawL > RED_START || rawR > RED_START);
|
||||
g.fillStyle = headerWarn ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
||||
g.textAlign = 'center';
|
||||
if (CONFIG.PPM_EBU_HEADER_SHOW_VALUE) {
|
||||
const yText = rect.y - 12;
|
||||
const fmt = (v) => {
|
||||
const sign = v >= 0 ? '+' : '-';
|
||||
return `${sign}${Math.abs(v).toFixed(1)}`;
|
||||
};
|
||||
const isRedL = rawL > RED_START;
|
||||
const isRedR = rawR > RED_START;
|
||||
g.textAlign = 'center';
|
||||
g.fillStyle = isRedL ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
||||
g.fillText(fmt(smooth.L), centerLeft, yText);
|
||||
g.fillStyle = colNorm;
|
||||
g.fillText('|', centerX, yText);
|
||||
g.fillStyle = isRedR ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
||||
g.fillText(fmt(smooth.R), centerRight, yText);
|
||||
} else {
|
||||
g.fillText('PPM (EBU)', centerX, rect.y - 12);
|
||||
}
|
||||
g.font = prevFont;
|
||||
g.restore();
|
||||
|
||||
const yBottom = mapY(PPM_BOTTOM);
|
||||
const yRed = mapY(RED_START);
|
||||
const yTop = mapY(PPM_TOP);
|
||||
const innerW = Math.max(2, barW - 2);
|
||||
|
||||
const drawBar = (x0, dbVal) => {
|
||||
const yVal = mapY(dbVal);
|
||||
if (dbVal > RED_START) {
|
||||
if (redOnly) {
|
||||
const yNormTop = Math.min(yRed, yBottom);
|
||||
if (yBottom - yNormTop > 0) {
|
||||
g.fillStyle = colNorm;
|
||||
g.fillRect(x0 + 1, yNormTop, innerW, yBottom - yNormTop);
|
||||
}
|
||||
const yWarnTop = Math.min(yVal, yRed);
|
||||
if (yRed - yWarnTop > 0) {
|
||||
g.fillStyle = colWarn;
|
||||
g.fillRect(x0 + 1, yWarnTop, innerW, yRed - yWarnTop);
|
||||
}
|
||||
} else {
|
||||
if (yBottom - yVal > 0) {
|
||||
g.fillStyle = colWarn;
|
||||
g.fillRect(x0 + 1, yVal, innerW, yBottom - yVal);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (yBottom - yVal > 0) {
|
||||
g.fillStyle = colNorm;
|
||||
g.fillRect(x0 + 1, yVal, innerW, yBottom - yVal);
|
||||
}
|
||||
}
|
||||
g.globalAlpha = 0.12;
|
||||
g.fillStyle = '#ffffff';
|
||||
g.fillRect(x0 + 1, yVal, innerW, 2);
|
||||
g.globalAlpha = 1;
|
||||
};
|
||||
|
||||
drawBar(leftX, ppmL);
|
||||
drawBar(rightX, ppmR);
|
||||
|
||||
// Peak-Hold
|
||||
const holdMs = Number.isFinite(CONFIG.PPM_EBU_HOLD_MS)
|
||||
? Math.max(0, CONFIG.PPM_EBU_HOLD_MS)
|
||||
: (shared._holdCfg?.holdMs ?? DEFAULT_HOLD_MS);
|
||||
const holdDecay = Number.isFinite(CONFIG.PPM_EBU_HOLD_DECAY_DB_PER_S)
|
||||
? CONFIG.PPM_EBU_HOLD_DECAY_DB_PER_S
|
||||
: (shared._holdCfg?.decayDbPerS ?? shared._ballistics.decayDbPerS ?? DEFAULT_DECAY_DB_PER_S);
|
||||
|
||||
if (holdMs > 0) {
|
||||
if (!shared.hold) shared.hold = { L: PPM_BOTTOM, R: PPM_BOTTOM };
|
||||
const cfgChanged = !shared._holdState
|
||||
|| !shared._holdCfg
|
||||
|| shared._holdCfg.holdMs !== holdMs
|
||||
|| shared._holdCfg.decayDbPerS !== holdDecay;
|
||||
|
||||
if (cfgChanged) {
|
||||
const resetNow = performance.now();
|
||||
shared._holdState = {
|
||||
L: createPeakHoldState(shared.hold.L ?? PPM_BOTTOM, resetNow, holdMs),
|
||||
R: createPeakHoldState(shared.hold.R ?? PPM_BOTTOM, resetNow, holdMs),
|
||||
};
|
||||
shared._holdCfg = { holdMs, decayDbPerS: holdDecay };
|
||||
}
|
||||
|
||||
const nowHold = performance.now();
|
||||
const holdOpts = {
|
||||
holdMs,
|
||||
decayDbPerS: holdDecay,
|
||||
floor: PPM_BOTTOM,
|
||||
riseThreshold: 0.2,
|
||||
};
|
||||
shared.hold.L = stepPeakHold(ppmL, shared._holdState.L, nowHold, holdOpts);
|
||||
shared.hold.R = stepPeakHold(ppmR, shared._holdState.R, nowHold, holdOpts);
|
||||
|
||||
g.save();
|
||||
g.fillStyle = '#ffffff';
|
||||
g.fillRect(leftX + 1, mapY(shared.hold.L) - 1, innerW, 2);
|
||||
g.fillRect(rightX + 1, mapY(shared.hold.R) - 1, innerW, 2);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
drawPpmEbuStaticOverlay(g, shared, rect, CONFIG, {
|
||||
leftX,
|
||||
rightX,
|
||||
barW,
|
||||
innerW,
|
||||
scaleX,
|
||||
scaleW,
|
||||
centerX,
|
||||
yRed,
|
||||
yTop,
|
||||
colWarn,
|
||||
}, mapY, PPM_TOP, PPM_BOTTOM);
|
||||
}
|
||||
|
||||
function drawPpmEbuStaticOverlay(g, shared, rect, CONFIG, geom, mapY, PPM_TOP, PPM_BOTTOM) {
|
||||
const {
|
||||
leftX,
|
||||
rightX,
|
||||
barW,
|
||||
innerW,
|
||||
scaleX,
|
||||
scaleW,
|
||||
centerX,
|
||||
yRed,
|
||||
yTop,
|
||||
colWarn,
|
||||
} = geom;
|
||||
const topPad = 10;
|
||||
const layerX = rect.x;
|
||||
const layerY = rect.y - topPad;
|
||||
const layerW = rect.w;
|
||||
const layerH = rect.h + 24 + topPad;
|
||||
const key = [
|
||||
'scale-font-header-v1-top-pad',
|
||||
Math.round(rect.w),
|
||||
Math.round(rect.h),
|
||||
topPad,
|
||||
CONFIG.METER_BAR_THIN || 0.55,
|
||||
CONFIG.PPM_EBU_TOP ?? 14,
|
||||
CONFIG.PPM_EBU_BOTTOM ?? -14,
|
||||
CONFIG.PPM_EBU_RED_START ?? 9,
|
||||
CONFIG.AL_MARKERS_ENABLED === false ? 0 : 1,
|
||||
METER_HEADER_FONT,
|
||||
colWarn,
|
||||
].join('|');
|
||||
drawCachedStaticLayer(g, shared, 'ppm-ebu-static', key, layerX, layerY, layerW, layerH, (cg) => {
|
||||
cg.save();
|
||||
cg.translate(-layerX, -layerY);
|
||||
drawWarningEdges(cg, leftX, barW, rightX, centerX, yRed, yTop, colWarn);
|
||||
drawBarTicks(cg, leftX, rightX, innerW, mapY, PPM_TOP, PPM_BOTTOM, CONFIG);
|
||||
drawCenterScale(cg, { x: scaleX, y: rect.y, w: scaleW, h: rect.h }, centerX, mapY, +12, -12);
|
||||
cg.fillStyle = LABEL_COLOR;
|
||||
cg.textAlign = 'center';
|
||||
const baseY = rect.y + rect.h + 16;
|
||||
cg.fillText('L', leftX + barW / 2, baseY);
|
||||
cg.fillText('R', rightX + barW / 2, baseY);
|
||||
const prevFontLabels = cg.font;
|
||||
cg.fillStyle = '#ffffff';
|
||||
cg.font = 'bold 8.4px ui-monospace, monospace';
|
||||
cg.fillText('dB', centerX, baseY);
|
||||
cg.font = prevFontLabels;
|
||||
cg.restore();
|
||||
});
|
||||
}
|
||||
|
||||
function drawWarningEdges(g, leftX, barW, rightX, centerX, yWarn, yTop, color) {
|
||||
const innerRight = leftX + barW;
|
||||
const gapL = Math.abs(centerX - innerRight);
|
||||
const insetL = Math.max(1, Math.floor(gapL * 0.35));
|
||||
const xL = Math.round(innerRight + insetL) + 0.5;
|
||||
|
||||
const innerLeft = rightX;
|
||||
const gapR = Math.abs(centerX - innerLeft);
|
||||
const insetR = Math.max(1, Math.floor(gapR * 0.35));
|
||||
const xR = Math.round(innerLeft - insetR) + 0.5;
|
||||
|
||||
const y1 = Math.min(yWarn, yTop);
|
||||
const y2 = Math.max(yWarn, yTop);
|
||||
|
||||
g.save();
|
||||
g.strokeStyle = color;
|
||||
g.lineWidth = 1;
|
||||
g.beginPath(); g.moveTo(xL, y1); g.lineTo(xL, y2); g.stroke();
|
||||
g.beginPath(); g.moveTo(xR, y1); g.lineTo(xR, y2); g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawBarTicks(g, leftX, rightX, widthPx, mapY, top, bottom, CONFIG) {
|
||||
g.save();
|
||||
g.lineWidth = 1;
|
||||
|
||||
const DEFAULT_COLOR = 'rgb(0,0,255)';
|
||||
const AL_COLOR = WARN_COLOR;
|
||||
const showAL = CONFIG?.AL_MARKERS_ENABLED !== false;
|
||||
const highlightDb = showAL ? 0 : null;
|
||||
|
||||
const MAJOR_INSET = 2;
|
||||
const MINOR_FRAC = 0.45;
|
||||
|
||||
const cxL = leftX + 1 + widthPx / 2;
|
||||
const cxR = rightX + 1 + widthPx / 2;
|
||||
|
||||
const drawPair = (yPix, span, highlight = false) => {
|
||||
g.strokeStyle = highlight ? AL_COLOR : DEFAULT_COLOR;
|
||||
const width = Math.max(1, span);
|
||||
const x1L = Math.round(cxL - width / 2) + 0.5;
|
||||
const x2L = Math.round(cxL + width / 2) + 0.5;
|
||||
g.beginPath(); g.moveTo(x1L, yPix); g.lineTo(x2L, yPix); g.stroke();
|
||||
const x1R = Math.round(cxR - width / 2) + 0.5;
|
||||
const x2R = Math.round(cxR + width / 2) + 0.5;
|
||||
g.beginPath(); g.moveTo(x1R, yPix); g.lineTo(x2R, yPix); g.stroke();
|
||||
};
|
||||
|
||||
const majorWidth = Math.max(1, widthPx - 4 * MAJOR_INSET);
|
||||
const minorWidth = Math.max(1, Math.floor(widthPx * MINOR_FRAC));
|
||||
let highlightMatched = false;
|
||||
|
||||
for (const db of EBU_MAJOR_TICKS) {
|
||||
if (db < bottom || db > top) continue;
|
||||
const y = Math.round(mapY(db)) + 0.5;
|
||||
const isHighlight = highlightDb !== null && Math.abs(db - highlightDb) < 1e-3;
|
||||
if (isHighlight) highlightMatched = true;
|
||||
drawPair(y, majorWidth, isHighlight);
|
||||
}
|
||||
|
||||
for (const db of EBU_MINOR_TICKS) {
|
||||
if (db < bottom || db > top) continue;
|
||||
const y = Math.round(mapY(db)) + 0.5;
|
||||
const isHighlight = highlightDb !== null && Math.abs(db - highlightDb) < 1e-3;
|
||||
if (isHighlight) highlightMatched = true;
|
||||
drawPair(y, minorWidth, isHighlight);
|
||||
}
|
||||
|
||||
if (highlightDb !== null && !highlightMatched && highlightDb >= bottom && highlightDb <= top) {
|
||||
const y = Math.round(mapY(highlightDb)) + 0.5;
|
||||
drawPair(y, majorWidth, true);
|
||||
}
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawCenterScale(g, rect, centerX, mapY, top, bottom) {
|
||||
drawHairlineGrid(g, rect, mapY, top, bottom, 1);
|
||||
|
||||
g.save();
|
||||
const prevFont = g.font;
|
||||
g.font = METER_HEADER_FONT;
|
||||
g.fillStyle = LABEL_COLOR;
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'alphabetic';
|
||||
|
||||
for (const db of EBU_MAJOR_TICKS) {
|
||||
if (db < bottom || db > top) continue;
|
||||
const y = mapY(db);
|
||||
if (y < rect.y || y > rect.y + rect.h) continue;
|
||||
const label = db === 0 ? 'TEST' : (db > 0 ? `+${db}` : `${db}`);
|
||||
g.fillText(label, centerX, y + 5);
|
||||
}
|
||||
g.font = prevFont;
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function smoothHeader(shared, rawL, rawR, alpha = 0.2) {
|
||||
if (!shared._header) {
|
||||
shared._header = { L: rawL, R: rawR };
|
||||
return shared._header;
|
||||
}
|
||||
shared._header.L += alpha * (rawL - shared._header.L);
|
||||
shared._header.R += alpha * (rawR - shared._header.R);
|
||||
return shared._header;
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
// meters/rms.js — True RMS (dBu/dBFS) L/R mit dualem Modus, Kalibrierung & Warnschwelle
|
||||
// Erwartet update(packet) mit packet.rmsL / packet.rmsR (in dBFS).
|
||||
// Offset via CONFIG.RMS_OFFSET_DB (wirkt vor der dBu-Umrechnung).
|
||||
//
|
||||
// Defaults jetzt DIGITAL:
|
||||
// - CONFIG.RMS_MODE bleibt auf 'dbfs', damit das Meter direkt dBFS ausgibt.
|
||||
// - CONFIG.RMS_REF_DBFS_FOR_REF_DBU: Referenz in dBFS RMS (default: -18 für 0 dBu)
|
||||
// - CONFIG.RMS_REF_DBU: Referenz in dBu RMS (default: 0)
|
||||
// -> Umrechnung: dBu = (dBFS_RMS - REF_DBFS) + REF_DBU, falls jemals benötigt.
|
||||
// - Titel passt sich an: "RMS (dBu)" oder "RMS (dBFS RMS)" je nach Modus.
|
||||
// - Skalenobergrenze: dBu: +24 (typisch Pro-Audio Headroom) / dBFS: +5
|
||||
// - Standard-Warnschwelle: dBu: +20 / dBFS: 0 (überschreibbar via CONFIG.RMS_RED_START)
|
||||
|
||||
import { drawCachedStaticLayer } from './static_layer.js';
|
||||
import { METER_HEADER_FONT } from './scale_helpers.js';
|
||||
import { HEADER_BG, LABEL_COLOR, OK_COLOR, WARN_COLOR } from '../core/theme.js';
|
||||
|
||||
const RMS_FRAME_MS_NOMINAL = 16; // ~60 Hz UI-Refresh
|
||||
const RMS_FRAME_MS_MAX = 40; // Obergrenze für dt in der Glättung
|
||||
const RMS_FRAME_MS_SKIP = 250; // Heuristischer Schutz: riesige Gaps komplett skippen
|
||||
|
||||
export const id = 'rms';
|
||||
|
||||
export function initShared(CONFIG = {}) {
|
||||
const offset = CONFIG.RMS_OFFSET_DB || 0;
|
||||
const initVal = -60 + offset;
|
||||
return {
|
||||
values: { L: initVal, R: initVal }, // intern immer dBFS RMS aus der Messkette
|
||||
offset,
|
||||
lastValidL: initVal,
|
||||
lastValidR: initVal,
|
||||
_smooth: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function update(packet, shared) {
|
||||
const offset = shared.offset || 0;
|
||||
const floor = -60 + offset;
|
||||
if (!Number.isFinite(shared.lastValidL)) shared.lastValidL = floor;
|
||||
if (!Number.isFinite(shared.lastValidR)) shared.lastValidR = floor;
|
||||
|
||||
if (Number.isFinite(packet?.rmsL)) {
|
||||
const vL = packet.rmsL + offset;
|
||||
shared.values.L = vL;
|
||||
shared.lastValidL = vL;
|
||||
} else {
|
||||
shared.values.L = shared.lastValidL;
|
||||
}
|
||||
|
||||
if (Number.isFinite(packet?.rmsR)) {
|
||||
const vR = packet.rmsR + offset;
|
||||
shared.values.R = vR;
|
||||
shared.lastValidR = vR;
|
||||
} else {
|
||||
shared.values.R = shared.lastValidR;
|
||||
}
|
||||
}
|
||||
|
||||
export function draw(g, rect, CONFIG = {}, shared) {
|
||||
const MODE = (CONFIG.RMS_MODE ?? 'dbfs').toLowerCase(); // Default jetzt 'dbfs'
|
||||
|
||||
// Referenz: -18 dBFS RMS ≙ +4 dBu (übliches Studio-Line-Up). Anpassbar.
|
||||
const REF_DBFS = Number.isFinite(CONFIG.RMS_REF_DBFS_FOR_REF_DBU) ? CONFIG.RMS_REF_DBFS_FOR_REF_DBU : -18;
|
||||
const REF_DBU = Number.isFinite(CONFIG.RMS_REF_DBU) ? CONFIG.RMS_REF_DBU : +4;
|
||||
|
||||
// Anzeige-Umschaltung & Skala
|
||||
const isDBU = (MODE === 'dbu');
|
||||
const LOG_MIN = -60;
|
||||
const LOG_TOP = isDBU ? +24 : 0;
|
||||
|
||||
const RED_START =
|
||||
Number.isFinite(CONFIG.RMS_RED_START)
|
||||
? CONFIG.RMS_RED_START
|
||||
: (isDBU ? +20 : 0);
|
||||
|
||||
// Umrechnung auf Anzeigeeinheit
|
||||
const toDisplay = (dbfsRms) => {
|
||||
if (!Number.isFinite(dbfsRms)) return -Infinity;
|
||||
return isDBU ? (dbfsRms - REF_DBFS + REF_DBU) : dbfsRms;
|
||||
};
|
||||
|
||||
// Skalen-Mapping: unterhalb −20 stärker komprimiert, darüber nahezu linear.
|
||||
function scaleNorm(db) {
|
||||
if (!Number.isFinite(db)) return 0;
|
||||
if (db <= LOG_MIN) return 0;
|
||||
|
||||
if (isDBU) {
|
||||
// dBu: −60…−20 (45%), −20…0 (+25%), 0…+20 (+25%), +20…+24 (+5%)
|
||||
if (db <= -20) { const t = (db + 60) / 40; return Math.pow(t, 1.6) * 0.45; } // → 0…0.45
|
||||
if (db <= 0) { const t = (db + 20) / 20; return 0.45 + t * 0.25; } // → 0.70
|
||||
if (db <= +20) { const t = (db ) / 20; return 0.70 + t * 0.25; } // → 0.95
|
||||
if (db <= +24) { const t = (db - 20) / 4; return 0.95 + t * 0.05; } // → 1.00
|
||||
return 1;
|
||||
} else {
|
||||
// dBFS: linear 0 … −60
|
||||
if (db >= LOG_TOP) return 1;
|
||||
return (db - LOG_MIN) / (LOG_TOP - LOG_MIN);
|
||||
}
|
||||
}
|
||||
const mapY = (db) => rect.y + (1 - Math.max(0, Math.min(1, scaleNorm(db)))) * rect.h;
|
||||
|
||||
// Layout
|
||||
const innerPad = 8, scaleW = 26, gap = 8;
|
||||
const avail = rect.w - innerPad * 2 - scaleW - gap * 2;
|
||||
let barW = Math.max(8, Math.floor(avail / 2));
|
||||
barW = Math.floor(barW * (CONFIG.METER_BAR_THIN || 0.55));
|
||||
|
||||
const used = 2 * barW + scaleW + 2 * gap;
|
||||
const extra = Math.max(0, (rect.w - innerPad * 2) - used);
|
||||
const leftX = rect.x + innerPad + extra / 2;
|
||||
const scaleX = leftX + barW + gap;
|
||||
const rightX = scaleX + scaleW + gap;
|
||||
const centerX = scaleX + scaleW / 2;
|
||||
const centerLeft = leftX + barW / 2;
|
||||
const centerRight = rightX + barW / 2;
|
||||
|
||||
// Header-Hintergrund säubern
|
||||
g.save();
|
||||
g.fillStyle = HEADER_BG;
|
||||
g.fillRect(rect.x, rect.y - 24, rect.w, 24);
|
||||
g.restore();
|
||||
|
||||
// IEC-ähnliche Zeitkonstanten (Impulse/Fast/Slow) für die Anzeige
|
||||
const tauMs = resolveRmsTau(CONFIG);
|
||||
if (tauMs > 0) {
|
||||
const now = performance.now();
|
||||
if (!shared._smooth) {
|
||||
shared._smooth = {
|
||||
L: shared.values.L,
|
||||
R: shared.values.R,
|
||||
lastTs: now - RMS_FRAME_MS_NOMINAL,
|
||||
};
|
||||
}
|
||||
const lastTs = shared._smooth.lastTs ?? (now - RMS_FRAME_MS_NOMINAL);
|
||||
const dtRawMs = Math.max(0, now - lastTs);
|
||||
shared._smooth.lastTs = now;
|
||||
|
||||
if (dtRawMs <= RMS_FRAME_MS_SKIP) {
|
||||
const dtUsedMs = Math.min(dtRawMs, RMS_FRAME_MS_MAX); // clamp dt to ignore occasional large gaps
|
||||
const alpha = 1 - Math.exp(-dtUsedMs / tauMs);
|
||||
shared._smooth.L += alpha * (shared.values.L - shared._smooth.L);
|
||||
shared._smooth.R += alpha * (shared.values.R - shared._smooth.R);
|
||||
}
|
||||
} else {
|
||||
shared._smooth = null;
|
||||
}
|
||||
|
||||
// Werte in Anzeigeeinheit clampen
|
||||
const rawDispL = toDisplay(shared._smooth?.L ?? shared.values.L);
|
||||
const rawDispR = toDisplay(shared._smooth?.R ?? shared.values.R);
|
||||
const smooth = smoothHeader(shared, rawDispL, rawDispR);
|
||||
const vL = clamp(rawDispL, LOG_MIN, LOG_TOP);
|
||||
const vR = clamp(rawDispR, LOG_MIN, LOG_TOP);
|
||||
g.save();
|
||||
const headerWarn = CONFIG.RMS_HEADER_SHOW_VALUE && (rawDispL > RED_START || rawDispR > RED_START);
|
||||
const prevFont = g.font;
|
||||
g.font = 'bold 12px ui-monospace, monospace';
|
||||
g.fillStyle = headerWarn ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
||||
g.textAlign = 'center';
|
||||
if (CONFIG.RMS_HEADER_SHOW_VALUE) {
|
||||
const yText = rect.y - 12;
|
||||
const fmt = (v) => {
|
||||
const sign = v >= 0 ? '+' : '-';
|
||||
return `${sign}${Math.abs(v).toFixed(1)}`;
|
||||
};
|
||||
g.textAlign = 'center';
|
||||
const isRedL = rawDispL > RED_START;
|
||||
const isRedR = rawDispR > RED_START;
|
||||
g.fillStyle = isRedL ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
||||
g.fillText(fmt(smooth.L), centerLeft, yText);
|
||||
g.fillStyle = CONFIG.HEADER_TEXT_COLOR || MID_COLOR;
|
||||
g.fillText('|', centerX, yText);
|
||||
g.fillStyle = isRedR ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
||||
g.fillText(fmt(smooth.R), centerRight, yText);
|
||||
} else {
|
||||
const title = isDBU ? 'RMS (dBu)' : 'RMS (dBFS RMS)';
|
||||
g.fillText(title, centerX, rect.y - 12);
|
||||
}
|
||||
g.font = prevFont;
|
||||
g.restore();
|
||||
|
||||
const yFloor = mapY(LOG_MIN);
|
||||
const redStart = clamp(RED_START, LOG_MIN, LOG_TOP);
|
||||
const yRed = mapY(redStart);
|
||||
const innerW = Math.max(2, barW - 2);
|
||||
|
||||
const colNorm = CONFIG.RMS_COLOR_NORMAL || OK_COLOR;
|
||||
const colWarn = CONFIG.RMS_COLOR_WARN || WARN_COLOR;
|
||||
|
||||
const drawBar = (x0, valDisp) => {
|
||||
const yVal = mapY(valDisp);
|
||||
|
||||
if (valDisp > RED_START) {
|
||||
if (CONFIG.RMS_RED_BAR_ONLY) {
|
||||
// normaler Teil (bis Warnschwelle)
|
||||
const yNormTop = Math.min(yRed, yFloor);
|
||||
g.fillStyle = colNorm; g.fillRect(x0 + 1, yNormTop, innerW, Math.max(0, yFloor - yNormTop));
|
||||
// warnender Teil
|
||||
const yWarnTop = Math.min(yVal, yRed);
|
||||
g.fillStyle = colWarn; g.fillRect(x0 + 1, yWarnTop, innerW, Math.max(0, yRed - yWarnTop));
|
||||
} else {
|
||||
g.fillStyle = colWarn; g.fillRect(x0 + 1, yVal, innerW, Math.max(0, yFloor - yVal));
|
||||
}
|
||||
} else {
|
||||
g.fillStyle = colNorm; g.fillRect(x0 + 1, yVal, innerW, Math.max(0, yFloor - yVal));
|
||||
}
|
||||
// Glanzkante
|
||||
g.globalAlpha = .12; g.fillStyle = '#fff'; g.fillRect(x0 + 1, yVal, innerW, 2); g.globalAlpha = 1;
|
||||
};
|
||||
|
||||
drawBar(leftX, vL);
|
||||
drawBar(rightX, vR);
|
||||
|
||||
const majors = isDBU ? [24, 20, 10, 0, -10, -20, -30, -40, -50, -60]
|
||||
: [0, -6, -12, -18, -24, -30, -40, -60];
|
||||
const minorValues = isDBU ? null : buildDbfsMinorTicks();
|
||||
const alignmentHighlight = (!isDBU) ? getRmsAlignmentHighlight(CONFIG) : null;
|
||||
drawRmsStaticOverlay(g, shared, rect, CONFIG, {
|
||||
leftX,
|
||||
rightX,
|
||||
barW,
|
||||
innerW,
|
||||
scaleX,
|
||||
scaleW,
|
||||
centerX,
|
||||
yRed,
|
||||
mapY,
|
||||
LOG_TOP,
|
||||
LOG_MIN,
|
||||
isDBU,
|
||||
majors,
|
||||
minorValues,
|
||||
alignmentHighlight,
|
||||
});
|
||||
}
|
||||
|
||||
function drawRmsStaticOverlay(g, shared, rect, CONFIG, geom) {
|
||||
const {
|
||||
leftX,
|
||||
rightX,
|
||||
barW,
|
||||
innerW,
|
||||
scaleX,
|
||||
scaleW,
|
||||
centerX,
|
||||
yRed,
|
||||
mapY,
|
||||
LOG_TOP,
|
||||
LOG_MIN,
|
||||
isDBU,
|
||||
majors,
|
||||
minorValues,
|
||||
alignmentHighlight,
|
||||
} = geom;
|
||||
const topPad = 10;
|
||||
const layerX = rect.x;
|
||||
const layerY = rect.y - topPad;
|
||||
const layerW = rect.w;
|
||||
const layerH = rect.h + 24 + topPad;
|
||||
const key = [
|
||||
'scale-font-header-v1-top-pad',
|
||||
Math.round(rect.w),
|
||||
Math.round(rect.h),
|
||||
topPad,
|
||||
CONFIG.METER_BAR_THIN || 0.55,
|
||||
CONFIG.RMS_MODE || 'dbfs',
|
||||
CONFIG.AL_MARKERS_ENABLED === false ? 0 : 1,
|
||||
CONFIG.RMS_RED_START ?? '',
|
||||
METER_HEADER_FONT,
|
||||
].join('|');
|
||||
drawCachedStaticLayer(g, shared, 'rms-static', key, layerX, layerY, layerW, layerH, (cg) => {
|
||||
cg.save();
|
||||
cg.translate(-layerX, -layerY);
|
||||
drawOverlayTicksLR(cg, leftX, rightX, innerW, mapY, LOG_TOP, LOG_MIN, majors, minorValues, alignmentHighlight);
|
||||
const yTop = mapY(LOG_TOP);
|
||||
drawRedStripeLR(cg, leftX, barW, rightX, centerX, mapY, yRed, yTop);
|
||||
const scaleRect = { x: scaleX, y: rect.y, w: scaleW, h: rect.h };
|
||||
drawScale(cg, scaleRect, centerX, mapY, {
|
||||
isDBU,
|
||||
majors,
|
||||
minors: minorValues,
|
||||
hairStep: isDBU ? null : 1,
|
||||
topValue: LOG_TOP,
|
||||
bottomValue: LOG_MIN,
|
||||
highlightTick: null,
|
||||
});
|
||||
const unitLabel = 'dBFS (RMS)';
|
||||
cg.fillStyle = LABEL_COLOR;
|
||||
cg.textAlign = 'center';
|
||||
const baseY = rect.y + rect.h + 16;
|
||||
cg.fillText('L', leftX + barW / 2, baseY);
|
||||
cg.fillText('R', rightX + barW / 2, baseY);
|
||||
const prevFontLabels = cg.font;
|
||||
cg.fillStyle = '#ffffff';
|
||||
cg.font = 'bold 8.4px ui-monospace, monospace';
|
||||
cg.fillText(unitLabel, centerX, baseY);
|
||||
cg.font = prevFontLabels;
|
||||
cg.restore();
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Helpers =====
|
||||
|
||||
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
|
||||
|
||||
function resolveRmsTau(CONFIG = {}) {
|
||||
const mode = String(CONFIG.RMS_TC_MODE || 'fast').toLowerCase();
|
||||
const custom = Number(CONFIG.RMS_TC_MS);
|
||||
if (Number.isFinite(custom) && custom > 0) return custom;
|
||||
switch (mode) {
|
||||
case 'impulse': return 35;
|
||||
case 'slow': return 1000;
|
||||
case 'none': return 0;
|
||||
case 'fast':
|
||||
default: return 125;
|
||||
}
|
||||
}
|
||||
|
||||
function drawRedStripeLR(g, leftX, barW, rightX, centerX, mapY, yWarn, yTop) {
|
||||
const innerRight = leftX + barW;
|
||||
const gapL = Math.abs(centerX - innerRight);
|
||||
const insetL = Math.max(1, Math.floor(gapL * 0.35));
|
||||
const xL = Math.round(innerRight + insetL) + 0.5;
|
||||
|
||||
const innerLeft = rightX;
|
||||
const gapR = Math.abs(centerX - innerLeft);
|
||||
const insetR = Math.max(1, Math.floor(gapR * 0.35));
|
||||
const xR = Math.round(innerLeft - insetR) + 0.5;
|
||||
|
||||
const y1 = Math.min(yWarn, yTop), y2 = Math.max(yWarn, yTop);
|
||||
g.save(); g.strokeStyle = WARN_COLOR; g.lineWidth = 1;
|
||||
g.beginPath(); g.moveTo(xL, y1); g.lineTo(xL, y2); g.stroke();
|
||||
g.beginPath(); g.moveTo(xR, y1); g.lineTo(xR, y2); g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawOverlayTicksLR(g, leftX, rightX, widthPx, mapY, TOP, BOTTOM, majors, minorValues = null, highlightTick = null) {
|
||||
g.save();
|
||||
|
||||
const MAJOR_INSET = 2;
|
||||
const MINOR_FRAC = 0.45;
|
||||
|
||||
const cxL = leftX + 1 + widthPx / 2;
|
||||
const cxR = rightX + 1 + widthPx / 2;
|
||||
|
||||
const approx = (a, b) => Math.abs(a - b) < 0.01;
|
||||
|
||||
const drawMajor = (value) => {
|
||||
const majorW = Math.max(1, widthPx - 4 * MAJOR_INSET);
|
||||
const yPix = Math.round(mapY(value)) + 0.5;
|
||||
const isHighlight = highlightTick && approx(value, highlightTick.value);
|
||||
const color = isHighlight ? highlightTick.color : 'rgb(0,0,255)';
|
||||
const lineWidth = isHighlight ? 1.8 : 1;
|
||||
|
||||
const x1L = Math.round(cxL - majorW / 2) + 0.5;
|
||||
const x2L = Math.round(cxL + majorW / 2) + 0.5;
|
||||
g.strokeStyle = color; g.lineWidth = lineWidth;
|
||||
g.beginPath(); g.moveTo(x1L, yPix); g.lineTo(x2L, yPix); g.stroke();
|
||||
|
||||
const x1R = Math.round(cxR - majorW / 2) + 0.5;
|
||||
const x2R = Math.round(cxR + majorW / 2) + 0.5;
|
||||
g.beginPath(); g.moveTo(x1R, yPix); g.lineTo(x2R, yPix); g.stroke();
|
||||
};
|
||||
|
||||
const drawMinor = (value) => {
|
||||
const minorW = Math.max(1, Math.floor(widthPx * MINOR_FRAC));
|
||||
const yPix = Math.round(mapY(value)) + 0.5;
|
||||
const isHighlight = highlightTick && approx(value, highlightTick.value);
|
||||
const color = isHighlight ? highlightTick.color : 'rgba(0,0,255,0.55)';
|
||||
const lineWidth = isHighlight ? 1.4 : 1;
|
||||
|
||||
const x1L = Math.round(cxL - minorW / 2) + 0.5;
|
||||
const x2L = Math.round(cxL + minorW / 2) + 0.5;
|
||||
g.strokeStyle = color; g.lineWidth = lineWidth;
|
||||
g.beginPath(); g.moveTo(x1L, yPix); g.lineTo(x2L, yPix); g.stroke();
|
||||
|
||||
const x1R = Math.round(cxR - minorW / 2) + 0.5;
|
||||
const x2R = Math.round(cxR + minorW / 2) + 0.5;
|
||||
g.beginPath(); g.moveTo(x1R, yPix); g.lineTo(x2R, yPix); g.stroke();
|
||||
};
|
||||
|
||||
const top = TOP, bot = BOTTOM;
|
||||
const hi = Math.max(top, bot), lo = Math.min(top, bot);
|
||||
const inRange = (v) => v <= hi && v >= lo;
|
||||
|
||||
if (Array.isArray(majors) && majors.length > 0) {
|
||||
const sorted = majors.slice().sort((a, b) => b - a); // desc
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const vMaj = sorted[i];
|
||||
if (inRange(vMaj)) {
|
||||
drawMajor(vMaj);
|
||||
}
|
||||
if (minorValues == null && i < sorted.length - 1) {
|
||||
const vNext = sorted[i + 1];
|
||||
const mid = (vMaj + vNext) / 2;
|
||||
if (inRange(mid)) {
|
||||
drawMinor(mid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(minorValues) && minorValues.length > 0) {
|
||||
const sortedMin = minorValues.slice().sort((a, b) => b - a);
|
||||
for (const v of sortedMin) {
|
||||
if (!inRange(v)) continue;
|
||||
drawMinor(v);
|
||||
}
|
||||
}
|
||||
g.restore();
|
||||
}
|
||||
|
||||
// Mittenskala & Raster
|
||||
function drawScale(g, colRect, centerX, mapY, {
|
||||
isDBU,
|
||||
majors = [],
|
||||
minors = [],
|
||||
hairStep = null,
|
||||
topValue = 0,
|
||||
bottomValue = -60,
|
||||
highlightTick = null,
|
||||
}) {
|
||||
if (isDBU) {
|
||||
g.save();
|
||||
const prevFont = g.font;
|
||||
g.font = METER_HEADER_FONT;
|
||||
g.fillStyle = LABEL_COLOR;
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'alphabetic';
|
||||
|
||||
const labels = [24, 20, 10, 4, 0, -10, -20, -30, -40, -50, -60];
|
||||
for (const v of labels) {
|
||||
const y = mapY(v);
|
||||
if (y < colRect.y || y > colRect.y + colRect.h) continue;
|
||||
const lab = v > 0 ? ('+' + v) : (v === 0 ? '0' : String(v));
|
||||
g.fillText(lab, centerX, y + 5);
|
||||
}
|
||||
g.font = prevFont;
|
||||
g.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
const hi = Math.max(topValue, bottomValue);
|
||||
const lo = Math.min(topValue, bottomValue);
|
||||
const inRange = (v) => v <= hi && v >= lo;
|
||||
const approx = (a, b) => Math.abs(a - b) < 0.01;
|
||||
const isHighlightValue = (value) => !!highlightTick && approx(value, highlightTick.value);
|
||||
|
||||
if (hairStep && hairStep > 0) {
|
||||
g.save();
|
||||
g.beginPath();
|
||||
g.rect(colRect.x, colRect.y, colRect.w, colRect.h);
|
||||
g.clip();
|
||||
for (let v = topValue; v >= bottomValue; v -= hairStep) {
|
||||
if (!inRange(v)) continue;
|
||||
const y = Math.round(mapY(v)) + 0.5;
|
||||
g.strokeStyle = isHighlightValue(v) ? (highlightTick?.color || '#ffffff') : 'rgba(255,255,255,0.08)';
|
||||
g.lineWidth = isHighlightValue(v) ? 1.2 : 0.6;
|
||||
g.beginPath();
|
||||
g.moveTo(colRect.x, y);
|
||||
g.lineTo(colRect.x + colRect.w, y);
|
||||
g.stroke();
|
||||
}
|
||||
g.restore();
|
||||
}
|
||||
|
||||
g.save();
|
||||
const prevFont = g.font;
|
||||
g.font = METER_HEADER_FONT;
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'alphabetic';
|
||||
for (const v of majors || []) {
|
||||
if (!inRange(v)) continue;
|
||||
const y = mapY(v);
|
||||
g.fillStyle = isHighlightValue(v) ? (highlightTick?.color || LABEL_COLOR) : LABEL_COLOR;
|
||||
g.fillText(String(v), centerX, y + 5);
|
||||
}
|
||||
g.font = prevFont;
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function buildDbfsMinorTicks() {
|
||||
return [-3, -9, -15, -21, -27, -33, -36, -42, -48, -54];
|
||||
}
|
||||
|
||||
function getRmsAlignmentHighlight(CONFIG) {
|
||||
if (!CONFIG || CONFIG.AL_MARKERS_ENABLED === false) return null;
|
||||
const isArd = CONFIG.PPM_DIN_MODE === 'al_minus9';
|
||||
return {
|
||||
value: isArd ? -15 : -18,
|
||||
color: WARN_COLOR,
|
||||
};
|
||||
}
|
||||
|
||||
function smoothHeader(shared, rawL, rawR, alpha = 0.2) {
|
||||
if (!shared._header) {
|
||||
shared._header = { L: rawL, R: rawR };
|
||||
return shared._header;
|
||||
}
|
||||
shared._header.L += alpha * (rawL - shared._header.L);
|
||||
shared._header.R += alpha * (rawR - shared._header.R);
|
||||
return shared._header;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// meters/scale_helpers.js — shared helpers for drawing center-scale grid lines
|
||||
|
||||
export const METER_HEADER_FONT = 'bold 12px ui-monospace, monospace';
|
||||
|
||||
/**
|
||||
* Draws a hairline-style grid (like the RMS meter) across a center column.
|
||||
* The caller provides the existing mapY function so non-linear scales are supported.
|
||||
*/
|
||||
export function drawHairlineGrid(
|
||||
g,
|
||||
rect,
|
||||
mapY,
|
||||
topValue,
|
||||
bottomValue,
|
||||
step = 1,
|
||||
color = 'rgba(255,255,255,0.08)',
|
||||
lineWidth = 0.6
|
||||
) {
|
||||
if (!g || !rect || typeof mapY !== 'function') return;
|
||||
if (!Number.isFinite(step) || step <= 0) return;
|
||||
|
||||
const hi = Math.max(topValue, bottomValue);
|
||||
const lo = Math.min(topValue, bottomValue);
|
||||
|
||||
g.save();
|
||||
g.beginPath();
|
||||
g.rect(rect.x, rect.y, rect.w, rect.h);
|
||||
g.clip();
|
||||
|
||||
for (let v = hi; v >= lo - 1e-6; v -= step) {
|
||||
const y = Math.round(mapY(v)) + 0.5;
|
||||
if (!Number.isFinite(y)) continue;
|
||||
if (y < rect.y - 2 || y > rect.y + rect.h + 2) continue;
|
||||
|
||||
g.strokeStyle = color;
|
||||
g.lineWidth = lineWidth;
|
||||
g.beginPath();
|
||||
g.moveTo(rect.x, y);
|
||||
g.lineTo(rect.x + rect.w, y);
|
||||
g.stroke();
|
||||
}
|
||||
|
||||
g.restore();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export function drawCachedStaticLayer(targetCtx, shared, layerId, key, x, y, width, height, build) {
|
||||
if (!targetCtx || !shared || typeof build !== 'function') return false;
|
||||
if (!shared._staticLayers) shared._staticLayers = new Map();
|
||||
let layer = shared._staticLayers.get(layerId);
|
||||
const w = Math.max(1, Math.ceil(width));
|
||||
const h = Math.max(1, Math.ceil(height));
|
||||
const needsRebuild = !layer
|
||||
|| layer.key !== key
|
||||
|| layer.width !== w
|
||||
|| layer.height !== h;
|
||||
|
||||
if (needsRebuild) {
|
||||
layer = createSurface(w, h);
|
||||
if (!layer) return false;
|
||||
layer.key = key;
|
||||
layer.ctx.save();
|
||||
try {
|
||||
layer.ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
layer.ctx.clearRect(0, 0, w, h);
|
||||
build(layer.ctx);
|
||||
} finally {
|
||||
layer.ctx.restore();
|
||||
}
|
||||
shared._staticLayers.set(layerId, layer);
|
||||
}
|
||||
|
||||
targetCtx.drawImage(layer.canvas, x, y);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
// meters/stopwatch.js — Stoppuhr als Slot-Meter (Start/Stop, Reset, Auto)
|
||||
// Auto: startet bei Signal > -70 dBFS und stoppt, wenn >=1s lang <= -70 dBFS.
|
||||
|
||||
import { CONFIG } from '../core/config.js';
|
||||
import { HEADER_BG, MID_COLOR } from '../core/theme.js';
|
||||
|
||||
export const id = 'stopwatch';
|
||||
export const disableCache = true;
|
||||
|
||||
const AUTO_STOP_AFTER_MS = 1000;
|
||||
|
||||
function isExternalClient() {
|
||||
const host = String(globalThis?.location?.hostname || '').trim().toLowerCase();
|
||||
return !!host && host !== 'localhost' && host !== '127.0.0.1' && host !== '::1' && host !== '[::1]';
|
||||
}
|
||||
|
||||
function getSilenceDb() {
|
||||
const raw = Number(CONFIG?.STOPWATCH_AUTO_THRESHOLD_DBFS);
|
||||
return Math.max(-120, Math.min(20, Number.isFinite(raw) ? raw : -70));
|
||||
}
|
||||
|
||||
export function initShared() {
|
||||
const now = performance.now();
|
||||
return {
|
||||
auto: false,
|
||||
entries: [], // { elapsedMs, startTs, running }
|
||||
scrollOffset: 0, // 0 = neueste; größer = weiter zurück
|
||||
_dragScroll: null, // { startY, startOffset }
|
||||
_lastNow: now,
|
||||
_silenceStartTs: null,
|
||||
_lastLevelDb: -120,
|
||||
_buttonRects: null,
|
||||
_deleteVisible: false,
|
||||
_deleteRects: [],
|
||||
_scrollInfo: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function update(packet, shared) {
|
||||
const now = performance.now();
|
||||
shared._lastNow = now;
|
||||
|
||||
const rmsL = Number.isFinite(packet?.rmsL) ? packet.rmsL : -Infinity;
|
||||
const rmsR = Number.isFinite(packet?.rmsR) ? packet.rmsR : -Infinity;
|
||||
const levelDb = Number.isFinite(rmsL) || Number.isFinite(rmsR) ? Math.max(rmsL, rmsR) : -Infinity;
|
||||
shared._lastLevelDb = Number.isFinite(levelDb) ? levelDb : -120;
|
||||
|
||||
if (!shared.auto) return;
|
||||
|
||||
const silenceDb = getSilenceDb();
|
||||
const hasSignal = Number.isFinite(levelDb) && levelDb > silenceDb;
|
||||
if (hasSignal) {
|
||||
shared._silenceStartTs = null;
|
||||
if (!isRunning(shared)) startNew(shared, now);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shared._silenceStartTs === null) shared._silenceStartTs = now;
|
||||
if (isRunning(shared) && (now - shared._silenceStartTs) >= AUTO_STOP_AFTER_MS) {
|
||||
stop(shared, now);
|
||||
}
|
||||
}
|
||||
|
||||
function stop(shared, now) {
|
||||
const entry = getActiveEntry(shared);
|
||||
if (!entry || !entry.running) return;
|
||||
const startTs = Number.isFinite(entry.startTs) ? entry.startTs : now;
|
||||
entry.elapsedMs = (entry.elapsedMs || 0) + Math.max(0, now - startTs);
|
||||
entry.running = false;
|
||||
entry.startTs = 0;
|
||||
}
|
||||
|
||||
function startNew(shared, now) {
|
||||
const entries = Array.isArray(shared.entries) ? shared.entries : [];
|
||||
shared.entries = entries;
|
||||
if (isRunning(shared)) return;
|
||||
// Neueste Einträge immer sichtbar halten: Liste "scrollt" automatisch nach oben.
|
||||
shared.scrollOffset = 0;
|
||||
entries.push({ elapsedMs: 0, startTs: now, running: true });
|
||||
if (entries.length > 50) entries.shift();
|
||||
shared._silenceStartTs = null;
|
||||
}
|
||||
|
||||
function reset(shared, now) {
|
||||
shared.entries = [];
|
||||
shared.scrollOffset = 0;
|
||||
shared._dragScroll = null;
|
||||
shared._deleteRects = [];
|
||||
shared._silenceStartTs = null;
|
||||
}
|
||||
|
||||
function formatTime(ms) {
|
||||
const clamped = Math.max(0, ms | 0);
|
||||
const totalSeconds = Math.floor(clamped / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const tenths = Math.floor((clamped % 1000) / 100);
|
||||
|
||||
const pad2 = (n) => String(n).padStart(2, '0');
|
||||
if (hours > 0) return `${hours}:${pad2(minutes)}:${pad2(seconds)}`;
|
||||
return `${pad2(minutes)}:${pad2(seconds)}.${tenths}`;
|
||||
}
|
||||
|
||||
// Orientiert am Layout-Ansatz der anderen Meter (z.B. PPM DIN):
|
||||
// innerhalb eines festen innerPad wird "extra/2" genutzt, sodass bei ungerader Restbreite
|
||||
// eine stabile .5-Zentrierung entsteht (wirkt über Browser/Displays konsistenter).
|
||||
function computeContentRect(rect, padX = 8) {
|
||||
const innerPad = padX;
|
||||
const avail = Math.max(1, rect.w - innerPad * 2);
|
||||
const w = (avail > 2) ? (avail - 1) : avail; // erzwingt i.d.R. extra=1 → +0.5 Zentrierung
|
||||
const extra = Math.max(0, avail - w);
|
||||
const x = rect.x + innerPad + extra / 2;
|
||||
return { x, w, cx: x + w / 2 };
|
||||
}
|
||||
|
||||
function fitFontSizeToWidth(g, texts, maxWidth, {
|
||||
min = 10,
|
||||
max = 96,
|
||||
family = 'ui-monospace, monospace',
|
||||
weight = 'bold',
|
||||
} = {}) {
|
||||
const safeTexts = (Array.isArray(texts) ? texts : []).filter((t) => typeof t === 'string' && t.length);
|
||||
const samples = safeTexts.length ? safeTexts : ['00:00.0'];
|
||||
const width = Math.max(1, maxWidth);
|
||||
|
||||
let lo = Math.max(1, min | 0);
|
||||
let hi = Math.max(lo, max | 0);
|
||||
let best = lo;
|
||||
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
g.font = `${weight} ${mid}px ${family}`;
|
||||
let widest = 0;
|
||||
for (const t of samples) widest = Math.max(widest, g.measureText(t).width);
|
||||
if (widest <= width) {
|
||||
best = mid;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function sevenSegCharRatio(ch) {
|
||||
if (ch >= '0' && ch <= '9') return 0.64;
|
||||
if (ch === ':') return 0.26;
|
||||
if (ch === '.') return 0.24;
|
||||
if (ch === '-') return 0.45;
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
function sevenSegTotalRatio(text, gapRatio = 0.14) {
|
||||
const s = String(text ?? '');
|
||||
if (!s.length) return 1;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < s.length; i++) sum += sevenSegCharRatio(s[i]);
|
||||
sum += gapRatio * (s.length - 1);
|
||||
return Math.max(1e-6, sum);
|
||||
}
|
||||
|
||||
function drawSevenSegString(g, text, xCenter, yTop, maxWidth, height, color, {
|
||||
gapRatio = 0.14,
|
||||
dimAlpha = 0.25,
|
||||
} = {}) {
|
||||
const s = String(text ?? '');
|
||||
const h = Math.max(6, height);
|
||||
const ratio = sevenSegTotalRatio(s, gapRatio);
|
||||
const wTotal = Math.min(maxWidth, ratio * h);
|
||||
const scale = wTotal / (ratio * h);
|
||||
const hUsed = h * scale;
|
||||
const gap = gapRatio * hUsed;
|
||||
const x0 = xCenter - wTotal / 2;
|
||||
|
||||
let x = x0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
const cw = sevenSegCharRatio(ch) * hUsed;
|
||||
if (ch >= '0' && ch <= '9') {
|
||||
drawSevenSegDigit(g, ch, x, yTop, cw, hUsed, color);
|
||||
} else if (ch === ':') {
|
||||
drawSevenSegColon(g, x, yTop, cw, hUsed, color);
|
||||
} else if (ch === '.') {
|
||||
drawSevenSegDot(g, x, yTop, cw, hUsed, color);
|
||||
} else if (ch === '-') {
|
||||
drawSevenSegMinus(g, x, yTop, cw, hUsed, color);
|
||||
} else {
|
||||
g.save();
|
||||
g.globalAlpha = dimAlpha;
|
||||
drawSevenSegMinus(g, x, yTop, cw, hUsed, color);
|
||||
g.restore();
|
||||
}
|
||||
x += cw + gap;
|
||||
}
|
||||
return { width: wTotal, height: hUsed };
|
||||
}
|
||||
|
||||
function drawSevenSegDigit(g, ch, x, y, w, h, color) {
|
||||
const digit = Number(ch);
|
||||
const on = SEGMENTS_BY_DIGIT[digit] || SEGMENTS_BY_DIGIT[0];
|
||||
const t = Math.max(2, Math.round(h * 0.14));
|
||||
const r = Math.max(1, Math.round(t * 0.35));
|
||||
const pad = Math.max(1, Math.round(t * 0.45));
|
||||
|
||||
const halfH = h / 2;
|
||||
const vSegH = Math.max(2, Math.floor(halfH - pad - t / 2));
|
||||
|
||||
const seg = {
|
||||
A: { x: x + pad, y: y, w: Math.max(2, w - 2 * pad), h: t },
|
||||
D: { x: x + pad, y: y + h - t, w: Math.max(2, w - 2 * pad), h: t },
|
||||
G: { x: x + pad, y: y + halfH - t / 2, w: Math.max(2, w - 2 * pad), h: t },
|
||||
F: { x: x, y: y + pad, w: t, h: vSegH },
|
||||
B: { x: x + w - t, y: y + pad, w: t, h: vSegH },
|
||||
E: { x: x, y: y + halfH + t / 2, w: t, h: vSegH },
|
||||
C: { x: x + w - t, y: y + halfH + t / 2, w: t, h: vSegH },
|
||||
};
|
||||
|
||||
g.save();
|
||||
g.fillStyle = color;
|
||||
for (const key of Object.keys(seg)) {
|
||||
const s = seg[key];
|
||||
const alpha = on.has(key) ? 1 : 0.12;
|
||||
g.globalAlpha = alpha;
|
||||
g.beginPath();
|
||||
roundedRect(g, s.x, s.y, s.w, s.h, r);
|
||||
g.fill();
|
||||
}
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawSevenSegColon(g, x, y, w, h, color) {
|
||||
const r = Math.max(2, Math.round(h * 0.06));
|
||||
const cx = x + w / 2;
|
||||
const y1 = y + h * 0.33;
|
||||
const y2 = y + h * 0.67;
|
||||
g.save();
|
||||
g.fillStyle = color;
|
||||
g.globalAlpha = 0.95;
|
||||
g.beginPath(); g.arc(cx, y1, r, 0, Math.PI * 2); g.fill();
|
||||
g.beginPath(); g.arc(cx, y2, r, 0, Math.PI * 2); g.fill();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawSevenSegDot(g, x, y, w, h, color) {
|
||||
const r = Math.max(2, Math.round(h * 0.06));
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h * 0.82;
|
||||
g.save();
|
||||
g.fillStyle = color;
|
||||
g.globalAlpha = 0.95;
|
||||
g.beginPath(); g.arc(cx, cy, r, 0, Math.PI * 2); g.fill();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawSevenSegMinus(g, x, y, w, h, color) {
|
||||
const t = Math.max(2, Math.round(h * 0.14));
|
||||
const r = Math.max(1, Math.round(t * 0.35));
|
||||
const pad = Math.max(1, Math.round(t * 0.45));
|
||||
const seg = { x: x + pad, y: y + h / 2 - t / 2, w: Math.max(2, w - 2 * pad), h: t };
|
||||
g.save();
|
||||
g.fillStyle = color;
|
||||
g.globalAlpha = 0.95;
|
||||
g.beginPath();
|
||||
roundedRect(g, seg.x, seg.y, seg.w, seg.h, r);
|
||||
g.fill();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
const SEGMENTS_BY_DIGIT = [
|
||||
new Set(['A','B','C','D','E','F']),
|
||||
new Set(['B','C']),
|
||||
new Set(['A','B','G','E','D']),
|
||||
new Set(['A','B','G','C','D']),
|
||||
new Set(['F','G','B','C']),
|
||||
new Set(['A','F','G','C','D']),
|
||||
new Set(['A','F','G','E','C','D']),
|
||||
new Set(['A','B','C']),
|
||||
new Set(['A','B','C','D','E','F','G']),
|
||||
new Set(['A','B','C','D','F','G']),
|
||||
];
|
||||
|
||||
function computeButtons(rect) {
|
||||
const padX = 8;
|
||||
const padY = 0;
|
||||
const external = isExternalClient();
|
||||
let gap = external ? 6 : 8;
|
||||
let btnH = external ? 32 : 39;
|
||||
const content = computeContentRect(rect, padX);
|
||||
const btnW = Math.max(60, content.w);
|
||||
const availableH = Math.max(84, rect.h - 18);
|
||||
const wantedH = btnH * 3 + gap * 2;
|
||||
if (wantedH > availableH) {
|
||||
const scale = availableH / wantedH;
|
||||
btnH = Math.max(external ? 18 : 24, Math.floor(btnH * scale));
|
||||
gap = Math.max(external ? 2 : 3, Math.floor(gap * scale));
|
||||
}
|
||||
const x = content.x;
|
||||
const totalH = btnH * 3 + gap * 2;
|
||||
const yBottom = rect.y + rect.h - padY;
|
||||
const y0 = yBottom - totalH;
|
||||
const r1 = { x, y: y0, w: btnW, h: btnH };
|
||||
const deleteToggleW = Math.max(btnH, Math.min(Math.floor(btnW * 0.28), external ? 42 : 52));
|
||||
const resetW = Math.max(1, btnW - gap - deleteToggleW);
|
||||
const r2 = { x, y: y0 + btnH + gap, w: resetW, h: btnH };
|
||||
const rDelete = { x: x + resetW + gap, y: r2.y, w: deleteToggleW, h: btnH };
|
||||
const r3 = { x, y: y0 + (btnH + gap) * 2, w: btnW, h: btnH };
|
||||
return { startStop: r1, reset: r2, deleteToggle: rDelete, auto: r3, yTop: y0 };
|
||||
}
|
||||
|
||||
function pointInRect(x, y, r) {
|
||||
return x >= r.x && x <= (r.x + r.w) && y >= r.y && y <= (r.y + r.h);
|
||||
}
|
||||
|
||||
function getActiveEntry(shared) {
|
||||
const entries = Array.isArray(shared.entries) ? shared.entries : [];
|
||||
if (!entries.length) return null;
|
||||
return entries[entries.length - 1] || null;
|
||||
}
|
||||
|
||||
function isRunning(shared) {
|
||||
const entry = getActiveEntry(shared);
|
||||
return !!entry?.running;
|
||||
}
|
||||
|
||||
function elapsedForEntry(entry, now) {
|
||||
const base = Number.isFinite(entry?.elapsedMs) ? entry.elapsedMs : 0;
|
||||
if (!entry?.running) return base;
|
||||
const startTs = Number.isFinite(entry?.startTs) ? entry.startTs : now;
|
||||
return base + Math.max(0, now - startTs);
|
||||
}
|
||||
|
||||
function sumAllEntriesMs(entries, now) {
|
||||
const list = Array.isArray(entries) ? entries : [];
|
||||
let sum = 0;
|
||||
for (const entry of list) sum += elapsedForEntry(entry, now);
|
||||
return sum;
|
||||
}
|
||||
|
||||
function deleteEntry(shared, index) {
|
||||
const entries = Array.isArray(shared.entries) ? shared.entries : [];
|
||||
if (index < 0 || index >= entries.length) return false;
|
||||
const removed = entries.splice(index, 1)[0];
|
||||
if (removed?.running) shared._silenceStartTs = null;
|
||||
shared.scrollOffset = Math.max(0, shared.scrollOffset || 0);
|
||||
shared._dragScroll = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function pointer(evt, rect, _CONFIG, shared) {
|
||||
if (!evt) return false;
|
||||
const x = Number(evt.x);
|
||||
const y = Number(evt.y);
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return false;
|
||||
|
||||
const now = shared._lastNow || performance.now();
|
||||
const btns = shared._buttonRects || computeButtons(rect);
|
||||
const scroll = shared._scrollInfo;
|
||||
|
||||
if (evt.type === 'pointerdown') {
|
||||
if (shared._deleteVisible) {
|
||||
const deleteRects = Array.isArray(shared._deleteRects) ? shared._deleteRects : [];
|
||||
for (const hit of deleteRects) {
|
||||
if (hit?.rect && pointInRect(x, y, hit.rect)) {
|
||||
return deleteEntry(shared, hit.index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (evt.type === 'wheel') {
|
||||
if (!scroll || !scroll.scrollable || !pointInRect(x, y, scroll.listRect)) return false;
|
||||
const dy = Number(evt.deltaY);
|
||||
if (!Number.isFinite(dy) || dy === 0) return false;
|
||||
const sign = Math.sign(dy);
|
||||
const step = Math.max(1, Math.round(Math.abs(dy) / 100));
|
||||
// "Natural": scroll down => Richtung neueste (Offset ↓), scroll up => Richtung ältere (Offset ↑)
|
||||
shared.scrollOffset = clampInt((shared.scrollOffset || 0) - sign * step, 0, scroll.maxOffset);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (evt.type === 'pointerdown' && pointInRect(x, y, btns.startStop)) {
|
||||
// Manuelles Bedienen schaltet Auto aus, damit die Uhr nicht sofort wieder "zurückspringt".
|
||||
shared.auto = false;
|
||||
if (isRunning(shared)) stop(shared, now);
|
||||
else startNew(shared, now);
|
||||
return true;
|
||||
}
|
||||
if (evt.type === 'pointerdown' && pointInRect(x, y, btns.reset)) {
|
||||
reset(shared, now);
|
||||
return true;
|
||||
}
|
||||
if (evt.type === 'pointerdown' && btns.deleteToggle && pointInRect(x, y, btns.deleteToggle)) {
|
||||
shared._deleteVisible = !shared._deleteVisible;
|
||||
shared._deleteRects = [];
|
||||
return true;
|
||||
}
|
||||
if (evt.type === 'pointerdown' && pointInRect(x, y, btns.auto)) {
|
||||
shared.auto = !shared.auto;
|
||||
shared._silenceStartTs = null;
|
||||
if (shared.auto) {
|
||||
const levelDb = Number.isFinite(shared._lastLevelDb) ? shared._lastLevelDb : -120;
|
||||
if (levelDb > getSilenceDb()) startNew(shared, now);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!scroll || !scroll.scrollable) return false;
|
||||
|
||||
if (evt.type === 'pointerdown') {
|
||||
if (pointInRect(x, y, scroll.listRect)) {
|
||||
shared._dragScroll = { startY: y, startOffset: shared.scrollOffset || 0 };
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (evt.type === 'pointermove') {
|
||||
if (!shared._dragScroll) return false;
|
||||
const dy = y - shared._dragScroll.startY;
|
||||
const stepPx = Math.max(6, Number(scroll.lineStepPx) || 24);
|
||||
const deltaLines = Math.round(dy / stepPx);
|
||||
// "Natural": Finger nach unten => ältere Einträge (Offset ↑), Finger nach oben => neuere (Offset ↓)
|
||||
shared.scrollOffset = clampInt((shared._dragScroll.startOffset || 0) + deltaLines, 0, scroll.maxOffset);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (evt.type === 'pointerup' || evt.type === 'pointercancel') {
|
||||
if (!shared._dragScroll) return false;
|
||||
shared._dragScroll = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function draw(g, rect, _CONFIG, shared) {
|
||||
g.save();
|
||||
try {
|
||||
const now = performance.now();
|
||||
const entries = Array.isArray(shared.entries) ? shared.entries : [];
|
||||
const style = (_CONFIG?.STOPWATCH_DISPLAY_STYLE === 'seven') ? 'seven' : 'mono';
|
||||
const external = isExternalClient();
|
||||
const padX = 8;
|
||||
const content = computeContentRect(rect, padX);
|
||||
|
||||
// Header-Hintergrund säubern (wie andere Meter)
|
||||
g.save();
|
||||
g.fillStyle = HEADER_BG;
|
||||
g.fillRect(rect.x, rect.y - 24, rect.w, 26);
|
||||
g.restore();
|
||||
|
||||
// Titel / Status
|
||||
g.save();
|
||||
g.textAlign = 'center';
|
||||
g.fillStyle = CONFIG.HEADER_TEXT_COLOR || MID_COLOR;
|
||||
g.font = 'bold 12px ui-monospace, monospace';
|
||||
const status = shared.auto ? 'AUTO' : (isRunning(shared) ? 'RUN' : 'STOP');
|
||||
g.fillText(`Stoppuhr (${status})`, content.cx, rect.y - 12);
|
||||
g.restore();
|
||||
|
||||
// Zeit-Anzeige (oben) + Historie darunter (jede Start-Aktion erzeugt eine neue Zeile)
|
||||
const btns = computeButtons(rect);
|
||||
shared._buttonRects = btns;
|
||||
const contentTop = rect.y + 10;
|
||||
const contentBottom = btns.yTop - 4;
|
||||
const contentH = Math.max(0, contentBottom - contentTop);
|
||||
|
||||
const timeW = content.w;
|
||||
const listRect = { x: content.x, y: contentTop, w: timeW, h: contentH };
|
||||
const deleteBtnW = Math.max(external ? 18 : 22, Math.min(external ? 24 : 28, Math.floor(content.w * 0.16)));
|
||||
const deleteGap = Math.max(external ? 4 : 6, Math.round(deleteBtnW * 0.28));
|
||||
const canDeleteRows = content.w >= (external ? 88 : 112);
|
||||
const showDeleteRows = !!shared._deleteVisible && canDeleteRows;
|
||||
const rowTimeW = showDeleteRows ? Math.max(1, content.w - deleteBtnW - deleteGap) : content.w;
|
||||
const rowTimeCx = showDeleteRows ? content.x + rowTimeW / 2 : content.cx;
|
||||
const deleteX = content.x + content.w - deleteBtnW;
|
||||
const deleteRects = [];
|
||||
|
||||
const drawDeleteBtn = (r, active = false) => {
|
||||
g.save();
|
||||
g.fillStyle = active ? 'rgba(255,59,59,0.18)' : 'rgba(255,59,59,0.1)';
|
||||
g.strokeStyle = active ? '#ff3b3b' : 'rgba(255,120,120,0.65)';
|
||||
g.lineWidth = 1.2;
|
||||
g.beginPath();
|
||||
roundedRect(g, r.x, r.y, r.w, r.h, 5);
|
||||
g.fill();
|
||||
g.stroke();
|
||||
g.fillStyle = '#ffd6d6';
|
||||
const labelSize = Math.max(9, Math.min(13, Math.floor(r.h * 0.56)));
|
||||
g.font = `bold ${labelSize}px ui-monospace, monospace`;
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'middle';
|
||||
g.fillText('X', r.x + r.w / 2, r.y + r.h / 2 + 0.5);
|
||||
g.restore();
|
||||
};
|
||||
|
||||
if (style === 'seven') {
|
||||
const lineGap = 8;
|
||||
const totalText = formatTime(sumAllEntriesMs(entries, now));
|
||||
const entrySizingTexts = entries.length
|
||||
? entries.map((e) => formatTime(elapsedForEntry(e, now)))
|
||||
: [formatTime(0)];
|
||||
const entryRatioMax = Math.max(...entrySizingTexts.map((t) => sevenSegTotalRatio(t)));
|
||||
const totalRatio = sevenSegTotalRatio(totalText);
|
||||
|
||||
// Einzelzeiten und Summe getrennt skalieren: nur die Zeilen reservieren Platz für die X-Buttons.
|
||||
let segH = Math.floor(rowTimeW / Math.max(1e-6, entryRatioMax));
|
||||
let totalSegH = Math.floor(timeW / Math.max(1e-6, totalRatio));
|
||||
segH = Math.max(external ? 10 : 12, Math.min(external ? 108 : 140, segH));
|
||||
totalSegH = Math.max(external ? 10 : 12, Math.min(external ? 108 : 140, totalSegH));
|
||||
// Höhe begrenzen: mindestens 1 Eintrag + Summenzeile muss passen.
|
||||
while (segH > (external ? 10 : 12) || totalSegH > (external ? 10 : 12)) {
|
||||
const refH = Math.max(segH, totalSegH);
|
||||
const topPad = Math.max(external ? 6 : 10, Math.round(refH * (external ? 0.18 : 0.28)));
|
||||
const bottomPad = Math.max(external ? 5 : 8, Math.round(refH * (external ? 0.12 : 0.18)));
|
||||
const needed = segH + totalSegH + lineGap + topPad + bottomPad;
|
||||
if (needed <= contentH) break;
|
||||
if (totalSegH > segH) totalSegH -= 1;
|
||||
else segH -= 1;
|
||||
}
|
||||
const refH = Math.max(segH, totalSegH);
|
||||
const topPad = Math.max(external ? 6 : 10, Math.round(refH * (external ? 0.18 : 0.28)));
|
||||
const bottomPad = Math.max(external ? 5 : 8, Math.round(refH * (external ? 0.12 : 0.18)));
|
||||
const stepY = segH + (external ? 5 : lineGap);
|
||||
|
||||
const totalY = Math.floor(contentBottom - bottomPad - totalSegH);
|
||||
const ySep = Math.round(totalY - topPad) + 0.5;
|
||||
|
||||
const entryAreaH = Math.max(0, ySep - contentTop);
|
||||
const maxEntryByHeight = Math.max(1, Math.floor((entryAreaH + lineGap) / Math.max(1, stepY)));
|
||||
const displayEntryLines = maxEntryByHeight;
|
||||
|
||||
const maxOffset = Math.max(0, entries.length - displayEntryLines);
|
||||
shared.scrollOffset = clampInt(shared.scrollOffset || 0, 0, maxOffset);
|
||||
const start = Math.max(0, entries.length - displayEntryLines - (shared.scrollOffset || 0));
|
||||
const visible = entries.length ? entries.slice(start, start + displayEntryLines) : [];
|
||||
|
||||
for (let i = 0; i < visible.length; i++) {
|
||||
const entry = visible[i];
|
||||
const running = !!entry?.running;
|
||||
const y = contentTop + i * stepY;
|
||||
const col = running ? '#00e7ff' : 'rgba(255,255,255,0.9)';
|
||||
drawSevenSegString(g, formatTime(elapsedForEntry(entry, now)), rowTimeCx, y, rowTimeW, segH, col);
|
||||
if (showDeleteRows) {
|
||||
const r = { x: deleteX, y: y + Math.max(0, (segH - deleteBtnW) / 2), w: deleteBtnW, h: Math.min(deleteBtnW, segH) };
|
||||
deleteRects.push({ index: start + i, rect: r });
|
||||
drawDeleteBtn(r, running);
|
||||
}
|
||||
}
|
||||
|
||||
// Trennlinie über der Summenzeile (10. Zeile bleibt unten fix)
|
||||
g.save();
|
||||
g.strokeStyle = 'rgba(0,231,255,0.55)';
|
||||
g.setLineDash([6, 5]);
|
||||
g.beginPath();
|
||||
g.moveTo(content.x, ySep);
|
||||
g.lineTo(content.x + timeW, ySep);
|
||||
g.stroke();
|
||||
g.setLineDash([]);
|
||||
g.restore();
|
||||
|
||||
drawSevenSegString(g, totalText, content.cx, totalY, timeW, totalSegH, '#34d399');
|
||||
|
||||
const scrollable = maxOffset > 0;
|
||||
const entryRect = { x: content.x, y: contentTop, w: timeW, h: entryAreaH };
|
||||
shared._scrollInfo = { scrollable, maxLines: displayEntryLines, maxOffset, listRect: entryRect, lineStepPx: stepY };
|
||||
} else {
|
||||
const totalText = formatTime(sumAllEntriesMs(entries, now));
|
||||
const sampleEntries = entries;
|
||||
const sampleTexts = sampleEntries.length ? sampleEntries.map((e) => formatTime(elapsedForEntry(e, now))) : [formatTime(0)];
|
||||
let fontSize = fitFontSizeToWidth(g, sampleTexts, rowTimeW, { min: external ? 12 : 14, max: external ? 108 : 140 });
|
||||
let totalFontSize = fitFontSizeToWidth(g, [totalText], timeW, { min: external ? 12 : 14, max: external ? 108 : 140 });
|
||||
fontSize = Math.max(external ? 12 : 14, Math.min(external ? 108 : 140, fontSize));
|
||||
totalFontSize = Math.max(external ? 12 : 14, Math.min(external ? 108 : 140, totalFontSize));
|
||||
// Höhe begrenzen: mindestens 1 Eintrag + Summenzeile muss passen
|
||||
while (fontSize > (external ? 12 : 14) || totalFontSize > (external ? 12 : 14)) {
|
||||
const refSize = Math.max(fontSize, totalFontSize);
|
||||
const lineGap = Math.max(external ? 2 : 4, Math.round(refSize * (external ? 0.12 : 0.18)));
|
||||
const topPad = Math.max(external ? 6 : 10, Math.round(refSize * (external ? 0.18 : 0.28)));
|
||||
const bottomPad = Math.max(external ? 5 : 8, Math.round(refSize * (external ? 0.12 : 0.18)));
|
||||
const needed = fontSize + totalFontSize + lineGap + topPad + bottomPad;
|
||||
if (needed <= contentH) break;
|
||||
if (totalFontSize > fontSize) totalFontSize -= 1;
|
||||
else fontSize -= 1;
|
||||
}
|
||||
const refSize = Math.max(fontSize, totalFontSize);
|
||||
const lineGap = Math.max(external ? 2 : 4, Math.round(refSize * (external ? 0.12 : 0.18)));
|
||||
const topPad = Math.max(external ? 6 : 10, Math.round(refSize * (external ? 0.18 : 0.28)));
|
||||
const bottomPad = Math.max(external ? 5 : 8, Math.round(refSize * (external ? 0.12 : 0.18)));
|
||||
const stepY = fontSize + lineGap;
|
||||
|
||||
const totalY = Math.floor(contentBottom - bottomPad - totalFontSize);
|
||||
const ySep = Math.round(totalY - topPad) + 0.5;
|
||||
const entryAreaH = Math.max(0, ySep - contentTop);
|
||||
const maxEntryByHeight = Math.max(1, Math.floor((entryAreaH + lineGap) / Math.max(1, stepY)));
|
||||
const displayEntryLines = maxEntryByHeight;
|
||||
|
||||
const maxOffset = Math.max(0, entries.length - displayEntryLines);
|
||||
shared.scrollOffset = clampInt(shared.scrollOffset || 0, 0, maxOffset);
|
||||
const start = Math.max(0, entries.length - displayEntryLines - (shared.scrollOffset || 0));
|
||||
const visible = entries.slice(start, start + displayEntryLines);
|
||||
|
||||
g.save();
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'top';
|
||||
g.font = `bold ${fontSize}px ui-monospace, monospace`;
|
||||
for (let i = 0; i < visible.length; i++) {
|
||||
const entry = visible[i];
|
||||
const running = !!entry?.running;
|
||||
const y = contentTop + i * stepY;
|
||||
const ms = elapsedForEntry(entry, now);
|
||||
g.fillStyle = running ? '#00e7ff' : 'rgba(255,255,255,0.9)';
|
||||
g.fillText(formatTime(ms), rowTimeCx, y);
|
||||
if (showDeleteRows) {
|
||||
const btnH = Math.max(16, Math.min(deleteBtnW, fontSize));
|
||||
const r = { x: deleteX, y: y + Math.max(0, (fontSize - btnH) / 2), w: deleteBtnW, h: btnH };
|
||||
deleteRects.push({ index: start + i, rect: r });
|
||||
drawDeleteBtn(r, running);
|
||||
}
|
||||
}
|
||||
|
||||
g.save();
|
||||
g.strokeStyle = 'rgba(0,231,255,0.55)';
|
||||
g.setLineDash([6, 5]);
|
||||
g.beginPath();
|
||||
g.moveTo(content.x, ySep);
|
||||
g.lineTo(content.x + timeW, ySep);
|
||||
g.stroke();
|
||||
g.setLineDash([]);
|
||||
g.restore();
|
||||
|
||||
g.fillStyle = '#34d399';
|
||||
g.font = `bold ${totalFontSize}px ui-monospace, monospace`;
|
||||
g.fillText(totalText, content.cx, totalY);
|
||||
g.textBaseline = 'alphabetic';
|
||||
g.restore();
|
||||
|
||||
const scrollable = maxOffset > 0;
|
||||
const entryRect = { x: content.x, y: contentTop, w: timeW, h: entryAreaH };
|
||||
shared._scrollInfo = { scrollable, maxLines: displayEntryLines, maxOffset, listRect: entryRect, lineStepPx: stepY };
|
||||
}
|
||||
shared._deleteRects = deleteRects;
|
||||
|
||||
// Buttons
|
||||
const drawBtn = (r, label, active = false, warn = false, { blink = false } = {}) => {
|
||||
g.save();
|
||||
if (active && blink) {
|
||||
const phase = (now % 800) / 800; // 0..1
|
||||
const pulse = 0.08 + 0.22 * (0.5 + 0.5 * Math.sin(phase * Math.PI * 2));
|
||||
g.fillStyle = `rgba(0,231,255,${pulse.toFixed(3)})`;
|
||||
} else {
|
||||
g.fillStyle = active ? 'rgba(0,231,255,0.18)' : 'rgba(180,200,220,0.08)';
|
||||
}
|
||||
g.strokeStyle = warn ? '#ff3b3b' : (active ? '#00e7ff' : 'rgba(0,231,255,0.55)');
|
||||
g.lineWidth = 1.5;
|
||||
g.beginPath();
|
||||
const rad = 6;
|
||||
roundedRect(g, r.x, r.y, r.w, r.h, rad);
|
||||
g.fill();
|
||||
g.stroke();
|
||||
g.fillStyle = warn ? '#ffdddd' : '#e7f6ff';
|
||||
const labelSize = Math.max(external ? 8 : 10, Math.min(external ? 11 : 12, Math.floor(r.h * (external ? 0.32 : 0.34))));
|
||||
g.font = `bold ${labelSize}px ui-monospace, monospace`;
|
||||
g.textAlign = 'center';
|
||||
g.fillText(label, r.x + r.w / 2, r.y + r.h / 2 + Math.max(3, Math.round(labelSize * 0.32)));
|
||||
g.restore();
|
||||
};
|
||||
|
||||
drawBtn(btns.startStop, isRunning(shared) ? 'Stop' : 'Start', isRunning(shared));
|
||||
drawBtn(btns.reset, 'Reset', false, true);
|
||||
drawBtn(btns.deleteToggle, 'X', !!shared._deleteVisible, true);
|
||||
drawBtn(btns.auto, 'Auto', shared.auto, false, { blink: true });
|
||||
} finally {
|
||||
g.restore();
|
||||
}
|
||||
}
|
||||
|
||||
function roundedRect(g, x, y, w, h, r) {
|
||||
const rr = Math.max(0, Math.min(r, Math.min(w, h) / 2));
|
||||
g.moveTo(x + rr, y);
|
||||
g.arcTo(x + w, y, x + w, y + h, rr);
|
||||
g.arcTo(x + w, y + h, x, y + h, rr);
|
||||
g.arcTo(x, y + h, x, y, rr);
|
||||
g.arcTo(x, y, x + w, y, rr);
|
||||
g.closePath();
|
||||
}
|
||||
|
||||
function clampInt(v, lo, hi) {
|
||||
const n = Math.round(Number(v));
|
||||
if (!Number.isFinite(n)) return lo;
|
||||
return Math.max(lo, Math.min(hi, n));
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
// meters/tp.js — True Peak (dBTP) L/R mit nichtlinearer Skala, Warnschwelle und Glanzkante
|
||||
import { createPeakHoldState, stepPeakHold } from '../core/utils.js';
|
||||
import { drawHairlineGrid, METER_HEADER_FONT } from './scale_helpers.js';
|
||||
import { drawCachedStaticLayer } from './static_layer.js';
|
||||
import { HEADER_BG, LABEL_COLOR, MID_COLOR, WARN_COLOR } from '../core/theme.js';
|
||||
|
||||
export const id = 'tp';
|
||||
|
||||
const TP_PERCENT_SCALE = [
|
||||
{ db: -60, percent: '0,00 %', frac: 0.0000 },
|
||||
{ db: -50, percent: '16,67 %', frac: 0.0373 },
|
||||
{ db: -40, percent: '33,33 %', frac: 0.1429 },
|
||||
{ db: -35, percent: '41,67 %', frac: 0.2112 },
|
||||
{ db: -30, percent: '50,00 %', frac: 0.2857 },
|
||||
{ db: -25, percent: '58,33 %', frac: 0.3851 },
|
||||
{ db: -20, percent: '66,67 %', frac: 0.4783 },
|
||||
{ db: -15, percent: '75,00 %', frac: 0.6087 },
|
||||
{ db: -10, percent: '83,33 %', frac: 0.7391 },
|
||||
{ db: -5, percent: '91,67 %', frac: 0.8696 },
|
||||
{ db: 0, percent: '100,00 %', frac: 1.0000 },
|
||||
];
|
||||
|
||||
export function initShared(CONFIG) {
|
||||
const now = performance.now();
|
||||
return {
|
||||
values: { L: -60, R: -60 },
|
||||
hold: { L: -60, R: -60 },
|
||||
_holdState: {
|
||||
L: createPeakHoldState(-60, now, CONFIG?.TP_HOLD_MS ?? 1000),
|
||||
R: createPeakHoldState(-60, now, CONFIG?.TP_HOLD_MS ?? 1000),
|
||||
},
|
||||
offset: CONFIG?.TP_OFFSET_DB || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function update(packet, shared) {
|
||||
if (!packet || !shared) return;
|
||||
|
||||
const L = Number.isFinite(packet.tpL) ? packet.tpL : -60;
|
||||
const R = Number.isFinite(packet.tpR) ? packet.tpR : -60;
|
||||
shared.values.L = L + (shared.offset || 0);
|
||||
shared.values.R = R + (shared.offset || 0);
|
||||
}
|
||||
|
||||
const LOG_MIN = TP_PERCENT_SCALE[0].db;
|
||||
const LOG_TOP = TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].db;
|
||||
|
||||
function interpolateFrac(db) {
|
||||
if (db <= TP_PERCENT_SCALE[0].db) return TP_PERCENT_SCALE[0].frac;
|
||||
if (db >= TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].db) return TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].frac;
|
||||
for (let i = 1; i < TP_PERCENT_SCALE.length; i++) {
|
||||
const prev = TP_PERCENT_SCALE[i - 1];
|
||||
const curr = TP_PERCENT_SCALE[i];
|
||||
if (db <= curr.db) {
|
||||
const span = curr.db - prev.db || 1;
|
||||
const t = (db - prev.db) / span;
|
||||
return prev.frac + t * (curr.frac - prev.frac);
|
||||
}
|
||||
}
|
||||
return TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].frac;
|
||||
}
|
||||
|
||||
export function draw(g, rect, CONFIG, shared) {
|
||||
if (!g || !rect || !CONFIG || !shared) return;
|
||||
|
||||
// Live-Offset berücksichtigen (TP_OFFSET_DB)
|
||||
const __effOff = Number(CONFIG && CONFIG.TP_OFFSET_DB) || 0;
|
||||
const __offCorr = __effOff - (shared.offset || 0);
|
||||
|
||||
const mapY = (db) => {
|
||||
const clamped = Math.max(LOG_MIN, Math.min(LOG_TOP, db));
|
||||
const norm = interpolateFrac(clamped);
|
||||
return rect.y + (1 - norm) * rect.h;
|
||||
};
|
||||
|
||||
// Layout: zwei Balken + mittige Skala (nur Labels)
|
||||
const innerPad = 8, scaleW = 26, gap = 8;
|
||||
const avail = rect.w - innerPad * 2 - scaleW - gap * 2;
|
||||
let barW = Math.max(8, Math.floor(avail / 2));
|
||||
barW = Math.floor(barW * (CONFIG.METER_BAR_THIN || 0.55));
|
||||
|
||||
const used = 2 * barW + scaleW + 2 * gap;
|
||||
const extra = Math.max(0, (rect.w - innerPad * 2) - used);
|
||||
const leftX = rect.x + innerPad + extra / 2;
|
||||
const scaleX = leftX + barW + gap;
|
||||
const rightX = scaleX + scaleW + gap;
|
||||
const centerX = scaleX + scaleW / 2;
|
||||
|
||||
const rawL = shared.values.L + __offCorr;
|
||||
const rawR = shared.values.R + __offCorr;
|
||||
const tpL = Math.max(LOG_MIN, Math.min(LOG_TOP, rawL));
|
||||
const tpR = Math.max(LOG_MIN, Math.min(LOG_TOP, rawR));
|
||||
const smooth = smoothHeader(shared, rawL, rawR);
|
||||
const centerLeft = leftX + barW / 2;
|
||||
const centerRight = rightX + barW / 2;
|
||||
g.save();
|
||||
const prevFont = g.font;
|
||||
g.font = 'bold 12px ui-monospace, monospace';
|
||||
g.fillStyle = HEADER_BG;
|
||||
g.fillRect(rect.x, rect.y - 24, rect.w, 24);
|
||||
const headerWarn = CONFIG.TP_HEADER_SHOW_VALUE && (rawL > CONFIG.TP_RED_START || rawR > CONFIG.TP_RED_START);
|
||||
g.fillStyle = headerWarn ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
||||
g.textAlign = 'center';
|
||||
if (CONFIG.TP_HEADER_SHOW_VALUE) {
|
||||
const yText = rect.y - 12;
|
||||
const fmt = (v) => {
|
||||
const sign = v >= 0 ? '+' : '-';
|
||||
return `${sign}${Math.abs(v).toFixed(1)}`;
|
||||
};
|
||||
const isRedL = rawL > CONFIG.TP_RED_START;
|
||||
const isRedR = rawR > CONFIG.TP_RED_START;
|
||||
g.textAlign = 'center';
|
||||
g.fillStyle = isRedL ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
||||
g.fillText(fmt(smooth.L), centerLeft, yText);
|
||||
g.fillStyle = CONFIG.HEADER_TEXT_COLOR || MID_COLOR;
|
||||
g.fillText('|', centerX, yText);
|
||||
g.fillStyle = isRedR ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
||||
g.fillText(fmt(smooth.R), centerRight, yText);
|
||||
} else {
|
||||
g.fillText('True Peak', centerX, rect.y - 12);
|
||||
}
|
||||
g.font = prevFont;
|
||||
g.restore();
|
||||
const yFloor = mapY(LOG_MIN);
|
||||
const innerW = Math.max(2, barW - 2);
|
||||
|
||||
const drawBar = (x0, val) => {
|
||||
const yVal = mapY(val);
|
||||
const colNorm = CONFIG.TP_COLOR_NORMAL;
|
||||
g.fillStyle = colNorm;
|
||||
g.fillRect(x0 + 1, yVal, innerW, Math.max(0, yFloor - yVal));
|
||||
// Glanzkante
|
||||
g.globalAlpha = .12;
|
||||
g.fillStyle = '#fff';
|
||||
g.fillRect(x0 + 1, yVal, innerW, 2);
|
||||
g.globalAlpha = 1;
|
||||
};
|
||||
|
||||
drawBar(leftX, tpL);
|
||||
drawBar(rightX, tpR);
|
||||
|
||||
// Hold-Logik (weiße Linie)
|
||||
const now = performance.now();
|
||||
const holdMs = CONFIG.TP_HOLD_MS ?? 1000;
|
||||
const decayDbPerS = CONFIG.TP_DECAY_DB_PER_S ?? 20;
|
||||
|
||||
if (!shared._holdState) {
|
||||
shared._holdState = {
|
||||
L: createPeakHoldState(shared.hold?.L ?? LOG_MIN, now, holdMs),
|
||||
R: createPeakHoldState(shared.hold?.R ?? LOG_MIN, now, holdMs),
|
||||
};
|
||||
}
|
||||
|
||||
const holdOpts = { holdMs, decayDbPerS, floor: LOG_MIN, riseThreshold: 0.2 };
|
||||
shared.hold.L = stepPeakHold(tpL, shared._holdState.L, now, holdOpts);
|
||||
shared.hold.R = stepPeakHold(tpR, shared._holdState.R, now, holdOpts);
|
||||
|
||||
// Hold-Linien zeichnen
|
||||
g.save();
|
||||
g.fillStyle = 'white';
|
||||
const yHoldL = mapY(shared.hold.L);
|
||||
const yHoldR = mapY(shared.hold.R);
|
||||
g.fillRect(leftX + 1, yHoldL - 2, innerW, 3);
|
||||
g.fillRect(rightX + 1, yHoldR - 2, innerW, 3);
|
||||
g.restore();
|
||||
|
||||
drawTpStaticOverlay(g, shared, rect, CONFIG, {
|
||||
leftX,
|
||||
rightX,
|
||||
barW,
|
||||
innerW,
|
||||
scaleX,
|
||||
scaleW,
|
||||
centerX,
|
||||
}, mapY);
|
||||
}
|
||||
|
||||
function drawTpStaticOverlay(g, shared, rect, CONFIG, geom, mapY) {
|
||||
const { leftX, rightX, barW, innerW, scaleX, scaleW, centerX } = geom;
|
||||
const topPad = 10;
|
||||
const layerX = rect.x;
|
||||
const layerY = rect.y - topPad;
|
||||
const layerW = rect.w;
|
||||
const layerH = rect.h + 24 + topPad;
|
||||
const key = [
|
||||
'scale-font-header-v1-top-pad',
|
||||
Math.round(rect.w),
|
||||
Math.round(rect.h),
|
||||
topPad,
|
||||
CONFIG.METER_BAR_THIN || 0.55,
|
||||
CONFIG.AL_MARKERS_ENABLED === false ? 0 : 1,
|
||||
METER_HEADER_FONT,
|
||||
].join('|');
|
||||
drawCachedStaticLayer(g, shared, 'tp-static', key, layerX, layerY, layerW, layerH, (cg) => {
|
||||
cg.save();
|
||||
cg.translate(-layerX, -layerY);
|
||||
drawOverlayTicksLR(cg, leftX, rightX, innerW, mapY, getTpAlignmentTick(CONFIG));
|
||||
const scaleRect = { x: scaleX, y: rect.y, w: scaleW, h: rect.h };
|
||||
drawScale(cg, scaleRect, centerX, mapY, CONFIG);
|
||||
cg.fillStyle = LABEL_COLOR;
|
||||
cg.textAlign = 'center';
|
||||
const baseY = rect.y + rect.h + 16;
|
||||
cg.fillText('L', leftX + barW / 2, baseY);
|
||||
cg.fillText('R', rightX + barW / 2, baseY);
|
||||
const prevFontLabels = cg.font;
|
||||
cg.fillStyle = '#ffffff';
|
||||
cg.font = 'bold 8.4px ui-monospace, monospace';
|
||||
cg.fillText('dBTP', centerX, baseY);
|
||||
cg.font = prevFontLabels;
|
||||
cg.restore();
|
||||
});
|
||||
}
|
||||
|
||||
// Balken-Overlay-Ticks auf Basis der Vintage-Prozent-Skala
|
||||
const EXTRA_MINOR_TICKS = [-47, -19, -18, -17, -16, -14, -13, -12, -11, -9, -8, -7, -6, -4, -3, -2, -1];
|
||||
|
||||
function drawOverlayTicksLR(g, leftX, rightX, widthPx, mapY, alignmentTick = null) {
|
||||
if (!g) return;
|
||||
|
||||
g.save();
|
||||
g.lineWidth = 1;
|
||||
|
||||
const MAJOR_INSET = 2;
|
||||
const MINOR_FRAC = 0.45;
|
||||
const DEFAULT_COLOR = 'rgb(0,0,255)';
|
||||
const highlightValue = Number.isFinite(alignmentTick?.value) ? alignmentTick.value : null;
|
||||
const highlightColor = alignmentTick?.color || WARN_COLOR;
|
||||
const approx = (a, b) => Math.abs(a - b) < 1e-3;
|
||||
let highlightMatched = false;
|
||||
|
||||
const cxL = leftX + 1 + widthPx / 2;
|
||||
const cxR = rightX + 1 + widthPx / 2;
|
||||
|
||||
const drawMajor = (yPix, highlight = false) => {
|
||||
g.strokeStyle = highlight ? highlightColor : DEFAULT_COLOR;
|
||||
const majorW = Math.max(1, widthPx - 4 * MAJOR_INSET);
|
||||
const x1L = Math.round(cxL - majorW / 2) + 0.5;
|
||||
const x2L = Math.round(cxL + majorW / 2) + 0.5;
|
||||
g.beginPath(); g.moveTo(x1L, yPix); g.lineTo(x2L, yPix); g.stroke();
|
||||
|
||||
const x1R = Math.round(cxR - majorW / 2) + 0.5;
|
||||
const x2R = Math.round(cxR + majorW / 2) + 0.5;
|
||||
g.beginPath(); g.moveTo(x1R, yPix); g.lineTo(x2R, yPix); g.stroke();
|
||||
};
|
||||
|
||||
const drawMinor = (yPix, highlight = false) => {
|
||||
g.strokeStyle = highlight ? highlightColor : DEFAULT_COLOR;
|
||||
const span = Math.max(1, Math.floor(widthPx * MINOR_FRAC));
|
||||
const x1L = Math.round(cxL - span / 2) + 0.5;
|
||||
const x2L = Math.round(cxL + span / 2) + 0.5;
|
||||
g.beginPath(); g.moveTo(x1L, yPix); g.lineTo(x2L, yPix); g.stroke();
|
||||
const x1R = Math.round(cxR - span / 2) + 0.5;
|
||||
const x2R = Math.round(cxR + span / 2) + 0.5;
|
||||
g.beginPath(); g.moveTo(x1R, yPix); g.lineTo(x2R, yPix); g.stroke();
|
||||
};
|
||||
|
||||
for (const point of TP_PERCENT_SCALE) {
|
||||
const y = Math.round(mapY(point.db)) + 0.5;
|
||||
const isHighlight = highlightValue !== null && approx(point.db, highlightValue);
|
||||
if (isHighlight) highlightMatched = true;
|
||||
drawMajor(y, isHighlight);
|
||||
}
|
||||
|
||||
for (const db of EXTRA_MINOR_TICKS) {
|
||||
const y = Math.round(mapY(db)) + 0.5;
|
||||
const isHighlight = highlightValue !== null && approx(db, highlightValue);
|
||||
if (isHighlight) highlightMatched = true;
|
||||
drawMinor(y, isHighlight);
|
||||
}
|
||||
|
||||
if (highlightValue !== null && !highlightMatched) {
|
||||
const y = Math.round(mapY(highlightValue)) + 0.5;
|
||||
drawMajor(y, true);
|
||||
}
|
||||
|
||||
g.restore();
|
||||
}
|
||||
|
||||
// Skala mittig: nur dB-Labels
|
||||
function drawScale(g, colRect, centerX, mapY, CONFIG) {
|
||||
if (!g || !colRect) return;
|
||||
|
||||
drawHairlineGrid(g, colRect, mapY, LOG_TOP, LOG_MIN, 1);
|
||||
|
||||
g.save();
|
||||
const prevFont = g.font;
|
||||
g.font = METER_HEADER_FONT;
|
||||
g.fillStyle = LABEL_COLOR;
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'alphabetic';
|
||||
|
||||
for (const point of TP_PERCENT_SCALE) {
|
||||
const y = mapY(point.db);
|
||||
if (y < colRect.y || y > colRect.y + colRect.h) continue;
|
||||
const value = Math.abs(point.db);
|
||||
const dbLabel = Number.isInteger(value) ? String(value) : value.toFixed(1);
|
||||
g.fillText(dbLabel, centerX, y + 5);
|
||||
}
|
||||
g.font = prevFont;
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function getTpAlignmentTick(CONFIG) {
|
||||
if (!CONFIG || CONFIG.AL_MARKERS_ENABLED === false) return null;
|
||||
const isArd = CONFIG.PPM_DIN_MODE === 'al_minus9';
|
||||
return { value: isArd ? -12 : -15, color: WARN_COLOR };
|
||||
}
|
||||
|
||||
function smoothHeader(shared, rawL, rawR, alpha = 0.2) {
|
||||
if (!shared._header) {
|
||||
shared._header = { L: rawL, R: rawR };
|
||||
return shared._header;
|
||||
}
|
||||
shared._header.L += alpha * (rawL - shared._header.L);
|
||||
shared._header.R += alpha * (rawR - shared._header.R);
|
||||
return shared._header;
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import { createPeakHoldState, stepPeakHold } from '../core/utils.js';
|
||||
import { drawHairlineGrid, METER_HEADER_FONT } from './scale_helpers.js';
|
||||
import { drawCachedStaticLayer } from './static_layer.js';
|
||||
import { HEADER_BG, LABEL_COLOR, MID_COLOR, WARN_COLOR } from '../core/theme.js';
|
||||
|
||||
const VU_BOTTOM_DB = -20;
|
||||
const VU_TOP_DB = 3;
|
||||
const SCALE_TICKS_DB = [-20, -10, -7, -5, -3, 0, 1, 2, 3];
|
||||
const EXTRA_BAR_TICKS_DB = [-2, -1];
|
||||
const SKIP_AUTO_MINOR_DB = [-1.5];
|
||||
const VINTAGE_DEFLECTION = [
|
||||
{ db: -20, frac: 0.000 },
|
||||
{ db: -10, frac: 0.165 },
|
||||
{ db: -7, frac: 0.264 },
|
||||
{ db: -5, frac: 0.352 },
|
||||
{ db: -3, frac: 0.463 },
|
||||
{ db: 0, frac: 0.686 },
|
||||
{ db: +1, frac: 0.779 },
|
||||
{ db: +2, frac: 0.883 },
|
||||
{ db: +3, frac: 1.000 },
|
||||
];
|
||||
|
||||
// meters/vu.js — VU (L/R) mit Hold, Warnschwelle und Skala
|
||||
// Erwartet update(packet) mit packet.vuL / packet.vuR (in dBFS).
|
||||
|
||||
export const id = 'vu';
|
||||
|
||||
export function initShared(CONFIG) {
|
||||
const now = performance.now();
|
||||
return {
|
||||
values: { L: VU_BOTTOM_DB, R: VU_BOTTOM_DB },
|
||||
hold: { L: VU_BOTTOM_DB, R: VU_BOTTOM_DB },
|
||||
_holdState: {
|
||||
L: createPeakHoldState(VU_BOTTOM_DB, now, CONFIG?.VU_HOLD_MS ?? 600),
|
||||
R: createPeakHoldState(VU_BOTTOM_DB, now, CONFIG?.VU_HOLD_MS ?? 600),
|
||||
},
|
||||
calDbfs: CONFIG.VU_DBFS_REF,
|
||||
offset: CONFIG.VU_OFFSET_DB,
|
||||
};
|
||||
}
|
||||
|
||||
export function update(packet, shared) {
|
||||
// packet.vuL / vuR sind dBFS (durch Worklet als RMS/VU-integriert geliefert)
|
||||
const L = Number.isFinite(packet.vuL) ? packet.vuL : -60;
|
||||
const R = Number.isFinite(packet.vuR) ? packet.vuR : -60;
|
||||
shared.values.L = L - shared.calDbfs + shared.offset; // in VU
|
||||
shared.values.R = R - shared.calDbfs + shared.offset; // in VU
|
||||
}
|
||||
|
||||
export function draw(g, rect, CONFIG, shared) {
|
||||
const VU_TOP = VU_TOP_DB, VU_BOTTOM = VU_BOTTOM_DB;
|
||||
const clampVU = (v) => Math.max(VU_BOTTOM, Math.min(VU_TOP, v));
|
||||
const deflectionFrac = (dB) => {
|
||||
const table = VINTAGE_DEFLECTION;
|
||||
if (dB <= table[0].db) return table[0].frac;
|
||||
if (dB >= table[table.length - 1].db) return table[table.length - 1].frac;
|
||||
for (let i = 1; i < table.length; i++) {
|
||||
const prev = table[i - 1];
|
||||
const curr = table[i];
|
||||
if (dB <= curr.db) {
|
||||
const span = curr.db - prev.db || 1;
|
||||
const t = (dB - prev.db) / span;
|
||||
return prev.frac + t * (curr.frac - prev.frac);
|
||||
}
|
||||
}
|
||||
return table[table.length - 1].frac;
|
||||
};
|
||||
// Ensure scale labels, ticks and bar deflection share the same DIN-vintage mapping.
|
||||
const mapY = (dB) => {
|
||||
const clamped = clampVU(dB);
|
||||
const frac = deflectionFrac(clamped);
|
||||
const yBot = rect.y + rect.h;
|
||||
return yBot - frac * rect.h;
|
||||
};
|
||||
|
||||
|
||||
// Live-Offset anwenden: UI-Änderungen wirken sofort
|
||||
const effOff = Number(CONFIG && CONFIG.VU_OFFSET_DB) || 0;
|
||||
const offCorr = effOff - (shared.offset || 0);
|
||||
|
||||
|
||||
|
||||
// Layout: zwei vertikale Balken + mittige Skala
|
||||
const innerPad = 8, scaleW = 26, gap = 8;
|
||||
const avail = rect.w - innerPad * 2 - scaleW - gap * 2;
|
||||
let barW = Math.max(8, Math.floor(avail / 2));
|
||||
barW = Math.floor(barW * (CONFIG.METER_BAR_THIN || 0.55));
|
||||
|
||||
const used = 2 * barW + scaleW + 2 * gap;
|
||||
const extra = Math.max(0, (rect.w - innerPad * 2) - used);
|
||||
const leftX = rect.x + innerPad + extra / 2;
|
||||
const scaleX = leftX + barW + gap;
|
||||
const rightX = scaleX + scaleW + gap;
|
||||
const centerX = scaleX + scaleW / 2;
|
||||
const centerLeft = leftX + barW / 2;
|
||||
const centerRight = rightX + barW / 2;
|
||||
|
||||
// Werte
|
||||
const rawVuL = shared.values.L + offCorr;
|
||||
const rawVuR = shared.values.R + offCorr;
|
||||
const vuL = clampVU(rawVuL);
|
||||
const vuR = clampVU(rawVuR);
|
||||
const smooth = smoothHeader(shared, rawVuL, rawVuR);
|
||||
g.save();
|
||||
const prevFont = g.font;
|
||||
g.font = 'bold 12px ui-monospace, monospace';
|
||||
// Header-Hintergrund säubern, damit keine alten Werte liegen bleiben
|
||||
g.fillStyle = HEADER_BG;
|
||||
g.fillRect(rect.x, rect.y - 24, rect.w, 24);
|
||||
const headerWarn = CONFIG.VU_HEADER_SHOW_VALUE && (rawVuL > CONFIG.VU_RED_START || rawVuR > CONFIG.VU_RED_START);
|
||||
g.fillStyle = headerWarn ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
||||
g.textAlign = 'center';
|
||||
if (CONFIG.VU_HEADER_SHOW_VALUE) {
|
||||
const yText = rect.y - 12;
|
||||
const fmt = (v) => {
|
||||
const sign = v >= 0 ? '+' : '-';
|
||||
return `${sign}${Math.abs(v).toFixed(1)}`;
|
||||
};
|
||||
const isRedL = rawVuL > CONFIG.VU_RED_START;
|
||||
const isRedR = rawVuR > CONFIG.VU_RED_START;
|
||||
g.textAlign = 'center';
|
||||
g.fillStyle = isRedL ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
||||
g.fillText(fmt(smooth.L), centerLeft, yText);
|
||||
g.fillStyle = CONFIG.HEADER_TEXT_COLOR || MID_COLOR;
|
||||
g.fillText('|', centerX, yText);
|
||||
g.fillStyle = isRedR ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
||||
g.fillText(fmt(smooth.R), centerRight, yText);
|
||||
} else {
|
||||
g.fillText('Volume Units', centerX, rect.y - 12);
|
||||
}
|
||||
g.font = prevFont;
|
||||
g.restore();
|
||||
const yFloor = mapY(VU_BOTTOM);
|
||||
const yRed = mapY(CONFIG.VU_RED_START);
|
||||
const innerW = Math.max(2, barW - 2);
|
||||
|
||||
const drawVuBar = (x0, vu) => {
|
||||
const yVal = mapY(vu);
|
||||
const colNorm = CONFIG.VU_COLOR_NORMAL;
|
||||
const colWarn = CONFIG.VU_COLOR_WARN;
|
||||
|
||||
if (vu > CONFIG.VU_RED_START) {
|
||||
if (CONFIG.VU_RED_BAR_ONLY) {
|
||||
// Normaler Teil unten
|
||||
g.fillStyle = colNorm;
|
||||
g.fillRect(x0 + 1, Math.min(yRed, yFloor), innerW, Math.max(0, yFloor - yRed));
|
||||
// Roter Teil oben
|
||||
g.fillStyle = colWarn;
|
||||
g.fillRect(x0 + 1, Math.min(yVal, yRed), innerW, Math.max(0, yRed - yVal));
|
||||
} else {
|
||||
g.fillStyle = colWarn;
|
||||
g.fillRect(x0 + 1, yVal, innerW, Math.max(0, yFloor - yVal));
|
||||
}
|
||||
} else {
|
||||
g.fillStyle = colNorm;
|
||||
g.fillRect(x0 + 1, yVal, innerW, Math.max(0, yFloor - yVal));
|
||||
}
|
||||
|
||||
// leichte Glanzkante
|
||||
g.globalAlpha = .12; g.fillStyle = '#fff'; g.fillRect(x0 + 1, yVal, innerW, 2); g.globalAlpha = 1;
|
||||
};
|
||||
|
||||
// Balken zeichnen
|
||||
drawVuBar(leftX, vuL);
|
||||
|
||||
// Red stripe overlay (left)
|
||||
drawRedStripe(g, {x:leftX, w:innerW}, centerX, mapY);
|
||||
drawVuBar(rightX, vuR);
|
||||
// Overlay ticks on bars
|
||||
drawOverlayTicksLR(g, leftX, rightX, innerW, mapY, VU_TOP, VU_BOTTOM, SCALE_TICKS_DB, getVuAlignmentTick(CONFIG));
|
||||
|
||||
|
||||
// Red stripe overlay (right)
|
||||
drawRedStripe(g, {x:rightX, w:innerW}, centerX, mapY);
|
||||
const now = performance.now();
|
||||
const holdMs = (CONFIG.VU_HOLD_MS ?? 600);
|
||||
const decayDbPerS = (CONFIG.VU_DECAY_DB_PER_S ?? 35);
|
||||
const holdOpts = {
|
||||
holdMs,
|
||||
decayDbPerS,
|
||||
floor: VU_BOTTOM,
|
||||
riseThreshold: 0.2,
|
||||
};
|
||||
if (!shared._holdState) {
|
||||
shared._holdState = {
|
||||
L: createPeakHoldState(shared.hold?.L ?? VU_BOTTOM, now, holdMs),
|
||||
R: createPeakHoldState(shared.hold?.R ?? VU_BOTTOM, now, holdMs),
|
||||
};
|
||||
}
|
||||
shared.hold.L = stepPeakHold(vuL, shared._holdState.L, now, holdOpts);
|
||||
shared.hold.R = stepPeakHold(vuR, shared._holdState.R, now, holdOpts);
|
||||
|
||||
// Hold-Anzeigen
|
||||
g.save();
|
||||
g.fillStyle = '#fff';
|
||||
g.fillRect(leftX + 1, mapY(shared.hold.L) - 1, innerW, 2);
|
||||
g.fillRect(rightX + 1, mapY(shared.hold.R) - 1, innerW, 2);
|
||||
g.restore();
|
||||
|
||||
drawVuStaticOverlay(g, shared, rect, CONFIG, {
|
||||
leftX,
|
||||
rightX,
|
||||
barW,
|
||||
innerW,
|
||||
scaleX,
|
||||
scaleW,
|
||||
centerX,
|
||||
}, mapY);
|
||||
}
|
||||
|
||||
function drawVuStaticOverlay(g, shared, rect, CONFIG, geom, mapY) {
|
||||
const {
|
||||
leftX,
|
||||
rightX,
|
||||
barW,
|
||||
innerW,
|
||||
scaleX,
|
||||
scaleW,
|
||||
centerX,
|
||||
} = geom;
|
||||
const topPad = 10;
|
||||
const layerX = rect.x;
|
||||
const layerY = rect.y - topPad;
|
||||
const layerW = rect.w;
|
||||
const layerH = rect.h + 24 + topPad;
|
||||
const key = [
|
||||
'scale-font-header-v1-top-pad',
|
||||
Math.round(rect.w),
|
||||
Math.round(rect.h),
|
||||
topPad,
|
||||
CONFIG.METER_BAR_THIN || 0.55,
|
||||
CONFIG.AL_MARKERS_ENABLED === false ? 0 : 1,
|
||||
METER_HEADER_FONT,
|
||||
].join('|');
|
||||
drawCachedStaticLayer(g, shared, 'vu-static', key, layerX, layerY, layerW, layerH, (cg) => {
|
||||
cg.save();
|
||||
cg.translate(-layerX, -layerY);
|
||||
drawScale(cg, { x: scaleX, y: rect.y, w: scaleW, h: rect.h }, centerX, mapY, CONFIG);
|
||||
drawRedStripe(cg, { x: leftX, w: innerW }, centerX, mapY);
|
||||
drawRedStripe(cg, { x: rightX, w: innerW }, centerX, mapY);
|
||||
drawOverlayTicksLR(cg, leftX, rightX, innerW, mapY, VU_TOP_DB, VU_BOTTOM_DB, SCALE_TICKS_DB, getVuAlignmentTick(CONFIG));
|
||||
cg.fillStyle = LABEL_COLOR;
|
||||
cg.textAlign = 'center';
|
||||
const baseY = rect.y + rect.h + 16;
|
||||
cg.fillText('L', leftX + barW / 2, baseY);
|
||||
cg.fillText('R', rightX + barW / 2, baseY);
|
||||
const prevFontLabels = cg.font;
|
||||
cg.fillStyle = '#ffffff';
|
||||
cg.font = 'bold 8.4px ui-monospace, monospace';
|
||||
cg.fillText('dB (VU)', centerX, baseY);
|
||||
cg.font = prevFontLabels;
|
||||
cg.restore();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function drawRedStripe(g, rectLite, centerX, mapY) {
|
||||
// Red zone = [0..+3] dB
|
||||
const yTop = mapY(VU_TOP_DB);
|
||||
const y0 = mapY(0);
|
||||
const y1 = Math.min(y0, yTop), y2 = Math.max(y0, yTop);
|
||||
const isLeft = (rectLite.x + rectLite.w/2) < centerX;
|
||||
const innerEdge = isLeft ? (rectLite.x + rectLite.w) : rectLite.x;
|
||||
const gap = Math.abs(centerX - innerEdge);
|
||||
const inset = Math.max(1, Math.floor(gap * 0.35));
|
||||
const cx = Math.round(isLeft ? (innerEdge + inset) : (innerEdge - inset)) + 0.5;
|
||||
g.save();
|
||||
g.strokeStyle = WARN_COLOR;
|
||||
g.lineWidth = 1;
|
||||
g.beginPath();
|
||||
g.moveTo(cx, y1);
|
||||
g.lineTo(cx, y2);
|
||||
g.stroke();
|
||||
g.restore();
|
||||
|
||||
// Dünner roter Mittelstrich für 0..+3 dB
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
function drawOverlayTicksLR(g, leftX, rightX, widthPx, mapY, TOP, BOTTOM, majors, alignmentTick = null) {
|
||||
g.save();
|
||||
g.lineWidth = 1;
|
||||
|
||||
const MAJOR_INSET = 2;
|
||||
const MINOR_FRAC = 0.45;
|
||||
const DEFAULT_COLOR = 'rgb(0,0,255)';
|
||||
const highlightValue = Number.isFinite(alignmentTick?.value) ? alignmentTick.value : null;
|
||||
const highlightColor = alignmentTick?.color || WARN_COLOR;
|
||||
const approx = (a, b) => Math.abs(a - b) < 1e-3;
|
||||
let highlightMatched = false;
|
||||
|
||||
const cxL = leftX + 1 + widthPx / 2;
|
||||
const cxR = rightX + 1 + widthPx / 2;
|
||||
|
||||
const drawMajor = (yPix, highlight = false) => {
|
||||
g.strokeStyle = highlight ? highlightColor : DEFAULT_COLOR;
|
||||
const majorW = Math.max(1, widthPx - 4 * MAJOR_INSET);
|
||||
const x1L = Math.round(cxL - majorW / 2) + 0.5;
|
||||
const x2L = Math.round(cxL + majorW / 2) + 0.5;
|
||||
g.beginPath(); g.moveTo(x1L, yPix); g.lineTo(x2L, yPix); g.stroke();
|
||||
|
||||
const x1R = Math.round(cxR - majorW / 2) + 0.5;
|
||||
const x2R = Math.round(cxR + majorW / 2) + 0.5;
|
||||
g.beginPath(); g.moveTo(x1R, yPix); g.lineTo(x2R, yPix); g.stroke();
|
||||
};
|
||||
|
||||
const drawMinor = (yPix, highlight = false) => {
|
||||
g.strokeStyle = highlight ? highlightColor : DEFAULT_COLOR;
|
||||
const minorW = Math.max(1, Math.floor(widthPx * MINOR_FRAC));
|
||||
const x1L = Math.round(cxL - minorW / 2) + 0.5;
|
||||
const x2L = Math.round(cxL + minorW / 2) + 0.5;
|
||||
g.beginPath(); g.moveTo(x1L, yPix); g.lineTo(x2L, yPix); g.stroke();
|
||||
|
||||
const x1R = Math.round(cxR - minorW / 2) + 0.5;
|
||||
const x2R = Math.round(cxR + minorW / 2) + 0.5;
|
||||
g.beginPath(); g.moveTo(x1R, yPix); g.lineTo(x2R, yPix); g.stroke();
|
||||
};
|
||||
|
||||
const top = TOP, bot = BOTTOM;
|
||||
const hi = Math.max(top, bot), lo = Math.min(top, bot);
|
||||
const inRange = (v) => v <= hi && v >= lo;
|
||||
|
||||
if (Array.isArray(majors) && majors.length > 0) {
|
||||
const sorted = majors.slice().sort((a, b) => b - a); // desc (top->bottom)
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const vMaj = sorted[i];
|
||||
if (inRange(vMaj)) {
|
||||
const yMaj = Math.round(mapY(vMaj)) + 0.5;
|
||||
const isHighlight = highlightValue !== null && approx(vMaj, highlightValue);
|
||||
if (isHighlight) highlightMatched = true;
|
||||
drawMajor(yMaj, isHighlight);
|
||||
}
|
||||
if (i < sorted.length - 1) {
|
||||
const vNext = sorted[i + 1];
|
||||
const mid = (vMaj + vNext) / 2;
|
||||
const skip = SKIP_AUTO_MINOR_DB.some((s) => Math.abs(mid - s) < 1e-3);
|
||||
if (inRange(mid) && !skip) {
|
||||
const yMin = Math.round(mapY(mid)) + 0.5;
|
||||
const isHighlight = highlightValue !== null && approx(mid, highlightValue);
|
||||
if (isHighlight) highlightMatched = true;
|
||||
drawMinor(yMin, isHighlight);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const extra of EXTRA_BAR_TICKS_DB) {
|
||||
if (!inRange(extra)) continue;
|
||||
const yExtra = Math.round(mapY(extra)) + 0.5;
|
||||
const isHighlight = highlightValue !== null && approx(extra, highlightValue);
|
||||
if (isHighlight) highlightMatched = true;
|
||||
drawMinor(yExtra, isHighlight);
|
||||
}
|
||||
|
||||
if (highlightValue !== null && inRange(highlightValue) && !highlightMatched) {
|
||||
const y = Math.round(mapY(highlightValue)) + 0.5;
|
||||
drawMajor(y, true);
|
||||
}
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function smoothHeader(shared, rawL, rawR, alpha = 0.2) {
|
||||
if (!shared._header) {
|
||||
shared._header = { L: rawL, R: rawR };
|
||||
return shared._header;
|
||||
}
|
||||
shared._header.L += alpha * (rawL - shared._header.L);
|
||||
shared._header.R += alpha * (rawR - shared._header.R);
|
||||
return shared._header;
|
||||
}
|
||||
|
||||
function drawScale(g, colRect, centerX, mapY, CONFIG) {
|
||||
drawHairlineGrid(g, colRect, mapY, VU_TOP_DB, VU_BOTTOM_DB, 1);
|
||||
|
||||
g.save();
|
||||
const prevFont = g.font;
|
||||
g.font = METER_HEADER_FONT;
|
||||
g.fillStyle = LABEL_COLOR;
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'alphabetic';
|
||||
|
||||
for (const step of VINTAGE_DEFLECTION) {
|
||||
const y = mapY(step.db);
|
||||
if (y < colRect.y - 0.5 || y > colRect.y + colRect.h + 0.5) continue;
|
||||
const dbLabel = step.db > 0 ? `+${step.db}` : `${step.db}`;
|
||||
g.fillText(dbLabel, centerX, y + 5);
|
||||
}
|
||||
|
||||
g.font = prevFont;
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function getVuAlignmentTick(CONFIG) {
|
||||
if (!CONFIG || CONFIG.AL_MARKERS_ENABLED === false) return null;
|
||||
return { value: -4, color: WARN_COLOR };
|
||||
}
|
||||
+417
@@ -0,0 +1,417 @@
|
||||
:root {
|
||||
--bg:#08080f; --hud-bg:rgba(10,10,18,.72);
|
||||
--frame:#00e7ff; --grid-major:#1e2a35; --grid-minor:rgba(30,42,53,.55);
|
||||
--label:#8fd3d4; --line:#36bdf8; --warn:#ff3b3b; --ok:#34d399; --mid:#ffe066;
|
||||
}
|
||||
html,body{margin:0;height:100%;background:var(--bg);color:#ddd;font:16px ui-monospace,monospace}
|
||||
#cv{display:block;width:100vw;height:100vh;pointer-events:auto;position:fixed;top:0;left:0;z-index:2;touch-action:none}
|
||||
canvas.spectrogram-layer,
|
||||
canvas.waveform-layer{
|
||||
position:fixed;
|
||||
top:0;
|
||||
left:0;
|
||||
width:0;
|
||||
height:0;
|
||||
pointer-events:none;
|
||||
z-index:1;
|
||||
}
|
||||
.hud{
|
||||
position:fixed;top:10px;left:10px;z-index:9999;display:flex;gap:10px;align-items:center;pointer-events:auto;
|
||||
background:var(--hud-bg);border:1px solid #2b2f3a;border-radius:10px;padding:8px 10px;backdrop-filter:blur(6px)
|
||||
}
|
||||
.screensaver-active .hud,
|
||||
.screensaver-active #optionsPanelNew {
|
||||
display:none !important;
|
||||
}
|
||||
.screensaver-active #recorderHud,
|
||||
.screensaver-active .rec-processing,
|
||||
.screensaver-active .rec-warning,
|
||||
.screensaver-active .calibration-modal,
|
||||
.screensaver-active .split-popup {
|
||||
display:none !important;
|
||||
}
|
||||
.hud select{accent-color:var(--frame);background:#0b0b12;color:#ddd;border:1px solid #333;border-radius:8px;padding:6px 10px;font:14px ui-monospace,monospace}
|
||||
.hud label{display:flex;gap:6px;align-items:center}
|
||||
.err{position:fixed;left:12px;top:12px;max-width:70vw;color:#fff;background:#7a1b1b;border:1px solid #3b0d0d;border-radius:8px;padding:8px 10px;display:none;z-index:99999;white-space:pre-wrap}
|
||||
|
||||
/* Options-Panel (responsive) */
|
||||
.options { position:fixed; inset:80px 12px 20px 12px; z-index:9000; display:none; background:rgba(10,10,18,.85); border:1px solid #2b2f3a; border-radius:14px; padding:20px; padding-bottom:calc(20px + var(--options-kb-inset, 0px) + var(--options-extra-scroll, 42vh)); overflow:auto; backdrop-filter: blur(8px); }
|
||||
.options h2{margin:0 0 12px 0; color:#cfefff; font:600 18px ui-monospace,monospace}
|
||||
.grid{display:grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap:12px}
|
||||
.opt{ display:flex; align-items:center; gap:10px; background:#0c1017; border:1px solid #202633; border-radius:10px; padding:10px; flex-wrap:wrap; }
|
||||
.opt label{min-width:150px; color:#9fc9ff}
|
||||
.opt input[type="range"], .opt select, .opt input[type="number"], .opt input[type="color"]{ accent-color:var(--frame); background:#0b0b12; color:#ddd; border:1px solid #333; border-radius:8px; padding:6px 10px; font:16px ui-monospace,monospace }
|
||||
.opt input[type="color"]{
|
||||
padding:2px;
|
||||
width:56px;
|
||||
height:34px;
|
||||
background:transparent;
|
||||
border:1px solid #333;
|
||||
border-radius:8px;
|
||||
appearance:auto;
|
||||
-webkit-appearance:auto;
|
||||
}
|
||||
.opt input[type="color"]::-webkit-color-swatch-wrapper{
|
||||
padding:0;
|
||||
}
|
||||
.opt input[type="color"]::-webkit-color-swatch{
|
||||
border:none;
|
||||
border-radius:6px;
|
||||
}
|
||||
.opt input[type="color"]::-moz-color-swatch{
|
||||
border:none;
|
||||
border-radius:6px;
|
||||
}
|
||||
#optionsPanelNew .offset-block{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:8px;
|
||||
flex: 1 1 440px;
|
||||
}
|
||||
#optionsPanelNew .offset-row{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:10px;
|
||||
width:100%;
|
||||
}
|
||||
#optionsPanelNew .offset-row .ch{
|
||||
min-width:12px;
|
||||
color:#cfefff;
|
||||
}
|
||||
#optionsPanelNew .offset-row input[type="range"]{
|
||||
flex: 1 1 340px;
|
||||
min-width: 340px;
|
||||
}
|
||||
#optionsPanelNew .offset-row .offset-val{
|
||||
min-width:80px;
|
||||
text-align:right;
|
||||
color:#cfefff;
|
||||
}
|
||||
#optionsPanelNew .offset-notes{
|
||||
flex: 1 0 100%;
|
||||
margin-left:160px;
|
||||
max-width: 440px;
|
||||
}
|
||||
#optionsPanelNew .offset-notes small{
|
||||
display:block;
|
||||
}
|
||||
@media (max-width: 520px){
|
||||
#optionsPanelNew .offset-row input[type="range"]{ min-width: 240px; }
|
||||
#optionsPanelNew .offset-notes{ margin-left:0; }
|
||||
}
|
||||
.opt select:disabled,
|
||||
.opt input[type="range"]:disabled,
|
||||
.opt input[type="number"]:disabled,
|
||||
.opt input[type="color"]:disabled,
|
||||
.opt button:disabled{
|
||||
opacity:0.55;
|
||||
cursor:not-allowed;
|
||||
filter:grayscale(0.35);
|
||||
}
|
||||
.opt small{opacity:.75}
|
||||
.row{display:flex; gap:10px; align-items:center; flex-wrap:wrap}
|
||||
.btn { background:#0b0f1a; color:#dff6ff; border:1px solid #284a5a; border-radius:10px; padding:8px 12px; cursor:pointer }
|
||||
.btn:hover{background:#0e1422}
|
||||
.btn-lg{font-size:22px; padding:18px 28px; border-radius:14px;}
|
||||
.btn-danger-border{border-color:#d33;}
|
||||
.btn-active{background:#12301f; border-color:#34d399; color:#eafff1; box-shadow:0 0 0 1px rgba(52,211,153,.3) inset;}
|
||||
.rec-auto-armed{
|
||||
border-color:#ff9f1a;
|
||||
color:#fff4d9;
|
||||
animation: recAutoArmedBlink 0.8s ease-in-out infinite alternate;
|
||||
}
|
||||
.rec-auto-recording{
|
||||
border-color:#d33;
|
||||
background:#3a0d0d;
|
||||
color:#ffeaea;
|
||||
box-shadow:0 0 0 1px rgba(211,51,51,.28) inset;
|
||||
animation:none;
|
||||
}
|
||||
.btn:disabled{
|
||||
opacity:0.45;
|
||||
cursor:not-allowed;
|
||||
filter:grayscale(0.35);
|
||||
}
|
||||
|
||||
.split-popup{
|
||||
position:fixed;
|
||||
inset:0;
|
||||
z-index:9500;
|
||||
display:none;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
background:rgba(8,8,15,0.55);
|
||||
backdrop-filter:blur(3px);
|
||||
pointer-events:auto;
|
||||
}
|
||||
.split-popup__box{
|
||||
width:min(520px, calc(100vw - 24px));
|
||||
padding:14px 14px 10px;
|
||||
background:rgba(12,14,22,0.95);
|
||||
border:1px solid #284a5a;
|
||||
border-radius:14px;
|
||||
color:#e8f5ff;
|
||||
box-shadow:0 10px 30px rgba(0,0,0,0.35);
|
||||
font-family:ui-monospace,monospace;
|
||||
}
|
||||
.split-popup__head{
|
||||
display:flex;
|
||||
justify-content:space-between;
|
||||
align-items:center;
|
||||
gap:10px;
|
||||
margin-bottom:10px;
|
||||
}
|
||||
.split-popup__row{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
gap:10px;
|
||||
padding:6px 0;
|
||||
}
|
||||
|
||||
#recorderHud{
|
||||
position:fixed;
|
||||
inset:0;
|
||||
z-index:9100;
|
||||
display:none;
|
||||
background:transparent;
|
||||
pointer-events:none;
|
||||
}
|
||||
.rec-floating{
|
||||
position:fixed;
|
||||
pointer-events:auto;
|
||||
}
|
||||
#recorderHud a{color:#9cf;}
|
||||
.rec-list{
|
||||
background:rgba(10,12,18,0.8);
|
||||
border:1px solid #284a5a;
|
||||
border-radius:12px;
|
||||
padding:10px;
|
||||
min-width:220px;
|
||||
max-width:none;
|
||||
overflow-y:auto;
|
||||
color:#cfefff;
|
||||
font:14px ui-monospace,monospace;
|
||||
box-shadow:0 4px 18px rgba(0,0,0,0.25);
|
||||
}
|
||||
.rec-list h4{
|
||||
margin:0 0 6px 0;
|
||||
font:14px ui-monospace,monospace;
|
||||
color:#9fdba0;
|
||||
}
|
||||
.rec-list .rec-item{
|
||||
display:flex;
|
||||
justify-content:space-between;
|
||||
align-items:center;
|
||||
gap:10px;
|
||||
padding:6px 0;
|
||||
border-bottom:1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
.rec-list .rec-item:last-child{border-bottom:none;}
|
||||
.rec-list .rec-name{flex:1; word-break:break-word;}
|
||||
.rec-list .btn-compact{
|
||||
padding:6px 10px;
|
||||
font:13px ui-monospace,monospace;
|
||||
border-radius:8px;
|
||||
}
|
||||
.rec-list--external{
|
||||
padding:8px;
|
||||
font:13px ui-monospace,monospace;
|
||||
}
|
||||
.rec-list--external h4{
|
||||
margin:0 0 4px 0;
|
||||
font:13px ui-monospace,monospace;
|
||||
}
|
||||
.rec-list--external .rec-item{
|
||||
gap:8px;
|
||||
padding:4px 0;
|
||||
}
|
||||
.rec-list--external .btn-compact{
|
||||
padding:5px 8px;
|
||||
font:12px ui-monospace,monospace;
|
||||
}
|
||||
.rec-timer{
|
||||
display:block;
|
||||
color:#cfefff;
|
||||
font:600 37px ui-monospace,monospace;
|
||||
text-align:center;
|
||||
}
|
||||
.rec-timer .hund{
|
||||
font-size:18.5px;
|
||||
opacity:0.8;
|
||||
vertical-align:baseline;
|
||||
}
|
||||
|
||||
.rec-label {
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
gap:10px;
|
||||
padding:18px 28px;
|
||||
border-radius:14px;
|
||||
background:#0f2c0f;
|
||||
color:#dff6dd;
|
||||
font:22px ui-monospace,monospace;
|
||||
min-width:220px;
|
||||
}
|
||||
.rec-label.active {
|
||||
background:#0f2c0f;
|
||||
}
|
||||
.rec-label.blink {
|
||||
animation: recBlink 0.8s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes recBlink {
|
||||
from { background: #2d0a0a; }
|
||||
to { background: #5c0f0f; }
|
||||
}
|
||||
@keyframes recAutoArmedBlink {
|
||||
from { background:#3a2204; }
|
||||
to { background:#7a4708; }
|
||||
}
|
||||
.rec-processing{
|
||||
position:fixed;
|
||||
inset:0;
|
||||
z-index:9200;
|
||||
display:none;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
background:rgba(8,8,15,0.55);
|
||||
backdrop-filter:blur(3px);
|
||||
pointer-events:none;
|
||||
}
|
||||
.rec-processing-box{
|
||||
min-width:280px;
|
||||
padding:18px 26px;
|
||||
background:rgba(12,14,22,0.9);
|
||||
border:1px solid #284a5a;
|
||||
border-radius:14px;
|
||||
text-align:center;
|
||||
color:#e8f5ff;
|
||||
box-shadow:0 10px 30px rgba(0,0,0,0.35);
|
||||
}
|
||||
.rec-processing-text{
|
||||
font:600 18px ui-monospace,monospace;
|
||||
margin:10px 0 4px;
|
||||
letter-spacing:0.3px;
|
||||
}
|
||||
.rec-processing-sub{
|
||||
font:14px ui-monospace,monospace;
|
||||
color:#9cb7d1;
|
||||
}
|
||||
.rec-spinner{
|
||||
width:54px;
|
||||
height:54px;
|
||||
border-radius:50%;
|
||||
margin:0 auto;
|
||||
border:4px solid rgba(0,231,255,0.25);
|
||||
border-top-color:#00e7ff;
|
||||
animation: recSpin 1s linear infinite;
|
||||
}
|
||||
@keyframes recSpin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.rec-warning{
|
||||
position:fixed;
|
||||
inset:0;
|
||||
z-index:9300;
|
||||
display:none;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
background:rgba(8,8,15,0.65);
|
||||
backdrop-filter:blur(4px);
|
||||
pointer-events:auto;
|
||||
}
|
||||
.rec-warning-box{
|
||||
max-width:460px;
|
||||
padding:18px 22px;
|
||||
background:rgba(12,14,22,0.95);
|
||||
border:1px solid #284a5a;
|
||||
border-radius:14px;
|
||||
color:#e8f5ff;
|
||||
box-shadow:0 10px 30px rgba(0,0,0,0.35);
|
||||
}
|
||||
.rec-warning-box h3{
|
||||
margin:0 0 8px;
|
||||
font:600 18px ui-monospace,monospace;
|
||||
color:#9fdba0;
|
||||
}
|
||||
.rec-warning-box p{
|
||||
margin:0 0 10px;
|
||||
font:14px ui-monospace,monospace;
|
||||
color:#cfefff;
|
||||
line-height:1.5;
|
||||
}
|
||||
.rec-warning-ack{
|
||||
align-items:center;
|
||||
gap:10px;
|
||||
margin:10px 0 14px;
|
||||
color:#cfefff;
|
||||
}
|
||||
.rec-warning-box .btn{
|
||||
width:100%;
|
||||
justify-content:center;
|
||||
}
|
||||
|
||||
.calibration-modal{
|
||||
position:fixed;
|
||||
inset:0;
|
||||
z-index:9400;
|
||||
display:none;
|
||||
align-items:flex-start;
|
||||
justify-content:center;
|
||||
padding:70px 18px 24px;
|
||||
background:rgba(8,8,15,0.7);
|
||||
backdrop-filter:blur(4px);
|
||||
pointer-events:auto;
|
||||
}
|
||||
.calibration-box{
|
||||
width:100%;
|
||||
max-width:700px;
|
||||
max-height:78vh;
|
||||
overflow-y:auto;
|
||||
padding:18px 22px;
|
||||
background:rgba(12,14,22,0.95);
|
||||
border:1px solid #2f4d5d;
|
||||
border-radius:16px;
|
||||
color:#e8f5ff;
|
||||
box-shadow:0 10px 34px rgba(0,0,0,0.4);
|
||||
font-family:ui-monospace,monospace;
|
||||
}
|
||||
.calibration-box h3{
|
||||
margin:0 0 8px;
|
||||
font:600 19px ui-monospace,monospace;
|
||||
color:#c7ffe9;
|
||||
}
|
||||
.calibration-box h4{
|
||||
margin:12px 0 6px;
|
||||
font:600 16px ui-monospace,monospace;
|
||||
color:#9fdba0;
|
||||
}
|
||||
.calibration-box p{
|
||||
margin:0 0 8px;
|
||||
line-height:1.5;
|
||||
font:15px ui-monospace,monospace;
|
||||
color:#dcefff;
|
||||
}
|
||||
.calibration-box .calibration-quote{
|
||||
color:#c9e7ff;
|
||||
font-style:italic;
|
||||
}
|
||||
.calibration-divider{
|
||||
height:1px;
|
||||
margin:10px 0 8px;
|
||||
background:linear-gradient(90deg, transparent 0%, #24424f 15%, #3e5c74 50%, #24424f 85%, transparent 100%);
|
||||
}
|
||||
.calibration-box .btn{
|
||||
width:100%;
|
||||
justify-content:center;
|
||||
margin-top:6px;
|
||||
}
|
||||
|
||||
/* --- Option Sections --- */
|
||||
.opt-head { grid-column: 1 / -1; padding-top: 8px; }
|
||||
.opt-head h3 { margin: 6px 0 2px; color: #c7ffe9; font: 600 16px ui-monospace, monospace; border-bottom: 1px solid #1e2a35; padding-bottom: 6px; }
|
||||
|
||||
#optionsPanelNew summary{font-size:17px;padding:8px 4px;}
|
||||
#optionsPanelNew .opt{font-size:16px;}
|
||||
+1683
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,868 @@
|
||||
// views/classic_needles.js - VU-Meter als klassisches Nadelinstrument
|
||||
// Nutzt die bestehenden VU-Werte (inkl. Offsets/Kalibrierung) und zeichnet
|
||||
// zwei kompakte, horizontale Nadelinstrumente (L/R) mit Slot-Auswahl.
|
||||
import { drawCachedStaticLayer } from './static_layer.js';
|
||||
|
||||
export const id = 'classic-needles';
|
||||
|
||||
const PANEL_BG = 'rgba(5,5,11,0.78)';
|
||||
const FRAME_STROKE = 'rgba(0,231,255,0.8)';
|
||||
const SCALE_ARC_STROKE = 'rgb(0,0,255)'; // wie Tick-Linien der Balken-Meter
|
||||
const METER_PIVOT_Y_FRAC = 0.70;
|
||||
const METER_RADIUS_FRAC = 0.50;
|
||||
const NEEDLE_CAP_RADIUS = 8;
|
||||
const NEEDLE_SPRING_DAMPING = 0.92; // 1.0 = kritisch gedämpft (kein Überschwingen)
|
||||
const NEEDLE_SPRING_HZ_UP = 6.5; // nur UI-Physik (VU-Ballistik ist bereits im Worklet)
|
||||
const NEEDLE_SPRING_HZ_DOWN = 4.2;
|
||||
const NEEDLE_GOAL_TAU_S = 0.04; // ganz leichtes Smoothing gegen UI-Stufen/Jitter
|
||||
const FIELD_DIVIDER_STROKE = 'rgba(0,231,255,0.4)'; // wie Felder-Trennlinien
|
||||
const FIELD_DIVIDER_DASH = [4, 3];
|
||||
// Spannweite wie klassisches VU-Foto: kein Halbkreis, sondern flacherer Bogen
|
||||
const ANGLE_START = degToRad(200) - Math.PI * 2; // links unten
|
||||
const ANGLE_END = degToRad(340) - Math.PI * 2; // rechts unten (-20°)
|
||||
|
||||
const VU_DEFLECTION = [
|
||||
{ db: -20, frac: 0.000 },
|
||||
{ db: -10, frac: 0.165 },
|
||||
{ db: -7, frac: 0.264 },
|
||||
{ db: -5, frac: 0.352 },
|
||||
{ db: -3, frac: 0.463 },
|
||||
{ db: 0, frac: 0.686 },
|
||||
{ db: +1, frac: 0.779 },
|
||||
{ db: +2, frac: 0.883 },
|
||||
{ db: +3, frac: 1.000 },
|
||||
];
|
||||
|
||||
const DIN_SCALE = [
|
||||
{ db: -50, pos: 0.0205 },
|
||||
{ db: -40, pos: 0.0564 },
|
||||
{ db: -35, pos: 0.0974 },
|
||||
{ db: -30, pos: 0.1538 },
|
||||
{ db: -25, pos: 0.2308 },
|
||||
{ db: -20, pos: 0.3179 },
|
||||
{ db: -15, pos: 0.4359 },
|
||||
{ db: -10, pos: 0.5538 },
|
||||
{ db: -5, pos: 0.7026 },
|
||||
{ db: 0, pos: 0.8513 },
|
||||
{ db: +5, pos: 1.0000 },
|
||||
];
|
||||
|
||||
const TP_PERCENT_SCALE = [
|
||||
{ db: -60, frac: 0.0000 },
|
||||
{ db: -50, frac: 0.0373 },
|
||||
{ db: -40, frac: 0.1429 },
|
||||
{ db: -35, frac: 0.2112 },
|
||||
{ db: -30, frac: 0.2857 },
|
||||
{ db: -25, frac: 0.3851 },
|
||||
{ db: -20, frac: 0.4783 },
|
||||
{ db: -15, frac: 0.6087 },
|
||||
{ db: -10, frac: 0.7391 },
|
||||
{ db: -5, frac: 0.8696 },
|
||||
{ db: 0, frac: 1.0000 },
|
||||
];
|
||||
|
||||
const TP_EXTRA_MINOR_TICKS = [-47, -19, -18, -17, -16, -14, -13, -12, -11, -9, -8, -7, -6, -4, -3, -2, -1];
|
||||
|
||||
const PPM_EBU_MAJOR_TICKS = [-12, -8, -4, 0, +4, +8, +12];
|
||||
const PPM_EBU_MINOR_TICKS = [-10, -6, -2, +2, +6, +9, +10];
|
||||
|
||||
const PPM_DIN_MAJOR_TICKS = [-50, -40, -30, -20, -10, -5, 0, +5];
|
||||
const PPM_DIN_MINOR_TICKS = [
|
||||
{ db: -35 },
|
||||
{ db: -25 },
|
||||
{ db: -21, color: 'warn' },
|
||||
{ db: -15 },
|
||||
{ db: -9 },
|
||||
{ db: -8 },
|
||||
{ db: -7 },
|
||||
{ db: -6 },
|
||||
{ db: -4 },
|
||||
{ db: -3 },
|
||||
{ db: -2 },
|
||||
{ db: -1 },
|
||||
{ db: +1 },
|
||||
{ db: +2 },
|
||||
{ db: +3 },
|
||||
{ db: +4 },
|
||||
];
|
||||
|
||||
export function init() {
|
||||
return {
|
||||
staticLayers: new Map(),
|
||||
smooth: { L: null, R: null },
|
||||
goal: { L: null, R: null },
|
||||
vel: { L: 0, R: 0 },
|
||||
lastTs: (typeof performance !== 'undefined' ? performance.now() : Date.now()),
|
||||
};
|
||||
}
|
||||
export function resize() {}
|
||||
export function destroy(state) {
|
||||
if (state) state.staticLayers = null;
|
||||
}
|
||||
|
||||
export async function render(env, state) {
|
||||
const { ctx: g, rect, config: CONFIG, meters, audio } = env;
|
||||
const meterId = resolveNeedleMeterId(env);
|
||||
if (!meterId) {
|
||||
drawCachedStaticLayer(state, g, 'classic-empty', 'empty', rect, (cg) => {
|
||||
cg.fillStyle = PANEL_BG;
|
||||
cg.fillRect(0, 0, rect.w, rect.h);
|
||||
cg.fillStyle = '#bcd';
|
||||
cg.textAlign = 'left';
|
||||
cg.font = 'bold 16px ui-monospace, monospace';
|
||||
cg.fillText('Classic Needles', 12, 44);
|
||||
cg.fillStyle = '#9aa';
|
||||
cg.font = '14px ui-monospace, monospace';
|
||||
cg.fillText('Slot leer — wähle ein Meter im HUD.', 12, 66);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const scale = getNeedleScaleDescriptor(meterId, CONFIG);
|
||||
const meterState = meters?.getState?.(meterId) || null;
|
||||
const raw = readMeterDisplayLR(meterId, meterState, CONFIG, scale, audio);
|
||||
const targets = { L: scale.valueToNorm(raw.L), R: scale.valueToNorm(raw.R) };
|
||||
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||||
smoothNeedle(state, targets, now);
|
||||
// Box-Abmessungen an den Real-Time-Analyzer anlehnen (gleiche Offsets)
|
||||
const BOX_LEFT = 0;
|
||||
const BOX_TOP = Number.isFinite(env?.topInset) ? Number(env.topInset) : 70;
|
||||
const BOX_RIGHT = 0;
|
||||
const BOX_BOTTOM = 0;
|
||||
const outerRect = {
|
||||
x: rect.x + BOX_LEFT,
|
||||
y: rect.y + BOX_TOP,
|
||||
w: Math.max(140, rect.w - BOX_LEFT - BOX_RIGHT),
|
||||
h: Math.max(140, rect.h - BOX_TOP - BOX_BOTTOM),
|
||||
};
|
||||
const INNER_INSET_X = Math.round(Math.min(140, outerRect.w * 0.08));
|
||||
const innerRect = {
|
||||
x: outerRect.x + INNER_INSET_X,
|
||||
y: outerRect.y,
|
||||
w: Math.max(140, outerRect.w - INNER_INSET_X * 2),
|
||||
h: outerRect.h,
|
||||
};
|
||||
const innerGap = Math.max(56, innerRect.w * 0.12);
|
||||
const availableW = Math.max(100, innerRect.w - innerGap);
|
||||
const meterW = availableW / 2;
|
||||
const meterH = outerRect.h;
|
||||
const baseY = outerRect.y;
|
||||
const decorMetrics = createDecorMetrics(meterW, innerGap);
|
||||
const gapCenterX = innerRect.x + meterW + innerGap / 2;
|
||||
const leftBoxClamp = decorMetrics.ownBounds
|
||||
? {
|
||||
x: outerRect.x,
|
||||
y: outerRect.y,
|
||||
w: Math.max(0, Math.floor(gapCenterX - outerRect.x)),
|
||||
h: outerRect.h,
|
||||
}
|
||||
: outerRect;
|
||||
const rightBoxClamp = decorMetrics.ownBounds
|
||||
? {
|
||||
x: Math.ceil(gapCenterX),
|
||||
y: outerRect.y,
|
||||
w: Math.max(0, (outerRect.x + outerRect.w) - Math.ceil(gapCenterX)),
|
||||
h: outerRect.h,
|
||||
}
|
||||
: outerRect;
|
||||
|
||||
const metersRects = [
|
||||
{ x: innerRect.x, y: baseY, w: meterW, h: meterH, label: 'L', norm: state.smooth.L, raw: raw.L, boxClamp: leftBoxClamp },
|
||||
{ x: innerRect.x + meterW + innerGap, y: baseY, w: meterW, h: meterH, label: 'R', norm: state.smooth.R, raw: raw.R, boxClamp: rightBoxClamp },
|
||||
];
|
||||
|
||||
drawCachedStaticLayer(
|
||||
state,
|
||||
g,
|
||||
'classic-shell',
|
||||
[
|
||||
meterId,
|
||||
rect.w,
|
||||
rect.h,
|
||||
outerRect.x,
|
||||
outerRect.y,
|
||||
outerRect.w,
|
||||
outerRect.h,
|
||||
innerRect.x,
|
||||
innerRect.w,
|
||||
innerGap,
|
||||
scale.kind,
|
||||
scale.bottom,
|
||||
scale.top,
|
||||
scale.redStart,
|
||||
getFooterText(meterId, CONFIG),
|
||||
JSON.stringify(scale.majorTicks || []),
|
||||
JSON.stringify(scale.minorTicks || []),
|
||||
].join('|'),
|
||||
rect,
|
||||
(cg) => {
|
||||
cg.fillStyle = PANEL_BG;
|
||||
cg.fillRect(0, 0, rect.w, rect.h);
|
||||
cg.save();
|
||||
cg.translate(-rect.x, -rect.y);
|
||||
cg.strokeStyle = FRAME_STROKE;
|
||||
cg.lineWidth = 2;
|
||||
cg.strokeRect(outerRect.x, outerRect.y, outerRect.w, outerRect.h);
|
||||
metersRects.forEach((m) => {
|
||||
drawClassicMeter(cg, m, scale, decorMetrics);
|
||||
drawMeterBox(cg, m, m.boxClamp || outerRect, decorMetrics);
|
||||
drawRefLabel(cg, m, meterId, CONFIG, decorMetrics);
|
||||
drawNeedleCap(cg, m);
|
||||
});
|
||||
cg.restore();
|
||||
},
|
||||
);
|
||||
|
||||
metersRects.forEach((m) => {
|
||||
drawNeedle(g, m, m.norm, getNeedleColor(m.norm, scale));
|
||||
});
|
||||
}
|
||||
|
||||
function createDecorMetrics(meterW, innerGap) {
|
||||
const widthT = clamp((meterW - 150) / 170, 0, 1);
|
||||
const gapT = clamp((innerGap - 56) / 36, 0, 1);
|
||||
const t = Math.min(widthT, gapT);
|
||||
return {
|
||||
ownBounds: meterW < 300 || innerGap < 72,
|
||||
refFont: roundLerp(10, 12, t),
|
||||
refOffsetY: roundLerp(7, 10, t),
|
||||
boxOuterExtra: roundLerp(30, 48, t),
|
||||
boxPadX: roundLerp(4, 10, t),
|
||||
boxPadTop: roundLerp(6, 10, t),
|
||||
boxPadBottom: roundLerp(10, 16, t),
|
||||
channelFont: roundLerp(18, 26, t),
|
||||
channelPad: roundLerp(5, 8, t),
|
||||
majorFont: roundLerp(9, 13, t),
|
||||
majorTickWarn: roundLerp(11, 16, t),
|
||||
majorTickNormal: roundLerp(9, 14, t),
|
||||
majorLabelOffset: roundLerp(14, 20, t),
|
||||
unitFont: roundLerp(9, 12, t),
|
||||
unitAngleOffset: lerp(0.10, 0.16, t),
|
||||
unitOffset: roundLerp(24, 34, t),
|
||||
pctFont: roundLerp(10, 16, t),
|
||||
pctInset: roundLerp(30, 48, t),
|
||||
pct100YOffset: roundLerp(5, 10, t),
|
||||
pctSignFont: roundLerp(11, 18, t),
|
||||
pctSignOffsetX: roundLerp(10, 18, t),
|
||||
pctSignOffsetY: roundLerp(14, 28, t),
|
||||
};
|
||||
}
|
||||
|
||||
function drawRefLabel(g, rect, meterId, CONFIG, metrics) {
|
||||
const modeText = getFooterText(meterId, CONFIG);
|
||||
const refFont = metrics?.refFont ?? 12;
|
||||
const refOffsetY = metrics?.refOffsetY ?? 10;
|
||||
g.save();
|
||||
g.fillStyle = 'rgba(207,233,255,0.7)';
|
||||
g.font = `${refFont}px ui-monospace, monospace`;
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'alphabetic';
|
||||
g.fillText(modeText, rect.x + rect.w / 2, rect.y + rect.h - refOffsetY);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawMeterBox(g, rect, clampRect, metrics) {
|
||||
const cx = rect.x + rect.w / 2;
|
||||
const cy = rect.y + rect.h * METER_PIVOT_Y_FRAC;
|
||||
const radius = Math.min(rect.w, rect.h * 1.3) * METER_RADIUS_FRAC;
|
||||
|
||||
// Include labels (+20) and "- dB" (+34) plus some safety margin
|
||||
const outerR = radius + (metrics?.boxOuterExtra ?? 48);
|
||||
const xSpan = Math.max(Math.abs(Math.cos(ANGLE_START)), Math.abs(Math.cos(ANGLE_END))) * outerR;
|
||||
|
||||
const padX = metrics?.boxPadX ?? 10;
|
||||
const padTop = metrics?.boxPadTop ?? 10;
|
||||
const padBottom = metrics?.boxPadBottom ?? 16;
|
||||
|
||||
let x = cx - xSpan - padX;
|
||||
let y = cy - outerR - padTop;
|
||||
let w = (xSpan + padX) * 2;
|
||||
let h = (cy + NEEDLE_CAP_RADIUS + padBottom) - y;
|
||||
|
||||
const clampSource = clampRect || rect;
|
||||
|
||||
// Clamp to the available panel area. In compact layouts keep each box inside its own half.
|
||||
const minX = (clampSource?.x ?? rect.x) + 2;
|
||||
const minY = (clampSource?.y ?? rect.y) + 2;
|
||||
const maxX = (clampSource?.x ?? rect.x) + (clampSource?.w ?? rect.w) - 2;
|
||||
const maxY = (clampSource?.y ?? rect.y) + (clampSource?.h ?? rect.h) - 2;
|
||||
if (x < minX) { w -= (minX - x); x = minX; }
|
||||
if (y < minY) { h -= (minY - y); y = minY; }
|
||||
if (x + w > maxX) w = maxX - x;
|
||||
if (y + h > maxY) h = maxY - y;
|
||||
|
||||
if (w <= 2 || h <= 2) return;
|
||||
|
||||
g.save();
|
||||
g.strokeStyle = FIELD_DIVIDER_STROKE;
|
||||
g.lineWidth = 1;
|
||||
g.setLineDash(FIELD_DIVIDER_DASH);
|
||||
g.strokeRect(Math.round(x) + 0.5, Math.round(y) + 0.5, Math.round(w) - 1, Math.round(h) - 1);
|
||||
g.setLineDash([]);
|
||||
|
||||
const chan = rect?.label;
|
||||
if (chan === 'L' || chan === 'R') {
|
||||
g.fillStyle = 'rgba(207,233,255,0.7)';
|
||||
g.font = `bold ${metrics?.channelFont ?? 26}px ui-monospace, monospace`;
|
||||
g.textBaseline = 'top';
|
||||
const pad = metrics?.channelPad ?? 8;
|
||||
if (chan === 'L') {
|
||||
g.textAlign = 'left';
|
||||
g.fillText('L', x + pad, y + pad - 2);
|
||||
} else {
|
||||
g.textAlign = 'right';
|
||||
g.fillText('R', x + w - pad, y + pad - 2);
|
||||
}
|
||||
}
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function getNeedleColor(norm, scale) {
|
||||
const warn = scale?.warnColor || '#ff3b3b';
|
||||
const normal = scale?.normalColor || '#ffe066';
|
||||
if (Number.isFinite(scale?.redStart) && typeof scale?.valueToNorm === 'function') {
|
||||
const redNorm = scale.valueToNorm(scale.redStart);
|
||||
if (Number.isFinite(redNorm) && Number.isFinite(norm) && norm >= redNorm) return warn;
|
||||
}
|
||||
return normal;
|
||||
}
|
||||
|
||||
function resolveNeedleMeterId(env) {
|
||||
const slotList = env?.slots?.(id);
|
||||
const selected = Array.isArray(slotList) ? slotList[0] : null;
|
||||
if (selected === 'none') return null;
|
||||
return selected || 'vu';
|
||||
}
|
||||
|
||||
function getFooterText(meterId, CONFIG) {
|
||||
if (meterId === 'vu') {
|
||||
return Number.isFinite(CONFIG?.VU_DBFS_REF) ? `Ref ${CONFIG.VU_DBFS_REF.toFixed(1)} dBFS` : 'VU';
|
||||
}
|
||||
if (meterId === 'ppm-ebu') return 'PPM (EBU)';
|
||||
if (meterId === 'ppm-din') return 'PPM (DIN)';
|
||||
if (meterId === 'tp') return 'dBTP';
|
||||
if (meterId === 'rms') {
|
||||
const isDBU = (String(CONFIG?.RMS_MODE ?? 'dbfs').toLowerCase() === 'dbu');
|
||||
return isDBU ? 'RMS (dBu)' : 'RMS (dBFS RMS)';
|
||||
}
|
||||
if (meterId === 'lufs') return 'LUFS (M)';
|
||||
return String(meterId || 'meter');
|
||||
}
|
||||
|
||||
function getNeedleScaleDescriptor(meterId, CONFIG) {
|
||||
if (meterId === 'ppm-ebu') {
|
||||
const bottom = Number.isFinite(CONFIG?.PPM_EBU_BOTTOM) ? CONFIG.PPM_EBU_BOTTOM : -14;
|
||||
const top = Number.isFinite(CONFIG?.PPM_EBU_TOP) ? CONFIG.PPM_EBU_TOP : +14;
|
||||
const redStart = Number.isFinite(CONFIG?.PPM_EBU_RED_START) ? CONFIG.PPM_EBU_RED_START : +9;
|
||||
const warnColor = CONFIG?.PPM_EBU_COLOR_WARN || '#ff3b3b';
|
||||
const normalColor = CONFIG?.PPM_EBU_COLOR_NORMAL || '#ffe066';
|
||||
const valueToNorm = (v) => {
|
||||
const clamped = clamp(v, bottom, top);
|
||||
return clamp01((clamped - bottom) / (top - bottom || 1));
|
||||
};
|
||||
return {
|
||||
id: meterId,
|
||||
kind: 'ppm-ebu',
|
||||
bottom,
|
||||
top,
|
||||
redStart,
|
||||
warnColor,
|
||||
normalColor,
|
||||
unitLabel: 'dB',
|
||||
majorTicks: PPM_EBU_MAJOR_TICKS,
|
||||
minorTicks: PPM_EBU_MINOR_TICKS,
|
||||
formatMajor: (db) => (db === 0 ? 'TEST' : (db > 0 ? `+${db}` : `${db}`)),
|
||||
valueToNorm,
|
||||
};
|
||||
}
|
||||
|
||||
if (meterId === 'ppm-din') {
|
||||
const bottom = Number.isFinite(CONFIG?.PPM_DIN_BOTTOM) ? CONFIG.PPM_DIN_BOTTOM : -50;
|
||||
const top = Number.isFinite(CONFIG?.PPM_DIN_TOP) ? CONFIG.PPM_DIN_TOP : +5;
|
||||
const redStart = Number.isFinite(CONFIG?.PPM_DIN_RED_START) ? CONFIG.PPM_DIN_RED_START : 0;
|
||||
const warnColor = CONFIG?.PPM_DIN_COLOR_WARN || '#ff3b3b';
|
||||
const normalColor = CONFIG?.PPM_DIN_COLOR_NORMAL || '#ffe066';
|
||||
const mapRaw = (db) => {
|
||||
const clamped = clamp(db, bottom, top);
|
||||
let prev = DIN_SCALE[0];
|
||||
for (let i = 1; i < DIN_SCALE.length; i++) {
|
||||
const curr = DIN_SCALE[i];
|
||||
if (clamped <= curr.db) {
|
||||
const span = curr.db - prev.db || 1;
|
||||
const t = (clamped - prev.db) / span;
|
||||
return prev.pos + t * (curr.pos - prev.pos);
|
||||
}
|
||||
prev = curr;
|
||||
}
|
||||
return DIN_SCALE[DIN_SCALE.length - 1].pos;
|
||||
};
|
||||
const normBottom = mapRaw(bottom);
|
||||
const normSpan = Math.max(1e-6, 1 - normBottom);
|
||||
const valueToNorm = (v) => {
|
||||
const n = mapRaw(v);
|
||||
return clamp01((n - normBottom) / normSpan);
|
||||
};
|
||||
const showAl = CONFIG?.AL_MARKERS_ENABLED !== false;
|
||||
const dynWarnDb = showAl ? ((CONFIG?.PPM_DIN_MODE === 'al_minus6') ? -6 : -9) : null;
|
||||
const minors = PPM_DIN_MINOR_TICKS.slice().map((t) => ({
|
||||
value: t.db,
|
||||
color: (t.color === 'warn' || (dynWarnDb !== null && t.db === dynWarnDb)) ? 'warn' : null,
|
||||
}));
|
||||
return {
|
||||
id: meterId,
|
||||
kind: 'ppm-din',
|
||||
bottom,
|
||||
top,
|
||||
redStart,
|
||||
warnColor,
|
||||
normalColor,
|
||||
unitLabel: 'dB',
|
||||
majorTicks: PPM_DIN_MAJOR_TICKS,
|
||||
minorTicks: minors,
|
||||
formatMajor: (db) => (db > 0 ? `+${db}` : `${db}`),
|
||||
valueToNorm,
|
||||
};
|
||||
}
|
||||
|
||||
if (meterId === 'tp') {
|
||||
const bottom = -60;
|
||||
const top = 0;
|
||||
const redStart = Number.isFinite(CONFIG?.TP_RED_START) ? CONFIG.TP_RED_START : -1;
|
||||
const warnColor = CONFIG?.TP_COLOR_WARN || '#ff3b3b';
|
||||
const normalColor = CONFIG?.TP_COLOR_NORMAL || '#ffe066';
|
||||
const mapRaw = (db) => {
|
||||
const clamped = clamp(db, bottom, top);
|
||||
if (clamped <= TP_PERCENT_SCALE[0].db) return TP_PERCENT_SCALE[0].frac;
|
||||
if (clamped >= TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].db) return TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].frac;
|
||||
for (let i = 1; i < TP_PERCENT_SCALE.length; i++) {
|
||||
const prev = TP_PERCENT_SCALE[i - 1];
|
||||
const curr = TP_PERCENT_SCALE[i];
|
||||
if (clamped <= curr.db) {
|
||||
const span = curr.db - prev.db || 1;
|
||||
const t = (clamped - prev.db) / span;
|
||||
return prev.frac + t * (curr.frac - prev.frac);
|
||||
}
|
||||
}
|
||||
return TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].frac;
|
||||
};
|
||||
const valueToNorm = (v) => clamp01(mapRaw(v));
|
||||
return {
|
||||
id: meterId,
|
||||
kind: 'tp',
|
||||
bottom,
|
||||
top,
|
||||
redStart,
|
||||
warnColor,
|
||||
normalColor,
|
||||
unitLabel: 'dBTP',
|
||||
majorTicks: TP_PERCENT_SCALE.map((p) => p.db),
|
||||
minorTicks: TP_EXTRA_MINOR_TICKS,
|
||||
formatMajor: (db) => {
|
||||
const v = Math.abs(db);
|
||||
return Number.isInteger(v) ? String(v) : v.toFixed(1);
|
||||
},
|
||||
valueToNorm,
|
||||
};
|
||||
}
|
||||
|
||||
if (meterId === 'rms') {
|
||||
const isDBU = (String(CONFIG?.RMS_MODE ?? 'dbfs').toLowerCase() === 'dbu');
|
||||
const bottom = -60;
|
||||
const top = isDBU ? +24 : 0;
|
||||
const redStart = Number.isFinite(CONFIG?.RMS_RED_START) ? CONFIG.RMS_RED_START : (isDBU ? +20 : 0);
|
||||
const warnColor = CONFIG?.RMS_COLOR_WARN || '#ff3b3b';
|
||||
const normalColor = CONFIG?.RMS_COLOR_NORMAL || '#34d399';
|
||||
const valueToNorm = (db) => {
|
||||
const v = clamp(db, bottom, top);
|
||||
if (!isDBU) return clamp01((v - bottom) / (top - bottom || 1));
|
||||
|
||||
if (v <= -20) {
|
||||
const t = (v + 60) / 40;
|
||||
return clamp01(Math.pow(t, 1.6) * 0.45);
|
||||
}
|
||||
if (v <= 0) {
|
||||
const t = (v + 20) / 20;
|
||||
return clamp01(0.45 + t * 0.25);
|
||||
}
|
||||
if (v <= 20) {
|
||||
const t = v / 20;
|
||||
return clamp01(0.70 + t * 0.25);
|
||||
}
|
||||
if (v <= 24) {
|
||||
const t = (v - 20) / 4;
|
||||
return clamp01(0.95 + t * 0.05);
|
||||
}
|
||||
return 1;
|
||||
};
|
||||
const majors = isDBU
|
||||
? [24, 20, 10, 0, -10, -20, -30, -40, -50, -60]
|
||||
: [0, -6, -12, -18, -24, -30, -40, -60];
|
||||
const minors = isDBU ? [] : [-3, -9, -15, -21, -27, -33, -36, -42, -48, -54];
|
||||
return {
|
||||
id: meterId,
|
||||
kind: 'rms',
|
||||
bottom,
|
||||
top,
|
||||
redStart,
|
||||
warnColor,
|
||||
normalColor,
|
||||
unitLabel: isDBU ? 'dBu' : 'dBFS',
|
||||
majorTicks: majors,
|
||||
minorTicks: minors,
|
||||
formatMajor: (v) => (v > 0 ? `+${v}` : `${v}`),
|
||||
valueToNorm,
|
||||
rmsMode: isDBU ? 'dbu' : 'dbfs',
|
||||
};
|
||||
}
|
||||
|
||||
if (meterId === 'lufs') {
|
||||
const bottom = -50;
|
||||
const top = -5;
|
||||
const redStart = Number.isFinite(CONFIG?.LUFS_RED_START)
|
||||
? CONFIG.LUFS_RED_START
|
||||
: (Number.isFinite(CONFIG?.LUFS_YELLOW_START) ? CONFIG.LUFS_YELLOW_START : -2);
|
||||
const warnColor = CONFIG?.LUFS_COLOR_RED || '#ff3b3b';
|
||||
const normalColor = CONFIG?.LUFS_COLOR_YELLOW || '#ffe066';
|
||||
const valueToNorm = (v) => {
|
||||
const clamped = clamp(v, bottom, top);
|
||||
return clamp01((clamped - bottom) / (top - bottom || 1));
|
||||
};
|
||||
return {
|
||||
id: meterId,
|
||||
kind: 'lufs',
|
||||
bottom,
|
||||
top,
|
||||
redStart,
|
||||
warnColor,
|
||||
normalColor,
|
||||
unitLabel: 'LUFS',
|
||||
majorTicks: [-50, -40, -30, -23, -18, -10, -5],
|
||||
minorTicks: [-45, -35, -25, -20, -15],
|
||||
formatMajor: (v) => `${v}`,
|
||||
valueToNorm,
|
||||
};
|
||||
}
|
||||
|
||||
// Default: VU (Classic-Needles Look)
|
||||
{
|
||||
const bottom = -20;
|
||||
const top = +3;
|
||||
const redStart = Number.isFinite(CONFIG?.VU_RED_START) ? CONFIG.VU_RED_START : 0;
|
||||
const warnColor = CONFIG?.VU_COLOR_WARN || '#ff3b3b';
|
||||
const normalColor = CONFIG?.VU_COLOR_NORMAL || '#ffe066';
|
||||
const mapRaw = (dB) => {
|
||||
const db = clamp(dB, bottom, top);
|
||||
const table = VU_DEFLECTION;
|
||||
if (db <= table[0].db) return table[0].frac;
|
||||
if (db >= table[table.length - 1].db) return table[table.length - 1].frac;
|
||||
for (let i = 1; i < table.length; i++) {
|
||||
const prev = table[i - 1];
|
||||
const curr = table[i];
|
||||
if (db <= curr.db) {
|
||||
const span = curr.db - prev.db || 1;
|
||||
const t = (db - prev.db) / span;
|
||||
return prev.frac + t * (curr.frac - prev.frac);
|
||||
}
|
||||
}
|
||||
return table[table.length - 1].frac;
|
||||
};
|
||||
const valueToNorm = (v) => clamp01(mapRaw(v));
|
||||
return {
|
||||
id: 'vu',
|
||||
kind: 'vu',
|
||||
bottom,
|
||||
top,
|
||||
redStart,
|
||||
warnColor,
|
||||
normalColor,
|
||||
unitLabel: '- dB',
|
||||
majorTicks: [-20, -10, -7, -5, -3, -1, 0, 1, 2, 3],
|
||||
minorTicks: [-15, -12, -9, -8, -6, -4, -2],
|
||||
formatMajor: (db) => {
|
||||
if (db < 0) return String(Math.abs(db));
|
||||
if (db === 3) return '*';
|
||||
return String(db);
|
||||
},
|
||||
valueToNorm,
|
||||
vuPercentMarks: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function readMeterDisplayLR(meterId, shared, CONFIG, scale, audio) {
|
||||
const fallback = () => scale?.bottom ?? -60;
|
||||
|
||||
if (meterId === 'lufs') {
|
||||
const v = Number.isFinite(shared?.momentary) ? shared.momentary : fallback();
|
||||
return { L: v, R: v };
|
||||
}
|
||||
|
||||
const hasLR = Number.isFinite(shared?.values?.L) || Number.isFinite(shared?.values?.R);
|
||||
if (!shared || !hasLR) return { L: fallback(), R: fallback() };
|
||||
|
||||
let rawL = Number.isFinite(shared.values.L) ? shared.values.L : fallback();
|
||||
let rawR = Number.isFinite(shared.values.R) ? shared.values.R : fallback();
|
||||
|
||||
if (meterId === 'vu') {
|
||||
const effOff = Number.isFinite(CONFIG?.VU_OFFSET_DB) ? CONFIG.VU_OFFSET_DB : 0;
|
||||
const offCorr = effOff - (shared.offset || 0);
|
||||
rawL += offCorr;
|
||||
rawR += offCorr;
|
||||
} else if (meterId === 'tp') {
|
||||
const effOff = Number.isFinite(CONFIG?.TP_OFFSET_DB) ? CONFIG.TP_OFFSET_DB : 0;
|
||||
const offCorr = effOff - (shared.offset || 0);
|
||||
rawL += offCorr;
|
||||
rawR += offCorr;
|
||||
} else if (meterId === 'ppm-ebu') {
|
||||
const effOff = Number.isFinite(CONFIG?.PPM_EBU_OFFSET) ? CONFIG.PPM_EBU_OFFSET : 0;
|
||||
const offCorr = effOff - (shared.offset || 0);
|
||||
rawL += offCorr;
|
||||
rawR += offCorr;
|
||||
} else if (meterId === 'ppm-din') {
|
||||
const baseMode = (CONFIG?.PPM_DIN_MODE === 'al_minus6') ? -6 : -9;
|
||||
const effOff = baseMode + (Number(CONFIG?.PPM_DIN_TRIM_DB) || 0);
|
||||
const offCorr = effOff - (shared.offset || 0);
|
||||
rawL += offCorr;
|
||||
rawR += offCorr;
|
||||
} else if (meterId === 'rms') {
|
||||
const effOff = Number.isFinite(CONFIG?.RMS_OFFSET_DB) ? CONFIG.RMS_OFFSET_DB : 0;
|
||||
const offCorr = effOff - (shared.offset || 0);
|
||||
rawL += offCorr;
|
||||
rawR += offCorr;
|
||||
|
||||
const mode = String(CONFIG?.RMS_MODE ?? 'dbfs').toLowerCase();
|
||||
const isDBU = (mode === 'dbu');
|
||||
if (isDBU) {
|
||||
const refDbfs = Number.isFinite(CONFIG?.RMS_REF_DBFS_FOR_REF_DBU) ? CONFIG.RMS_REF_DBFS_FOR_REF_DBU : -18;
|
||||
const refDbu = Number.isFinite(CONFIG?.RMS_REF_DBU) ? CONFIG.RMS_REF_DBU : +4;
|
||||
rawL = (rawL - refDbfs) + refDbu;
|
||||
rawR = (rawR - refDbfs) + refDbu;
|
||||
}
|
||||
}
|
||||
|
||||
return { L: rawL, R: rawR };
|
||||
}
|
||||
|
||||
function smoothNeedle(state, target, now) {
|
||||
const prevTs = state.lastTs || now;
|
||||
const dtFull = Math.max(1e-3, Math.min(0.20, (now - prevTs) / 1000));
|
||||
const maxStep = 1 / 120;
|
||||
const steps = Math.max(1, Math.ceil(dtFull / maxStep));
|
||||
const dt = dtFull / steps;
|
||||
|
||||
if (!state.vel) state.vel = { L: 0, R: 0 };
|
||||
if (!state.goal) state.goal = { L: null, R: null };
|
||||
|
||||
const step = (ch) => {
|
||||
const goal = target[ch];
|
||||
if (!Number.isFinite(goal)) return;
|
||||
|
||||
if (!Number.isFinite(state.smooth[ch])) {
|
||||
state.smooth[ch] = goal;
|
||||
state.vel[ch] = 0;
|
||||
state.goal[ch] = goal;
|
||||
return;
|
||||
}
|
||||
|
||||
let x = state.smooth[ch];
|
||||
let v = Number.isFinite(state.vel[ch]) ? state.vel[ch] : 0;
|
||||
let g = Number.isFinite(state.goal[ch]) ? state.goal[ch] : goal;
|
||||
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const alphaGoal = 1 - Math.exp(-dt / NEEDLE_GOAL_TAU_S);
|
||||
g = g + alphaGoal * (goal - g);
|
||||
|
||||
const rising = g > x;
|
||||
const hz = rising ? NEEDLE_SPRING_HZ_UP : NEEDLE_SPRING_HZ_DOWN;
|
||||
const omega = 2 * Math.PI * hz;
|
||||
const zeta = NEEDLE_SPRING_DAMPING;
|
||||
|
||||
// Gedämpfter Feder-Masse-Dämpfer (semi-implicit Euler): x'' + 2ζω x' + ω²(x-goal)=0
|
||||
const a = (omega * omega) * (g - x) - (2 * zeta * omega) * v;
|
||||
v = v + a * dt;
|
||||
x = x + v * dt;
|
||||
}
|
||||
|
||||
state.goal[ch] = g;
|
||||
state.vel[ch] = v;
|
||||
state.smooth[ch] = x;
|
||||
};
|
||||
|
||||
step('L');
|
||||
step('R');
|
||||
state.lastTs = now;
|
||||
}
|
||||
|
||||
function drawClassicMeter(g, rect, scale, metrics) {
|
||||
const cx = rect.x + rect.w / 2;
|
||||
const cy = rect.y + rect.h * METER_PIVOT_Y_FRAC;
|
||||
const radius = Math.min(rect.w, rect.h * 1.3) * METER_RADIUS_FRAC;
|
||||
drawScale(g, cx, cy, radius, scale, metrics);
|
||||
}
|
||||
|
||||
function drawScale(g, cx, cy, radius, scale, metrics) {
|
||||
const mapValueToAngle = (v) => lerp(ANGLE_START, ANGLE_END, scale.valueToNorm(v));
|
||||
const redStart = scale.redStart;
|
||||
const warnCol = scale.warnColor || '#ff3b3b';
|
||||
const majors = Array.isArray(scale.majorTicks) ? scale.majorTicks : [];
|
||||
const minors = Array.isArray(scale.minorTicks) ? scale.minorTicks : [];
|
||||
|
||||
// Skalenbogen
|
||||
g.save();
|
||||
g.lineWidth = 3;
|
||||
g.strokeStyle = SCALE_ARC_STROKE;
|
||||
g.beginPath();
|
||||
g.arc(cx, cy, radius, ANGLE_START, ANGLE_END);
|
||||
g.stroke();
|
||||
|
||||
// Major ticks + Beschriftung
|
||||
g.font = `bold ${metrics?.majorFont ?? 13}px ui-monospace, monospace`;
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'middle';
|
||||
for (const v of majors) {
|
||||
const ang = mapValueToAngle(v);
|
||||
const inRed = Number.isFinite(redStart) && v >= redStart;
|
||||
const tickCol = inRed ? warnCol : '#cfe9ff';
|
||||
g.lineWidth = inRed ? 2.5 : 2;
|
||||
g.strokeStyle = tickCol;
|
||||
g.fillStyle = tickCol;
|
||||
drawTick(g, cx, cy, radius, ang, inRed ? (metrics?.majorTickWarn ?? 16) : (metrics?.majorTickNormal ?? 14));
|
||||
const labelOffset = metrics?.majorLabelOffset ?? 20;
|
||||
const tx = cx + Math.cos(ang) * (radius + labelOffset);
|
||||
const ty = cy + Math.sin(ang) * (radius + labelOffset);
|
||||
const label = scale.formatMajor ? scale.formatMajor(v) : String(v);
|
||||
g.fillText(label, tx, ty);
|
||||
}
|
||||
|
||||
// Minor dots
|
||||
const dotRad = Math.max(0, radius - 16);
|
||||
const dotR = 2.2;
|
||||
for (const tick of minors) {
|
||||
const v = (typeof tick === 'number') ? tick : tick?.value;
|
||||
if (!Number.isFinite(v)) continue;
|
||||
const isWarn = (typeof tick === 'object' && tick?.color === 'warn');
|
||||
g.fillStyle = isWarn ? warnCol : 'rgba(207,233,255,0.65)';
|
||||
const ang = mapValueToAngle(v);
|
||||
const x = cx + Math.cos(ang) * dotRad;
|
||||
const y = cy + Math.sin(ang) * dotRad;
|
||||
g.beginPath();
|
||||
g.arc(x, y, dotR, 0, Math.PI * 2);
|
||||
g.fill();
|
||||
}
|
||||
|
||||
// Roter Bereich
|
||||
if (Number.isFinite(redStart)) {
|
||||
const redAng = mapValueToAngle(redStart);
|
||||
g.strokeStyle = warnCol;
|
||||
g.lineWidth = 4;
|
||||
g.beginPath();
|
||||
g.arc(cx, cy, radius, redAng, ANGLE_END);
|
||||
g.stroke();
|
||||
}
|
||||
|
||||
// Unit label oben rechts
|
||||
if (scale.unitLabel) {
|
||||
g.fillStyle = '#cfe9ff';
|
||||
g.font = `bold ${metrics?.unitFont ?? 12}px ui-monospace, monospace`;
|
||||
g.textBaseline = 'alphabetic';
|
||||
const dbLabelAng = ANGLE_END - (metrics?.unitAngleOffset ?? 0.16);
|
||||
const unitOffset = metrics?.unitOffset ?? 34;
|
||||
g.fillText(scale.unitLabel, cx + Math.cos(dbLabelAng) * (radius + unitOffset), cy + Math.sin(dbLabelAng) * (radius + unitOffset));
|
||||
}
|
||||
|
||||
// VU-Percent-Scale (wie Foto)
|
||||
if (scale.vuPercentMarks) {
|
||||
g.font = `bold ${metrics?.pctFont ?? 16}px ui-monospace, monospace`;
|
||||
g.fillStyle = 'rgba(207,233,255,0.8)';
|
||||
g.textBaseline = 'middle';
|
||||
const pctR = Math.max(0, radius - (metrics?.pctInset ?? 48));
|
||||
const posAtValue = (v) => {
|
||||
const a = mapValueToAngle(v);
|
||||
return { x: cx + Math.cos(a) * pctR, y: cy + Math.sin(a) * pctR };
|
||||
};
|
||||
const p10 = posAtValue(-20); // 10% ≙ -20 dB
|
||||
const p50 = posAtValue(-6); // 50% ≙ -6 dB
|
||||
const p100 = posAtValue(0); // 100% ≙ 0 dB
|
||||
g.fillText('10', p10.x, p10.y);
|
||||
g.fillText('50', p50.x, p50.y);
|
||||
g.fillText('100', p100.x, p100.y + (metrics?.pct100YOffset ?? 10));
|
||||
g.font = `bold ${metrics?.pctSignFont ?? 18}px ui-monospace, monospace`;
|
||||
g.fillText('%', p100.x + (metrics?.pctSignOffsetX ?? 18), p100.y + (metrics?.pctSignOffsetY ?? 28));
|
||||
}
|
||||
|
||||
// Glaslinie
|
||||
g.strokeStyle = 'rgba(255,255,255,0.07)';
|
||||
g.lineWidth = 8;
|
||||
g.beginPath();
|
||||
g.arc(cx, cy, radius - 12, ANGLE_START + 0.04, ANGLE_END - 0.04);
|
||||
g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawNeedle(g, rect, norm, color) {
|
||||
const cx = rect.x + rect.w / 2;
|
||||
const cy = rect.y + rect.h * METER_PIVOT_Y_FRAC;
|
||||
const radius = Math.min(rect.w, rect.h * 1.3) * METER_RADIUS_FRAC;
|
||||
const needleLen = radius * 0.9;
|
||||
|
||||
if (!Number.isFinite(norm)) return;
|
||||
const t = clamp01(norm);
|
||||
const ang = lerp(ANGLE_START, ANGLE_END, t);
|
||||
const tipX = cx + Math.cos(ang) * needleLen;
|
||||
const tipY = cy + Math.sin(ang) * needleLen;
|
||||
const baseX = cx + Math.cos(ang + Math.PI) * (needleLen * 0.08);
|
||||
const baseY = cy + Math.sin(ang + Math.PI) * (needleLen * 0.08);
|
||||
|
||||
g.save();
|
||||
g.strokeStyle = color;
|
||||
g.lineWidth = 3;
|
||||
g.beginPath();
|
||||
g.moveTo(baseX, baseY);
|
||||
g.lineTo(tipX, tipY);
|
||||
g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawNeedleCap(g, rect) {
|
||||
const cx = rect.x + rect.w / 2;
|
||||
const cy = rect.y + rect.h * METER_PIVOT_Y_FRAC;
|
||||
const capRadius = NEEDLE_CAP_RADIUS;
|
||||
g.save();
|
||||
const grad = g.createRadialGradient(cx, cy, 2, cx, cy, capRadius * 1.5);
|
||||
grad.addColorStop(0, '#0b111c');
|
||||
grad.addColorStop(1, 'rgba(0,231,255,0.35)');
|
||||
g.fillStyle = grad;
|
||||
g.strokeStyle = '#00e7ff';
|
||||
g.lineWidth = 2;
|
||||
g.beginPath();
|
||||
g.arc(cx, cy, capRadius, 0, Math.PI * 2);
|
||||
g.fill();
|
||||
g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawTick(g, cx, cy, radius, ang, len) {
|
||||
const x1 = cx + Math.cos(ang) * (radius - len);
|
||||
const y1 = cy + Math.sin(ang) * (radius - len);
|
||||
const x2 = cx + Math.cos(ang) * (radius + 2);
|
||||
const y2 = cy + Math.sin(ang) * (radius + 2);
|
||||
g.beginPath();
|
||||
g.moveTo(x1, y1);
|
||||
g.lineTo(x2, y2);
|
||||
g.stroke();
|
||||
}
|
||||
|
||||
function clamp(v, min, max) {
|
||||
return Math.min(max, Math.max(min, v));
|
||||
}
|
||||
function clamp01(v) {
|
||||
return Math.min(1, Math.max(0, v));
|
||||
}
|
||||
function lerp(a, b, t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
function roundLerp(a, b, t) {
|
||||
return Math.round(lerp(a, b, t));
|
||||
}
|
||||
function degToRad(d) {
|
||||
return (d * Math.PI) / 180;
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
// views/clock.js — Vollbild-Studiosclock mit "Zurück"-Button
|
||||
|
||||
const DOT_FONT = {
|
||||
'0': ['01110','10001','10011','10101','11001','10001','01110'],
|
||||
'1': ['00100','01100','00100','00100','00100','00100','01110'],
|
||||
'2': ['01110','10001','00001','00110','01000','10000','11111'],
|
||||
'3': ['11110','00001','00001','01110','00001','00001','11110'],
|
||||
'4': ['00010','00110','01010','10010','11111','00010','00010'],
|
||||
'5': ['11111','10000','11110','00001','00001','10001','01110'],
|
||||
'6': ['00110','01000','10000','11110','10001','10001','01110'],
|
||||
'7': ['11111','00001','00010','00100','01000','01000','01000'],
|
||||
'8': ['01110','10001','10001','01110','10001','10001','01110'],
|
||||
'9': ['01110','10001','10001','01111','00001','00010','01100'],
|
||||
':': ['0','1','0','0','1','0','0'],
|
||||
'.': ['000','000','000','000','000','000','010'],
|
||||
};
|
||||
const DOT_COLS = DOT_FONT['0'][0].length;
|
||||
const DIN_SCALE = [
|
||||
{ db: -50, pos: 0.0205 },
|
||||
{ db: -40, pos: 0.0564 },
|
||||
{ db: -35, pos: 0.0974 },
|
||||
{ db: -30, pos: 0.1538 },
|
||||
{ db: -25, pos: 0.2308 },
|
||||
{ db: -20, pos: 0.3179 },
|
||||
{ db: -15, pos: 0.4359 },
|
||||
{ db: -10, pos: 0.5538 },
|
||||
{ db: -5, pos: 0.7026 },
|
||||
{ db: 0, pos: 0.8513 },
|
||||
{ db: +5, pos: 1.0000 },
|
||||
];
|
||||
|
||||
export const id = 'clock';
|
||||
|
||||
export function init(env) {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = 'Vollbild';
|
||||
btn.className = 'btn btn-compact';
|
||||
Object.assign(btn.style, {
|
||||
position: 'fixed',
|
||||
top: '10px',
|
||||
right: '10px',
|
||||
zIndex: 12000,
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
btn.onclick = () => {
|
||||
toggleFullscreen(env, btn);
|
||||
};
|
||||
document.body.appendChild(btn);
|
||||
return { btn, fullscreen: false };
|
||||
}
|
||||
|
||||
export function destroy(state) {
|
||||
if (state?.btn && state.btn.parentNode) state.btn.parentNode.removeChild(state.btn);
|
||||
}
|
||||
|
||||
export function resize() {}
|
||||
|
||||
export async function render(env, state) {
|
||||
const { ctx: g, rect } = env;
|
||||
const styleCfg = env.config?.CLOCK_STYLE;
|
||||
if (styleCfg === 'digital') {
|
||||
drawClockDigital(g, rect, env, { showPpm: false });
|
||||
} else if (styleCfg === 'digital-ppm') {
|
||||
drawClockDigital(g, rect, env, { showPpm: true });
|
||||
} else if (styleCfg === 'analog-ppm') {
|
||||
drawClockAnalog(g, rect, env, { showPpm: true });
|
||||
} else {
|
||||
drawClockAnalog(g, rect, env, { showPpm: false });
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFullscreen(env, btn) {
|
||||
const hud = document.querySelector('.hud');
|
||||
const options = document.getElementById('optionsPanelNew');
|
||||
const isFs = btn.dataset.fs === '1';
|
||||
if (isFs) {
|
||||
if (hud) hud.style.display = '';
|
||||
if (options) options.style.display = (env.style === 'options-panel') ? 'block' : 'none';
|
||||
btn.textContent = 'Vollbild';
|
||||
btn.dataset.fs = '0';
|
||||
} else {
|
||||
if (hud) hud.style.display = 'none';
|
||||
if (options) options.style.display = 'none';
|
||||
btn.textContent = '↩';
|
||||
btn.dataset.fs = '1';
|
||||
}
|
||||
}
|
||||
|
||||
function drawClockAnalog(ctx, rect, env, opts = {}) {
|
||||
const showPpm = !!opts.showPpm;
|
||||
const d = new Date();
|
||||
const hours = d.getHours();
|
||||
const mins = d.getMinutes();
|
||||
const secs = d.getSeconds() + d.getMilliseconds() / 1000;
|
||||
const centerX = rect.x + rect.w / 2;
|
||||
const centerY = rect.y + rect.h / 2;
|
||||
const radius = Math.min(rect.w, rect.h) * 0.48;
|
||||
const baseDot = Math.max(1.5, Math.round(radius * 0.03));
|
||||
const ringDot = baseDot * 0.7;
|
||||
const glyphDot = baseDot * 1.3;
|
||||
const glow = env.config?.CLOCK_LED_GLOW !== false;
|
||||
const color = normalizeColor(env.config?.CLOCK_LED_COLOR || '#ff0000');
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(8,8,12,1)';
|
||||
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
|
||||
ctx.translate(centerX, centerY);
|
||||
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const ang = (Math.PI * 2 * i) / 60 - Math.PI / 2;
|
||||
const r = radius * 0.88;
|
||||
const filled = i <= secs;
|
||||
const cx = Math.cos(ang) * r;
|
||||
const cy = Math.sin(ang) * r;
|
||||
if (filled) {
|
||||
drawLed(ctx, cx, cy, ringDot, glow, color);
|
||||
} else {
|
||||
ctx.fillStyle = 'rgba(120,120,120,0.25)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, ringDot, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
if (i % 5 === 0) {
|
||||
const outerR = r + ringDot * 3.2;
|
||||
drawLed(ctx, Math.cos(ang) * outerR, Math.sin(ang) * outerR, ringDot, glow, color);
|
||||
}
|
||||
}
|
||||
|
||||
const timeStr = `${pad2(hours)}:${pad2(mins)}`;
|
||||
const glyphSize = radius * 0.32;
|
||||
const timeSpacing = 0.9; // wie Screensaver
|
||||
const timeY = -(glyphSize * 1.2) / 2; // vertikal zentriert
|
||||
const blinkOn = isSecondPulseActive(d);
|
||||
const extraGaps = [0, 3]; // wie Screensaver (Luft zwischen Blöcken)
|
||||
const metrics = measureTextLayout(timeStr, glyphSize, timeSpacing, extraGaps);
|
||||
const timeX = -(metrics.colonCenter ?? metrics.width / 2); // Doppelpunkt auf Mitte
|
||||
drawDotText(ctx, timeStr, timeX, timeY, glyphSize, glyphDot * 1.1, timeSpacing, blinkOn ? (glyphDot * 1.1) : 0, extraGaps, glow, color);
|
||||
|
||||
if (showPpm) {
|
||||
const ppmWidth = radius * 1.2;
|
||||
const ppmY = glyphSize * 1.0; // etwas mehr Abstand zur Uhrzeit
|
||||
const ppmLevels = {
|
||||
L: mapPpmRawToDin(Number.isFinite(env.audio?.ppmDinL) ? env.audio.ppmDinL : env.audio?.ppmL, env.config),
|
||||
R: mapPpmRawToDin(Number.isFinite(env.audio?.ppmDinR) ? env.audio.ppmDinR : env.audio?.ppmR, env.config),
|
||||
};
|
||||
drawMiniPpm(ctx, { cx: 0, cy: ppmY }, ppmWidth, glyphDot, env, ppmLevels);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawClockDigital(ctx, rect, env, opts = {}) {
|
||||
const showPpm = !!opts.showPpm;
|
||||
const d = new Date();
|
||||
const hours = d.getHours();
|
||||
const mins = d.getMinutes();
|
||||
const secs = d.getSeconds() + d.getMilliseconds() / 1000;
|
||||
const day = d.getDate();
|
||||
const month = d.getMonth() + 1;
|
||||
const year = d.getFullYear();
|
||||
const centerX = rect.x + rect.w / 2;
|
||||
const centerY = rect.y + rect.h / 2;
|
||||
const radius = Math.min(rect.w, rect.h) * 0.48;
|
||||
const baseDot = Math.max(1.5, Math.round(radius * 0.028));
|
||||
const glyphDot = baseDot * 1.3;
|
||||
const glyphSize = radius * 0.32;
|
||||
const timeSpacing = 1.2;
|
||||
const blinkOn = isSecondPulseActive(d);
|
||||
const extraGaps = [0, 3, 6]; // nach HH, MM, SS
|
||||
const color = normalizeColor(env.config?.CLOCK_LED_COLOR || '#ff0000');
|
||||
const glow = env.config?.CLOCK_LED_GLOW !== false;
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(8,8,12,1)';
|
||||
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
|
||||
ctx.translate(centerX, centerY);
|
||||
|
||||
const timeStr = `${pad2(hours)}:${pad2(mins)}:${pad2(Math.floor(secs))}`;
|
||||
const dateStr = `${pad2(day)}.${pad2(month)}.${year}`;
|
||||
const dateSize = glyphSize * 0.7;
|
||||
const dateSpacing = 1.1;
|
||||
const dateGaps = [1, 4];
|
||||
const timeMetrics = measureTextLayout(timeStr, glyphSize, timeSpacing, extraGaps);
|
||||
const dateMetrics = measureTextLayout(dateStr, dateSize, dateSpacing, dateGaps);
|
||||
const lineGap = glyphSize * 0.6;
|
||||
const totalH = glyphSize * 1.2 + dateSize * 1.2 + lineGap;
|
||||
const startY = -totalH / 2;
|
||||
const timeX = -timeMetrics.width / 2;
|
||||
const timeY = startY;
|
||||
drawDotText(ctx, timeStr, timeX, timeY, glyphSize, glyphDot, timeSpacing, blinkOn ? glyphDot : 0, extraGaps, glow, color);
|
||||
|
||||
const dateX = -dateMetrics.width / 2;
|
||||
const dateY = startY + glyphSize * 1.2 + lineGap;
|
||||
drawDotText(ctx, dateStr, dateX, dateY, dateSize, glyphDot * 0.9, dateSpacing, null, dateGaps, glow, color, dateSize * 0.8);
|
||||
|
||||
if (showPpm) {
|
||||
const ppmLevels = {
|
||||
L: mapPpmRawToDin(Number.isFinite(env.audio?.ppmDinL) ? env.audio.ppmDinL : env.audio?.ppmL, env.config),
|
||||
R: mapPpmRawToDin(Number.isFinite(env.audio?.ppmDinR) ? env.audio.ppmDinR : env.audio?.ppmR, env.config),
|
||||
};
|
||||
const ppmWidth = dateMetrics.width;
|
||||
const dateBottom = dateY + dateSize * 1.2;
|
||||
const bottom = rect.h / 2;
|
||||
const ppmY = dateBottom + (bottom - dateBottom) * 0.5;
|
||||
drawMiniPpm(ctx, { cx: 0, cy: ppmY }, ppmWidth, glyphDot, env, ppmLevels);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawMiniPpm(ctx, pos, width, dotSize, env, levels) {
|
||||
const normalCol = normalizeColor(env.config?.CLOCK_LED_COLOR || '#ff0000');
|
||||
const warnCol = normalizeColor(env.config?.CLOCK_LED_COLOR || '#ff0000');
|
||||
const bgCol = 'rgba(255,255,255,0.08)';
|
||||
// Feste Farbe wie die %‑Beschriftung im PPM-DIN-Meter, etwas dunkler
|
||||
const tickCol = '#ff9922';
|
||||
const minDb = DIN_SCALE[0].db;
|
||||
const maxDb = DIN_SCALE[DIN_SCALE.length - 1].db;
|
||||
const lvl = [
|
||||
Number.isFinite(levels?.L) ? levels.L : mapPpmRawToDin(Number.isFinite(env.audio?.ppmDinL) ? env.audio.ppmDinL : env.audio?.ppmL, env.config),
|
||||
Number.isFinite(levels?.R) ? levels.R : mapPpmRawToDin(Number.isFinite(env.audio?.ppmDinR) ? env.audio.ppmDinR : env.audio?.ppmR, env.config),
|
||||
];
|
||||
const h = Math.max(4, dotSize * 1.4);
|
||||
const gap = h * 0.6;
|
||||
const halfW = width / 2;
|
||||
const ticks = [-50, -40, -30, -20, -10, -5, 0, +5];
|
||||
|
||||
const norm = (db) => {
|
||||
const clamped = Math.min(maxDb, Math.max(minDb, db));
|
||||
for (let i = 0; i < DIN_SCALE.length - 1; i++) {
|
||||
const a = DIN_SCALE[i];
|
||||
const b = DIN_SCALE[i + 1];
|
||||
if (clamped >= a.db && clamped <= b.db) {
|
||||
const t = (clamped - a.db) / (b.db - a.db || 1);
|
||||
return a.pos + t * (b.pos - a.pos);
|
||||
}
|
||||
}
|
||||
return clamped >= maxDb ? DIN_SCALE[DIN_SCALE.length - 1].pos : DIN_SCALE[0].pos;
|
||||
};
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(pos.cx, pos.cy);
|
||||
|
||||
for (let ch = 0; ch < 2; ch++) {
|
||||
const y = (ch === 0 ? - (h + gap) * 0.5 : (h + gap) * 0.5);
|
||||
ctx.fillStyle = bgCol;
|
||||
ctx.fillRect(-halfW, y - h / 2, width, h);
|
||||
|
||||
const val = norm(lvl[ch]);
|
||||
const fillW = width * val;
|
||||
const over = lvl[ch] > 0;
|
||||
const colNorm = normalCol;
|
||||
const colWarn = normalizeColor('#ff5050');
|
||||
const norm0 = norm(0);
|
||||
const warnStartX = -halfW + norm0 * width;
|
||||
|
||||
const ledMode = env.config?.CLOCK_PPM_LED_MODE === true;
|
||||
const drawTicksAndLabels = () => {
|
||||
ctx.fillStyle = tickCol;
|
||||
ctx.font = `${Math.max(5, h * 0.8)}px ui-monospace, monospace`;
|
||||
ticks.forEach((t) => {
|
||||
const x = -halfW + norm(t) * width;
|
||||
const tickLen = (ch === 1) ? h * 1.8 : h * 1.2; // unterer Balken: längere Ticks
|
||||
ctx.fillRect(x - 0.5, y - h * 0.6, 1, tickLen);
|
||||
if (ch === 1) {
|
||||
ctx.textAlign = (t === -50 ? 'right' : 'left');
|
||||
ctx.textBaseline = 'bottom';
|
||||
const labelX = t === -50 ? (x - h * 0.2) : (x + h * 0.2);
|
||||
ctx.fillText(String(t), labelX, y + tickLen + h * 0.15);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (ledMode) {
|
||||
// Nur untere Label (ch==1) zeichnen, keine Tick-Striche über den LEDs
|
||||
if (ch === 1) {
|
||||
ticks.forEach((t) => {
|
||||
const x = -halfW + norm(t) * width;
|
||||
const tickLen = h * 1.8;
|
||||
const labelY = y + tickLen + h * 0.15;
|
||||
// Verbindungsstrich nur unterhalb des Meters (bis zum Meter-Anfang)
|
||||
const lineTop = y + h * 0.5;
|
||||
ctx.fillStyle = tickCol;
|
||||
ctx.fillRect(x - 0.5, lineTop, 1, Math.max(0, labelY - lineTop));
|
||||
ctx.fillStyle = tickCol;
|
||||
ctx.font = `${Math.max(5, h * 0.8)}px ui-monospace, monospace`;
|
||||
ctx.textAlign = (t === -50 ? 'right' : 'left');
|
||||
ctx.textBaseline = 'bottom';
|
||||
const labelX = t === -50 ? (x - h * 0.2) : (x + h * 0.2);
|
||||
ctx.fillText(String(t), labelX, labelY);
|
||||
});
|
||||
}
|
||||
const step = dotSize * 1.6;
|
||||
const dotR = dotSize * 0.55;
|
||||
for (let x = -halfW; x <= -halfW + fillW; x += step) {
|
||||
const isWarn = x >= warnStartX;
|
||||
const col = isWarn ? colWarn : colNorm;
|
||||
drawLed(ctx, x, y, dotR, env.config?.CLOCK_LED_GLOW !== false, col);
|
||||
}
|
||||
} else {
|
||||
// Norm-Anteil
|
||||
const normW = over ? Math.max(0, warnStartX - (-halfW)) : fillW;
|
||||
if (normW > 0) {
|
||||
const glowH = h * 1.1;
|
||||
ctx.fillStyle = colorWithAlpha(colNorm, 0.12);
|
||||
ctx.fillRect(-halfW, y - glowH / 2, normW, glowH);
|
||||
ctx.fillStyle = colorWithAlpha(colNorm, 0.08);
|
||||
ctx.fillRect(-halfW, y - glowH, normW, glowH * 2);
|
||||
ctx.fillStyle = colNorm;
|
||||
ctx.fillRect(-halfW, y - h / 2, normW, h);
|
||||
}
|
||||
|
||||
// Warn-Anteil
|
||||
if (over) {
|
||||
const warnW = Math.max(0, fillW - (warnStartX - (-halfW)));
|
||||
if (warnW > 0) {
|
||||
const glowH = h * 1.1;
|
||||
ctx.fillStyle = colorWithAlpha(colWarn, 0.15);
|
||||
ctx.fillRect(warnStartX, y - glowH / 2, warnW, glowH);
|
||||
ctx.fillStyle = colorWithAlpha(colWarn, 0.1);
|
||||
ctx.fillRect(warnStartX, y - glowH, warnW, glowH * 2);
|
||||
ctx.fillStyle = colWarn;
|
||||
ctx.fillRect(warnStartX, y - h / 2, warnW, h);
|
||||
}
|
||||
}
|
||||
// Ticks/Labels obenauf im Balken-Modus (Farben wie PPM-DIN Prozent-Marks)
|
||||
ctx.save();
|
||||
const tickColOver = colorWithAlpha(tickCol, ledMode ? 1.0 : 0.6);
|
||||
ctx.fillStyle = tickColOver;
|
||||
ctx.font = `${Math.max(5, h * 0.8)}px ui-monospace, monospace`;
|
||||
ticks.forEach((t) => {
|
||||
const x = -halfW + norm(t) * width;
|
||||
const tickLen = (ch === 1) ? h * 1.8 : h * 1.2; // unterer Balken: längere Ticks
|
||||
ctx.fillRect(x - 0.5, y - h * 0.6, 1, tickLen);
|
||||
if (ch === 1) {
|
||||
ctx.textAlign = (t === -50 ? 'right' : 'left');
|
||||
ctx.textBaseline = 'bottom';
|
||||
const labelX = t === -50 ? (x - h * 0.2) : (x + h * 0.2);
|
||||
ctx.fillText(String(t), labelX, y + tickLen + h * 0.15);
|
||||
}
|
||||
});
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function mapPpmRawToDin(raw, cfg) {
|
||||
const minDb = Number.isFinite(cfg?.PPM_DIN_BOTTOM) ? cfg.PPM_DIN_BOTTOM : DIN_SCALE[0].db;
|
||||
const maxDb = Number.isFinite(cfg?.PPM_DIN_TOP) ? cfg.PPM_DIN_TOP : DIN_SCALE[DIN_SCALE.length - 1].db;
|
||||
if (!Number.isFinite(raw)) return minDb;
|
||||
const base = Number.isFinite(cfg?.PPM_REF_DBFS_PEAK_FOR_0_DBU) ? cfg.PPM_REF_DBFS_PEAK_FOR_0_DBU : -15;
|
||||
const effOff = (cfg?.PPM_DIN_MODE === 'al_minus6' ? -6 : -9) + (Number(cfg?.PPM_DIN_TRIM_DB) || 0);
|
||||
const mapped = (raw - base) + effOff;
|
||||
return Math.max(minDb, Math.min(maxDb, mapped));
|
||||
}
|
||||
|
||||
function drawDotText(ctx, text, x, y, glyphSize, dotRadius, spacingFactor = 0.9, colonDotRadiusOverride = null, extraGapIndices = [], glow = true, color = 'rgb(255,0,0)', glyphHeightOverride = null) {
|
||||
let cursorX = x;
|
||||
const glyphW = glyphSize;
|
||||
const glyphH = (glyphHeightOverride || glyphSize) * 1.2;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
const useDot = (ch === ':' && colonDotRadiusOverride !== null) ? colonDotRadiusOverride : dotRadius;
|
||||
drawDotChar(ctx, ch, cursorX, y, glyphW, glyphH, useDot, glow, color);
|
||||
cursorX += glyphW * spacingFactor;
|
||||
if (extraGapIndices.includes(i)) cursorX += glyphW / DOT_COLS;
|
||||
}
|
||||
}
|
||||
|
||||
function measureTextLayout(text, glyphSize, spacingFactor, extraGapIndices = []) {
|
||||
const glyphW = glyphSize;
|
||||
let cursor = 0;
|
||||
let colonCenter = null;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text[i] === ':') colonCenter = cursor + glyphW * 0.5;
|
||||
cursor += glyphW * spacingFactor;
|
||||
if (extraGapIndices.includes(i)) cursor += glyphW / DOT_COLS;
|
||||
}
|
||||
if (colonCenter === null) colonCenter = cursor / 2;
|
||||
return { width: cursor, colonCenter };
|
||||
}
|
||||
|
||||
function drawDotChar(ctx, ch, x, y, w, h, dotRadius, glow = true, color = 'rgb(255,0,0)') {
|
||||
if (!dotRadius) return;
|
||||
const rows = DOT_FONT[ch] || DOT_FONT['0'];
|
||||
const cols = rows[0].length;
|
||||
const r = Math.min(dotRadius, Math.max(1, Math.min(w, h) * 0.04));
|
||||
const cellW = w / cols;
|
||||
const cellH = h / rows.length;
|
||||
for (let row = 0; row < rows.length; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
if (rows[row][col] === '1') {
|
||||
const cx = x + col * cellW + cellW / 2;
|
||||
const cy = y + row * cellH + cellH / 2;
|
||||
drawLed(ctx, cx, cy, r, glow, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawLed(ctx, cx, cy, r, glow = true, color = '#ff0000') {
|
||||
if (r <= 0) return;
|
||||
if (glow) {
|
||||
const haloR = r * 2.2;
|
||||
const midR = r * 1.4;
|
||||
const col = normalizeColor(color);
|
||||
ctx.fillStyle = colorWithAlpha(col, 0.08);
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, haloR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = colorWithAlpha(col, 0.35);
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, midR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.fillStyle = normalizeColor(color);
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function pad2(n) {
|
||||
return n < 10 ? `0${n}` : String(n);
|
||||
}
|
||||
|
||||
function isSecondPulseActive(date, pulseMs = 500) {
|
||||
return date.getMilliseconds() < pulseMs;
|
||||
}
|
||||
|
||||
function normalizeColor(val) {
|
||||
if (typeof val !== 'string' || !val.trim()) return '#ff0000';
|
||||
return val.trim();
|
||||
}
|
||||
|
||||
function colorWithAlpha(color, alpha) {
|
||||
const c = normalizeColor(color);
|
||||
if (/^#([0-9a-fA-F]{6})$/.test(c)) {
|
||||
const r = parseInt(c.slice(1, 3), 16);
|
||||
const g = parseInt(c.slice(3, 5), 16);
|
||||
const b = parseInt(c.slice(5, 7), 16);
|
||||
return `rgba(${r},${g},${b},${alpha})`;
|
||||
}
|
||||
if (/^rgb\(/i.test(c)) {
|
||||
const nums = c.match(/(\d+\.?\d*)/g)?.slice(0, 3) || [255, 0, 0];
|
||||
return `rgba(${nums[0]},${nums[1]},${nums[2]},${alpha})`;
|
||||
}
|
||||
return `rgba(255,0,0,${alpha})`;
|
||||
}
|
||||
@@ -0,0 +1,905 @@
|
||||
// views/goniometer_rtw.js — XY-Goniometer (RTW-Look) + 3-Meter-Panel + Korrelation
|
||||
|
||||
import { DEFAULT_TOP_INSET, FRAME_COLOR, LABEL_COLOR, MID_COLOR, OK_COLOR, PANEL_BG, WARN_COLOR } from '../core/theme.js';
|
||||
|
||||
export const id = 'goniometer-rtw';
|
||||
|
||||
const ALIGN_PEAK = Math.pow(10, -15 / 20); // −15 dBFS Peak ≈ −18 dBFS RMS Sine
|
||||
const INNER_MARGIN = 0.05; // 5 % Innenabstand im Scope
|
||||
const MIN_TRACE_POINTS = 128;
|
||||
const MAX_TRACE_POINTS = 4096;
|
||||
const FRAME = { left: 0, top: DEFAULT_TOP_INSET, right: 0, bottom: 0 };
|
||||
const METER_WIDTH = 420;
|
||||
const METER_GAP = 8;
|
||||
const METER_SLOTS = 3;
|
||||
const METER_SLOT_SHRINK = 24;
|
||||
const METER_PAD_TOP = 1;
|
||||
const METER_PAD_BOTTOM = -7;
|
||||
const METER_EXTRA_BOTTOM_PAD = 6;
|
||||
const CORR_ATTACK_S = 1.5;
|
||||
const CORR_RELEASE_S = 2.5;
|
||||
const GONIO_GAIN_MIN_DB = -35;
|
||||
const GONIO_GAIN_MAX_DB = 35;
|
||||
const BASE_TARGET = 1.0;
|
||||
const BASE_HEADROOM_DB = 0;
|
||||
const BASE_ZOOM = 1.0;
|
||||
const BASE_GONIO_SCALE = (BASE_TARGET / ALIGN_PEAK) * Math.pow(10, -BASE_HEADROOM_DB / 20) * BASE_ZOOM;
|
||||
const AGC_TARGET_DB = linearToDb(ALIGN_PEAK);
|
||||
const CORR_SILENCE_THRESHOLD_DEFAULT = -75;
|
||||
const CORR_SILENCE_HOLD_MS = 30000;
|
||||
const CORR_SILENCE_DRIFT_MS = 60000;
|
||||
|
||||
function computePanelSlotWidth(canvasWidth, slotCount = METER_SLOTS, slotGap = 12) {
|
||||
const n = Math.max(1, Math.min(METER_SLOTS, slotCount | 0));
|
||||
const avail = Math.max(200, canvasWidth);
|
||||
const totalGap = (n - 1) * Math.max(2, slotGap);
|
||||
const usable = avail - totalGap;
|
||||
return Math.floor(usable / n);
|
||||
}
|
||||
|
||||
export function init() {
|
||||
return {
|
||||
corrDisplayed: 0,
|
||||
corrMeter: 0,
|
||||
corrLastTs: 0,
|
||||
corrLastValid: 0,
|
||||
corrSilenceSince: 0,
|
||||
gateLevel: 1,
|
||||
gateLastTs: 0,
|
||||
agcEnv: 1e-3,
|
||||
agcGainDb: 0,
|
||||
agcLastTs: 0,
|
||||
traceBuffer: new Float32Array(0),
|
||||
lineTrails: [],
|
||||
staticLayerCanvas: null,
|
||||
staticLayerCtx: null,
|
||||
staticLayerKey: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function resize() {}
|
||||
export function destroy() {}
|
||||
|
||||
export async function render(env, state) {
|
||||
const { ctx: g, rect, config: CONFIG, audio, utils, meters } = env;
|
||||
const frameNow = getNow();
|
||||
|
||||
const wakeTs = Number.isFinite(env?.screensaverWakeTs) ? Number(env.screensaverWakeTs) : 0;
|
||||
if (wakeTs && state?._lastWakeTs !== wakeTs) {
|
||||
// Nach Screensaver-Wake: Korrelation sauber zurücksetzen, damit kein "alter" Wert (z.B. +1) stehen bleibt.
|
||||
state._lastWakeTs = wakeTs;
|
||||
state.corrDisplayed = 0;
|
||||
state.corrMeter = 0;
|
||||
state.corrLastTs = 0;
|
||||
state.corrLastValid = 0;
|
||||
state.corrSilenceSince = frameNow;
|
||||
}
|
||||
|
||||
const slotsRaw = env.slots?.(id) || ['vu', 'ppm-ebu', 'tp'];
|
||||
const slots = slotsRaw.filter((v) => v && v !== 'none');
|
||||
const topInset = Number.isFinite(env?.topInset) ? Number(env.topInset) : FRAME.top;
|
||||
const layout = computeGoniometerLayout(rect, CONFIG, slots.length, topInset);
|
||||
const settings = resolveScopeSettings(CONFIG);
|
||||
const style = CONFIG.XY_STYLE === 'points' ? 'points' : 'lines';
|
||||
const gate = resolveSilenceGate(env, CONFIG, state);
|
||||
|
||||
drawStaticLayer(g, state, rect, layout, CONFIG, slots.length);
|
||||
|
||||
const xyData = extractXYData(audio);
|
||||
const lastSampleTs = Number.isFinite(env?.audio?.lastSampleTs) ? Number(env.audio.lastSampleTs) : 0;
|
||||
const audioAgeMs = lastSampleTs ? (frameNow - lastSampleTs) : Infinity;
|
||||
const audioFresh = Number.isFinite(audioAgeMs) && audioAgeMs >= 0 && audioAgeMs <= 250;
|
||||
const xyReady = xyData.ready && audioFresh;
|
||||
|
||||
const agc = (CONFIG.GONIO_AGC_ENABLED && xyReady)
|
||||
? computeAgcGain(state, xyData, frameNow)
|
||||
: { gainDb: settings.gainDb, gain: dbToLinear(settings.gainDb) };
|
||||
const appliedScale = BASE_GONIO_SCALE * agc.gain * gate.level;
|
||||
|
||||
const trace = xyReady
|
||||
? buildTrace(state, xyData, layout.scope, appliedScale, CONFIG)
|
||||
: null;
|
||||
renderTrace(g, state, trace, layout.scope, style, xyReady && gate.active, settings);
|
||||
|
||||
const silenceGateEnabled = CONFIG?.XY_SILENCE_GATE_ENABLED !== false;
|
||||
const corrThreshold = resolveCorrThreshold(CONFIG);
|
||||
const rmsL = Number.isFinite(env.audio?.rmsDb?.L) ? env.audio.rmsDb.L : -120;
|
||||
const rmsR = Number.isFinite(env.audio?.rmsDb?.R) ? env.audio.rmsDb.R : -120;
|
||||
const activeL = audioFresh && (!silenceGateEnabled || (rmsL >= corrThreshold));
|
||||
const activeR = audioFresh && (!silenceGateEnabled || (rmsR >= corrThreshold));
|
||||
|
||||
let corrTarget = 0;
|
||||
const canCorr = xyReady && utils?.correlation;
|
||||
const zeroOnSilence = !!CONFIG?.CORR_ZERO_ON_SILENCE;
|
||||
const bothSilent = !activeL && !activeR;
|
||||
|
||||
if (activeL && activeR && canCorr) {
|
||||
// Beide Kanäle aktiv → echte Korrelation aus L/R-Samples
|
||||
const corrRaw = utils.correlation(
|
||||
xyData.xyL,
|
||||
xyData.xyR,
|
||||
Number.isFinite(state.corrDisplayed) ? state.corrDisplayed : 0,
|
||||
CONFIG.CORR_SMOOTH,
|
||||
);
|
||||
if (Number.isFinite(corrRaw)) {
|
||||
state.corrDisplayed = corrRaw;
|
||||
state.corrLastValid = corrRaw;
|
||||
state.corrSilenceSince = 0;
|
||||
corrTarget = corrRaw;
|
||||
}
|
||||
} else if (activeL !== activeR) {
|
||||
// Nur ein Kanal aktiv → hart auf 0, keine Hold-/Decay-Logik
|
||||
state.corrSilenceSince = 0;
|
||||
corrTarget = 0;
|
||||
} else {
|
||||
if (zeroOnSilence && bothSilent) {
|
||||
// Beide still → zügig Richtung 0 (ohne Hold/Drift)
|
||||
state.corrLastValid = 0;
|
||||
state.corrSilenceSince = frameNow;
|
||||
corrTarget = 0;
|
||||
} else {
|
||||
// Beide still → Hold + Drift zur Mitte
|
||||
if (!state.corrSilenceSince) state.corrSilenceSince = frameNow;
|
||||
corrTarget = resolveCorrTarget(state, frameNow);
|
||||
}
|
||||
}
|
||||
|
||||
const fastZero = zeroOnSilence && bothSilent;
|
||||
const corrVisual = updateCorrelationDisplay(state, corrTarget, frameNow, fastZero ? true : gate.active);
|
||||
if (fastZero) {
|
||||
// Damit die nächste echte Korrelation nicht noch an einem alten Glättungswert "hängt".
|
||||
state.corrDisplayed = corrVisual;
|
||||
}
|
||||
drawCorrelationBar(
|
||||
g,
|
||||
layout.plot.x + layout.plot.w / 2,
|
||||
layout.plot.y + layout.plot.h - 28,
|
||||
Math.round(Math.min(layout.scope.w * 0.75, layout.plot.w - 50)),
|
||||
18,
|
||||
corrVisual
|
||||
);
|
||||
|
||||
if (slots.length) {
|
||||
const panelSlotWidth = computePanelSlotWidth(
|
||||
layout.meter.w,
|
||||
slots.length,
|
||||
Math.max(2, Math.min(24, Number(CONFIG?.GONI_METER_GAP) || 12))
|
||||
);
|
||||
await renderMeterPanel(g, layout.meter, slots, CONFIG, meters, panelSlotWidth);
|
||||
drawMeterOutline(g, layout.meter);
|
||||
}
|
||||
}
|
||||
|
||||
function drawStaticLayer(g, state, rect, layout, CONFIG, slotCount) {
|
||||
const layer = ensureStaticLayer(state, rect, layout, CONFIG, slotCount);
|
||||
if (!layer) {
|
||||
drawPlotBackdrop(g, layout.plot);
|
||||
drawFramework(g, layout, CONFIG);
|
||||
drawScopeBackground(g, layout.scope);
|
||||
drawCrosshair(g, layout.scope);
|
||||
drawAxisLabels(g, layout.scope);
|
||||
drawMeterPanelDividers(g, layout.meter, slotCount, CONFIG, computePanelSlotWidth(
|
||||
layout.meter.w,
|
||||
slotCount,
|
||||
Math.max(2, Math.min(24, Number(CONFIG?.GONI_METER_GAP) || 12))
|
||||
));
|
||||
return;
|
||||
}
|
||||
g.drawImage(layer, 0, 0, rect.w, rect.h);
|
||||
}
|
||||
|
||||
function ensureStaticLayer(state, rect, layout, CONFIG, slotCount) {
|
||||
const w = Math.max(1, rect.w | 0);
|
||||
const h = Math.max(1, rect.h | 0);
|
||||
const slotGap = Math.max(2, Math.min(24, Number(CONFIG?.GONI_METER_GAP) || 12));
|
||||
const key = [
|
||||
w, h,
|
||||
layout.plot.x, layout.plot.y, layout.plot.w, layout.plot.h,
|
||||
layout.scope.x, layout.scope.y, layout.scope.w, layout.scope.h,
|
||||
layout.meter.x, layout.meter.y, layout.meter.w, layout.meter.h,
|
||||
slotCount,
|
||||
slotGap,
|
||||
!!CONFIG?.PANEL_DIVIDERS_ENABLED,
|
||||
Number.isFinite(CONFIG?.AXIS_GUTTER_LEFT) ? CONFIG.AXIS_GUTTER_LEFT : 14,
|
||||
].join('|');
|
||||
if (state.staticLayerCanvas && state.staticLayerKey === key) {
|
||||
return state.staticLayerCanvas;
|
||||
}
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
if (!ctx) return null;
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
ctx.font = 'bold 14px ui-monospace, monospace';
|
||||
drawPlotBackdrop(ctx, layout.plot);
|
||||
drawFramework(ctx, layout, CONFIG);
|
||||
drawScopeBackground(ctx, layout.scope);
|
||||
drawCrosshair(ctx, layout.scope);
|
||||
drawAxisLabels(ctx, layout.scope);
|
||||
drawMeterPanelDividers(
|
||||
ctx,
|
||||
layout.meter,
|
||||
slotCount,
|
||||
CONFIG,
|
||||
computePanelSlotWidth(layout.meter.w, slotCount, slotGap)
|
||||
);
|
||||
state.staticLayerCanvas = canvas;
|
||||
state.staticLayerCtx = ctx;
|
||||
state.staticLayerKey = key;
|
||||
return canvas;
|
||||
}
|
||||
async function drawRealtimeSlot(g, rect, slotId, CONFIG, meters) {
|
||||
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 innerRect = {
|
||||
x: rect.x,
|
||||
y: rect.y + METER_PAD_TOP + innerOffset,
|
||||
w: rect.w,
|
||||
h: innerHeight,
|
||||
};
|
||||
|
||||
try {
|
||||
await meters.draw(g, innerRect, slotId, CONFIG);
|
||||
} catch (e) {
|
||||
console.warn(`Meter ${slotId} draw error:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Layout & drawing helpers
|
||||
|
||||
function computeGoniometerLayout(rect, CONFIG = {}, slotCount = METER_SLOTS, topInset = FRAME.top) {
|
||||
const plotX = FRAME.left;
|
||||
const plotY = Number.isFinite(topInset) ? topInset : FRAME.top;
|
||||
const innerWidth = Math.max(200, rect.w - plotX - FRAME.right);
|
||||
const minPlotW = 200;
|
||||
|
||||
const slotGap = Math.max(2, Math.min(24, Number(CONFIG?.GONI_METER_GAP) || 12));
|
||||
const panelSlotWidth = 100; // fixed width for better control
|
||||
const n = Math.max(0, Math.min(METER_SLOTS, slotCount | 0));
|
||||
const panelSlotsWidth = n > 0 ? (panelSlotWidth * n + (n - 1) * slotGap) : 0;
|
||||
|
||||
let meterW = n > 0 ? Math.max(panelSlotsWidth, (n === 1 ? 160 : (n === 2 ? 280 : METER_WIDTH))) : 0;
|
||||
let plotW = innerWidth - (n > 0 ? (meterW + METER_GAP) : 0);
|
||||
|
||||
if (plotW < minPlotW) {
|
||||
plotW = minPlotW;
|
||||
meterW = n > 0 ? (innerWidth - plotW - METER_GAP) : 0;
|
||||
}
|
||||
|
||||
if (n > 0) meterW = Math.max(meterW, panelSlotsWidth);
|
||||
if (plotW < 80) plotW = 80;
|
||||
|
||||
const plotH = Math.max(160, rect.h - plotY - FRAME.bottom);
|
||||
const plot = { x: plotX, y: plotY, w: plotW, h: plotH };
|
||||
const scope = computeScopeBox(plot);
|
||||
|
||||
const meter = {
|
||||
x: plot.x + plot.w + (n > 0 ? METER_GAP : 0),
|
||||
y: plot.y,
|
||||
w: meterW,
|
||||
h: plotH,
|
||||
};
|
||||
|
||||
return { plot, scope, meter };
|
||||
}
|
||||
|
||||
function computeScopeBox(plot) {
|
||||
const padding = 24;
|
||||
const minDim = Math.min(plot.w, plot.h);
|
||||
const tightSize = Math.max(120, minDim - padding * 2);
|
||||
const size = Math.min(Math.max(140, tightSize), minDim);
|
||||
const w = size;
|
||||
const h = size;
|
||||
const x = Math.round(plot.x + (plot.w - w) / 2);
|
||||
const y = Math.round(plot.y + (plot.h - h) / 2);
|
||||
const halfLimit = Math.max(24, Math.min(w, h) / 2 - 6);
|
||||
const halfBase = Math.max(60, Math.min(w, h) / 2 - 18);
|
||||
const half = Math.max(24, Math.min(halfBase, halfLimit));
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
const reach = Math.max(24, Math.min(half - 4, half * (1 - INNER_MARGIN)));
|
||||
const diag = half * 0.9;
|
||||
return { x, y, w, h, cx, cy, half, reach, diag };
|
||||
}
|
||||
|
||||
function drawFramework(g, layout, CONFIG) {
|
||||
drawPlotRails(g, layout.plot, CONFIG);
|
||||
if (layout.meter?.w > 0) {
|
||||
g.save();
|
||||
g.fillStyle = PANEL_BG;
|
||||
g.fillRect(layout.meter.x, layout.meter.y, layout.meter.w, layout.meter.h);
|
||||
g.restore();
|
||||
}
|
||||
}
|
||||
|
||||
function drawPlotBackdrop(g, plot) {
|
||||
g.save();
|
||||
g.fillStyle = PANEL_BG;
|
||||
g.fillRect(plot.x, plot.y, plot.w, plot.h);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawPlotRails(g, plot, CONFIG) {
|
||||
const gutterL = Number.isFinite(CONFIG?.AXIS_GUTTER_LEFT)
|
||||
? Math.max(8, Number(CONFIG.AXIS_GUTTER_LEFT))
|
||||
: 14;
|
||||
g.save();
|
||||
g.strokeStyle = FRAME_COLOR;
|
||||
g.lineWidth = 2;
|
||||
g.beginPath();
|
||||
g.moveTo(plot.x - gutterL, plot.y);
|
||||
g.lineTo(plot.x + plot.w, plot.y);
|
||||
g.stroke();
|
||||
g.beginPath();
|
||||
g.moveTo(plot.x - gutterL, plot.y + plot.h);
|
||||
g.lineTo(plot.x + plot.w, plot.y + plot.h);
|
||||
g.stroke();
|
||||
g.beginPath();
|
||||
g.moveTo(plot.x, plot.y);
|
||||
g.lineTo(plot.x, plot.y + plot.h);
|
||||
g.stroke();
|
||||
g.beginPath();
|
||||
g.moveTo(plot.x + plot.w, plot.y);
|
||||
g.lineTo(plot.x + plot.w, plot.y + plot.h);
|
||||
g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawScopeBackground(g, scope) {
|
||||
g.save();
|
||||
g.fillStyle = PANEL_BG;
|
||||
g.fillRect(scope.x, scope.y, scope.w, scope.h);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawCrosshair(g, scope) {
|
||||
g.save();
|
||||
g.globalAlpha = 0.45;
|
||||
g.strokeStyle = 'rgb(0,0,255)';
|
||||
g.lineWidth = 1.5;
|
||||
g.beginPath();
|
||||
g.moveTo(scope.cx - scope.diag, scope.cy - scope.diag);
|
||||
g.lineTo(scope.cx + scope.diag, scope.cy + scope.diag);
|
||||
g.moveTo(scope.cx - scope.diag, scope.cy + scope.diag);
|
||||
g.lineTo(scope.cx + scope.diag, scope.cy - scope.diag);
|
||||
g.moveTo(scope.cx, scope.cy - scope.half);
|
||||
g.lineTo(scope.cx, scope.cy + scope.half);
|
||||
g.moveTo(scope.cx - scope.half, scope.cy);
|
||||
g.lineTo(scope.cx + scope.half, scope.cy);
|
||||
g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawAxisLabels(g, scope) {
|
||||
g.save();
|
||||
g.fillStyle = FRAME_COLOR;
|
||||
g.fillText('R', scope.cx + scope.diag - 10, scope.cy - scope.diag - 4);
|
||||
g.fillText('L', scope.cx - scope.diag - 14, scope.cy - scope.diag - 4);
|
||||
const labelOffset = scope.half + 6;
|
||||
g.fillText('M', scope.cx - 6, scope.cy - labelOffset - 6);
|
||||
g.fillText('S', scope.cx - labelOffset - 16, scope.cy + 6);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawIdleMessage(g, scope) {
|
||||
g.save();
|
||||
g.fillStyle = '#bcd';
|
||||
g.fillText('Warte auf Audio …', scope.x + 12, scope.y + 20);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
async function renderMeterPanel(g, meterLayout, slots, CONFIG, meters, panelSlotWidth) {
|
||||
const slotCount = Array.isArray(slots) ? slots.length : 0;
|
||||
if (!slotCount || meterLayout?.w <= 0) return;
|
||||
const gap = Math.max(2, Math.min(24, Number(CONFIG?.GONI_METER_GAP) || 12));
|
||||
const slotW = panelSlotWidth;
|
||||
const totalSlotW = slotW * slotCount + gap * (slotCount - 1);
|
||||
const startX = meterLayout.x + Math.max(0, Math.floor((meterLayout.w - totalSlotW) / 2));
|
||||
const slotTopPadding = METER_PAD_TOP + METER_SLOT_SHRINK / 2;
|
||||
const slotBottomPadding = METER_PAD_BOTTOM + METER_EXTRA_BOTTOM_PAD + METER_SLOT_SHRINK / 2;
|
||||
const slotHeight = Math.max(40, meterLayout.h - slotTopPadding - slotBottomPadding);
|
||||
const outerPad = 6;
|
||||
|
||||
for (let i = 0; i < slotCount; i++) {
|
||||
const rectM = {
|
||||
x: startX + i * (slotW + gap),
|
||||
y: meterLayout.y + slotTopPadding,
|
||||
w: slotW,
|
||||
h: slotHeight,
|
||||
};
|
||||
|
||||
await drawRealtimeSlot(g, rectM, slots[i], CONFIG, meters);
|
||||
}
|
||||
}
|
||||
|
||||
function drawMeterPanelDividers(g, meterLayout, slotCount, CONFIG, panelSlotWidth) {
|
||||
if (!meterLayout?.w || !slotCount || !CONFIG?.PANEL_DIVIDERS_ENABLED) return;
|
||||
const gap = Math.max(2, Math.min(24, Number(CONFIG?.GONI_METER_GAP) || 12));
|
||||
const slotW = panelSlotWidth;
|
||||
const totalSlotW = slotW * slotCount + gap * (slotCount - 1);
|
||||
const startX = meterLayout.x + Math.max(0, Math.floor((meterLayout.w - totalSlotW) / 2));
|
||||
const slotTopPadding = METER_PAD_TOP + METER_SLOT_SHRINK / 2;
|
||||
const slotBottomPadding = METER_PAD_BOTTOM + METER_EXTRA_BOTTOM_PAD + METER_SLOT_SHRINK / 2;
|
||||
const outerPad = 6;
|
||||
for (let i = 1; i < slotCount; i++) {
|
||||
const dividerX = startX + i * slotW + (i - 1) * gap + gap / 2;
|
||||
g.save();
|
||||
g.strokeStyle = 'rgba(0,231,255,0.4)';
|
||||
g.lineWidth = 1;
|
||||
g.setLineDash([4, 3]);
|
||||
g.beginPath();
|
||||
g.moveTo(dividerX, meterLayout.y + slotTopPadding - outerPad);
|
||||
g.lineTo(dividerX, meterLayout.y + meterLayout.h - slotBottomPadding + outerPad);
|
||||
g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Trace extraction & rendering
|
||||
|
||||
function extractXYData(audio) {
|
||||
const xyL = audio?.xyL;
|
||||
const xyR = audio?.xyR;
|
||||
const isVec = (v) => v && (Array.isArray(v) || ArrayBuffer.isView(v));
|
||||
const ready = !!(audio?.alive && isVec(xyL) && isVec(xyR) && xyL.length && xyR.length);
|
||||
return {
|
||||
ready,
|
||||
xyL,
|
||||
xyR,
|
||||
length: ready ? Math.min(xyL.length, xyR.length) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveScopeSettings(CONFIG) {
|
||||
let gainDb = Number.isFinite(CONFIG?.GONIO_DISPLAY_GAIN_DB) ? CONFIG.GONIO_DISPLAY_GAIN_DB : 0;
|
||||
gainDb = Math.max(GONIO_GAIN_MIN_DB, Math.min(GONIO_GAIN_MAX_DB, Math.round(gainDb / 5) * 5));
|
||||
const lineFadeMs = Number.isFinite(CONFIG?.GONIO_LINE_FADE_MS) ? CONFIG.GONIO_LINE_FADE_MS : 300;
|
||||
const rtwClassic = true;
|
||||
return {
|
||||
gainDb,
|
||||
lineFadeMs,
|
||||
rtwClassic,
|
||||
traceColor: rtwClassic ? 'rgba(255,220,40,0.98)' : 'rgba(255,240,170,0.72)',
|
||||
trailBaseAlpha: rtwClassic ? 0.18 : 0.18,
|
||||
trailBoostAlpha: rtwClassic ? 0.42 : 0.42,
|
||||
lineAlpha: rtwClassic ? 0.82 : 0.72,
|
||||
lineWidth: 1.8,
|
||||
pointBaseAlpha: rtwClassic ? 0.14 : 0.16,
|
||||
pointBoostAlpha: rtwClassic ? 0.34 : 0.36,
|
||||
pointSize: rtwClassic ? 1.4 : 2.2,
|
||||
curveSmoothing: rtwClassic,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSilenceGate(env, CONFIG, state) {
|
||||
const enabled = CONFIG?.XY_SILENCE_GATE_ENABLED !== false;
|
||||
const threshold = Number.isFinite(CONFIG?.XY_SILENCE_THRESHOLD_RMS_DBFS)
|
||||
? CONFIG.XY_SILENCE_THRESHOLD_RMS_DBFS
|
||||
: CORR_SILENCE_THRESHOLD_DEFAULT;
|
||||
const rmsMono = Number.isFinite(env.audio?.rmsDb?.mono) ? env.audio.rmsDb.mono : -120;
|
||||
const targetActive = enabled ? (rmsMono >= threshold) : true;
|
||||
const now = getNow();
|
||||
const dt = state.gateLastTs ? Math.max(0, (now - state.gateLastTs) / 1000) : 0;
|
||||
state.gateLastTs = now;
|
||||
if (!Number.isFinite(state.gateLevel)) state.gateLevel = targetActive ? 1 : 0;
|
||||
const tau = targetActive ? 0.06 : 0.35; // schneller rein, sanft raus
|
||||
const alpha = 1 - Math.exp(-dt / Math.max(0.001, tau));
|
||||
state.gateLevel += alpha * ((targetActive ? 1 : 0) - state.gateLevel);
|
||||
state.gateLevel = Math.max(0, Math.min(1, state.gateLevel));
|
||||
const active = state.gateLevel > 0.02;
|
||||
return { active, level: state.gateLevel, rmsMono, threshold };
|
||||
}
|
||||
|
||||
function buildTrace(state, xyData, scope, scale, CONFIG) {
|
||||
if (xyData.length <= 1) {
|
||||
return { buffer: null, count: 0 };
|
||||
}
|
||||
|
||||
const targetPoints = resolveSampleTarget(CONFIG);
|
||||
const total = xyData.length;
|
||||
if (targetPoints <= 1 || total <= 1) {
|
||||
return { buffer: null, count: 0 };
|
||||
}
|
||||
const sampleCount = Math.max(2, Math.min(targetPoints, MAX_TRACE_POINTS));
|
||||
if (sampleCount <= 1) {
|
||||
return { buffer: null, count: 0 };
|
||||
}
|
||||
|
||||
const coords = ensureTraceBuffer(state, sampleCount * 2);
|
||||
const reach = scope.reach;
|
||||
const cx = scope.cx;
|
||||
const cy = scope.cy;
|
||||
const sourceLast = total - 1;
|
||||
|
||||
let write = 0;
|
||||
for (let i = 0; i < sampleCount; i++) {
|
||||
const pos = sampleCount <= 1 ? 0 : (i * sourceLast) / (sampleCount - 1);
|
||||
const idx0 = Math.floor(pos);
|
||||
const idx1 = Math.min(sourceLast, idx0 + 1);
|
||||
const frac = pos - idx0;
|
||||
const l0 = xyData.xyL[idx0];
|
||||
const l1 = xyData.xyL[idx1];
|
||||
const r0 = xyData.xyR[idx0];
|
||||
const r1 = xyData.xyR[idx1];
|
||||
const l = l0 + (l1 - l0) * frac;
|
||||
const r = r0 + (r1 - r0) * frac;
|
||||
const M = 0.5 * (l + r);
|
||||
const S = 0.5 * (l - r);
|
||||
const xNorm = clamp1(S * scale);
|
||||
const yNorm = clamp1(M * scale);
|
||||
coords[write++] = cx - xNorm * reach;
|
||||
coords[write++] = cy - yNorm * reach;
|
||||
if (write >= coords.length) break;
|
||||
}
|
||||
|
||||
if (write < 4) {
|
||||
return { buffer: null, count: 0 };
|
||||
}
|
||||
|
||||
return { buffer: coords, count: write / 2 };
|
||||
}
|
||||
|
||||
function renderTrace(g, state, trace, scope, style, xyReady, settings) {
|
||||
const trails = state.lineTrails || (state.lineTrails = []);
|
||||
const now = getNow();
|
||||
const hasTrace = !!(trace && trace.buffer && trace.count);
|
||||
const lineFadeMs = settings?.lineFadeMs ?? 0;
|
||||
|
||||
if (style === 'lines') {
|
||||
if (lineFadeMs <= 0) {
|
||||
trails.length = 0;
|
||||
if (hasTrace && trace.count > 1) {
|
||||
drawImmediateLine(g, trace, scope, settings);
|
||||
} else if (!xyReady) {
|
||||
drawIdleMessage(g, scope);
|
||||
}
|
||||
} else {
|
||||
if (hasTrace && trace.count > 1) {
|
||||
addLineTrail(trails, trace, now);
|
||||
}
|
||||
const rendered = drawLineTrails(g, trails, scope, now, lineFadeMs, settings);
|
||||
if (!rendered && !xyReady) drawIdleMessage(g, scope);
|
||||
}
|
||||
} else {
|
||||
if (lineFadeMs <= 0) {
|
||||
trails.length = 0;
|
||||
if (hasTrace) {
|
||||
drawPointTrace(g, trace, scope, settings);
|
||||
} else if (!xyReady) {
|
||||
drawIdleMessage(g, scope);
|
||||
}
|
||||
} else {
|
||||
if (hasTrace && trace.count > 0) {
|
||||
addLineTrail(trails, trace, now);
|
||||
}
|
||||
const rendered = drawPointTrails(g, trails, scope, now, lineFadeMs, settings);
|
||||
if (!rendered && !xyReady) {
|
||||
drawIdleMessage(g, scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addLineTrail(trails, trace, timestamp) {
|
||||
const coords = new Float32Array(trace.count * 2);
|
||||
coords.set(trace.buffer.subarray(0, trace.count * 2));
|
||||
trails.push({ coords, count: trace.count, time: timestamp });
|
||||
}
|
||||
|
||||
function drawLineTrails(g, trails, scope, now, fadeMs, settings) {
|
||||
if (!trails.length) return false;
|
||||
|
||||
const cutoff = now - fadeMs;
|
||||
const keep = [];
|
||||
let drawn = false;
|
||||
|
||||
g.save();
|
||||
g.beginPath();
|
||||
g.rect(scope.x, scope.y, scope.w, scope.h);
|
||||
g.clip();
|
||||
|
||||
for (const trail of trails) {
|
||||
if (trail.time < cutoff) continue;
|
||||
const age = now - trail.time;
|
||||
const fade = Math.max(0, 1 - age / fadeMs);
|
||||
if (fade <= 0) continue;
|
||||
|
||||
drawn = true;
|
||||
keep.push(trail);
|
||||
|
||||
g.save();
|
||||
g.globalAlpha = (settings?.trailBaseAlpha ?? 0.35) + (settings?.trailBoostAlpha ?? 0.65) * fade;
|
||||
g.strokeStyle = settings?.traceColor || 'rgba(255,231,74,0.92)';
|
||||
g.lineWidth = settings?.lineWidth ?? 1.3;
|
||||
beginTracePath(g, trail.coords, trail.count, settings);
|
||||
g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
g.restore();
|
||||
|
||||
trails.length = 0;
|
||||
Array.prototype.push.apply(trails, keep);
|
||||
|
||||
return drawn;
|
||||
}
|
||||
|
||||
function drawImmediateLine(g, trace, scope, settings) {
|
||||
g.save();
|
||||
g.beginPath();
|
||||
g.rect(scope.x, scope.y, scope.w, scope.h);
|
||||
g.clip();
|
||||
|
||||
g.globalAlpha = settings?.lineAlpha ?? 0.92;
|
||||
g.strokeStyle = settings?.traceColor || 'rgba(255,231,74,0.92)';
|
||||
g.lineWidth = settings?.lineWidth ?? 1.3;
|
||||
beginTracePath(g, trace.buffer, trace.count, settings);
|
||||
g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function beginTracePath(g, coords, count, settings) {
|
||||
g.beginPath();
|
||||
if (!coords || count <= 0) return;
|
||||
g.moveTo(coords[0], coords[1]);
|
||||
if (!(settings?.curveSmoothing) || count < 3) {
|
||||
for (let i = 1; i < count; i++) {
|
||||
const idx = i * 2;
|
||||
g.lineTo(coords[idx], coords[idx + 1]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (let i = 1; i < count - 1; i++) {
|
||||
const idx = i * 2;
|
||||
const nextIdx = (i + 1) * 2;
|
||||
const xc = (coords[idx] + coords[nextIdx]) * 0.5;
|
||||
const yc = (coords[idx + 1] + coords[nextIdx + 1]) * 0.5;
|
||||
g.quadraticCurveTo(coords[idx], coords[idx + 1], xc, yc);
|
||||
}
|
||||
const last = (count - 1) * 2;
|
||||
const prev = (count - 2) * 2;
|
||||
g.quadraticCurveTo(coords[prev], coords[prev + 1], coords[last], coords[last + 1]);
|
||||
}
|
||||
|
||||
function drawPointTrails(g, trails, scope, now, fadeMs, settings) {
|
||||
if (!trails.length) return false;
|
||||
|
||||
const cutoff = now - fadeMs;
|
||||
const keep = [];
|
||||
let drawn = false;
|
||||
const pointSize = settings?.pointSize ?? 1.4;
|
||||
const half = pointSize / 2;
|
||||
|
||||
g.save();
|
||||
g.beginPath();
|
||||
g.rect(scope.x, scope.y, scope.w, scope.h);
|
||||
g.clip();
|
||||
|
||||
for (const trail of trails) {
|
||||
if (trail.time < cutoff) continue;
|
||||
const age = now - trail.time;
|
||||
const fade = Math.max(0, 1 - age / fadeMs);
|
||||
if (fade <= 0) continue;
|
||||
|
||||
drawn = true;
|
||||
keep.push(trail);
|
||||
|
||||
for (let i = 0; i < trail.count; i++) {
|
||||
const idx = i * 2;
|
||||
const localAge = trail.count > 1 ? i / (trail.count - 1) : 0;
|
||||
const alpha = ((settings?.pointBaseAlpha ?? 0.25) + (settings?.pointBoostAlpha ?? 0.55) * (1 - localAge)) * fade;
|
||||
g.fillStyle = `rgba(255,231,74,${alpha})`;
|
||||
g.fillRect(trail.coords[idx] - half, trail.coords[idx + 1] - half, pointSize, pointSize);
|
||||
}
|
||||
}
|
||||
|
||||
g.restore();
|
||||
|
||||
trails.length = 0;
|
||||
Array.prototype.push.apply(trails, keep);
|
||||
|
||||
return drawn;
|
||||
}
|
||||
|
||||
function drawPointTrace(g, trace, scope, settings) {
|
||||
g.save();
|
||||
g.beginPath();
|
||||
g.rect(scope.x, scope.y, scope.w, scope.h);
|
||||
g.clip();
|
||||
|
||||
const pointSize = settings?.pointSize ?? 1.4;
|
||||
const half = pointSize / 2;
|
||||
for (let i = 0; i < trace.count; i++) {
|
||||
const idx = i * 2;
|
||||
const age = trace.count > 1 ? i / (trace.count - 1) : 0;
|
||||
const alpha = (settings?.pointBaseAlpha ?? 0.25) + (settings?.pointBoostAlpha ?? 0.55) * (1 - age);
|
||||
g.fillStyle = `rgba(255,231,74,${alpha})`;
|
||||
g.fillRect(trace.buffer[idx] - half, trace.buffer[idx + 1] - half, pointSize, pointSize);
|
||||
}
|
||||
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function resolveSampleTarget(CONFIG) {
|
||||
const target = Number(CONFIG?.XY_POINTS);
|
||||
if (!Number.isFinite(target)) return 512;
|
||||
return clampInt(target, MIN_TRACE_POINTS, MAX_TRACE_POINTS);
|
||||
}
|
||||
|
||||
function ensureTraceBuffer(state, capacity) {
|
||||
if (!(state.traceBuffer instanceof Float32Array) || state.traceBuffer.length < capacity) {
|
||||
state.traceBuffer = new Float32Array(capacity);
|
||||
}
|
||||
return state.traceBuffer;
|
||||
}
|
||||
|
||||
function clamp1(v) {
|
||||
return v < -1 ? -1 : (v > 1 ? 1 : v);
|
||||
}
|
||||
|
||||
function clampInt(value, min, max) {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
const v = Math.round(value);
|
||||
return Math.min(max, Math.max(min, v));
|
||||
}
|
||||
|
||||
function getNow() {
|
||||
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
|
||||
return performance.now();
|
||||
}
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function updateCorrelationDisplay(state, target, nowTs, gateActive = true) {
|
||||
const prev = Number.isFinite(state.corrMeter) ? state.corrMeter : target;
|
||||
const lastTs = Number.isFinite(state.corrLastTs) ? state.corrLastTs : nowTs;
|
||||
const dt = Math.max(0, (nowTs - lastTs) / 1000);
|
||||
if (!dt) {
|
||||
state.corrMeter = clamp1(target);
|
||||
state.corrLastTs = nowTs;
|
||||
return state.corrMeter;
|
||||
}
|
||||
const rising = Math.abs(target) > Math.abs(prev);
|
||||
// Schneller einrasten, wenn Gate aktiv ist; etwas zäher auf Null auslaufen, wenn Stille.
|
||||
const tauAttack = gateActive ? 0.08 : CORR_ATTACK_S;
|
||||
const tauRelease = gateActive ? 0.35 : CORR_RELEASE_S;
|
||||
const tau = rising ? tauAttack : tauRelease;
|
||||
const alpha = 1 - Math.exp(-dt / Math.max(0.001, tau));
|
||||
const next = clamp1(prev + (target - prev) * alpha);
|
||||
state.corrMeter = next;
|
||||
state.corrLastTs = nowTs;
|
||||
return next;
|
||||
}
|
||||
|
||||
function resolveCorrThreshold(CONFIG = {}) {
|
||||
if (Number.isFinite(CONFIG.XY_SILENCE_THRESHOLD_RMS_DBFS)) {
|
||||
return CONFIG.XY_SILENCE_THRESHOLD_RMS_DBFS;
|
||||
}
|
||||
if (Number.isFinite(CONFIG.CORR_SILENCE_THRESHOLD_RMS_DBFS)) {
|
||||
return CONFIG.CORR_SILENCE_THRESHOLD_RMS_DBFS;
|
||||
}
|
||||
return CORR_SILENCE_THRESHOLD_DEFAULT;
|
||||
}
|
||||
|
||||
function resolveCorrTarget(state, nowTs) {
|
||||
const lastValid = Number.isFinite(state.corrLastValid) ? state.corrLastValid : 0;
|
||||
if (!state.corrSilenceSince) {
|
||||
return lastValid;
|
||||
}
|
||||
const holdMs = CORR_SILENCE_HOLD_MS;
|
||||
const driftMs = CORR_SILENCE_DRIFT_MS;
|
||||
const elapsed = Math.max(0, nowTs - state.corrSilenceSince);
|
||||
if (elapsed <= holdMs) {
|
||||
return lastValid;
|
||||
}
|
||||
const t = Math.min(1, (elapsed - holdMs) / Math.max(1, driftMs));
|
||||
return lastValid * (1 - t);
|
||||
}
|
||||
|
||||
function computeAgcGain(state, xyData, nowTs) {
|
||||
const attackTau = 0.001; // 1 ms
|
||||
const releaseDbPerS = 10;
|
||||
const dt = state.agcLastTs ? Math.max(0, (nowTs - state.agcLastTs) / 1000) : 0;
|
||||
state.agcLastTs = nowTs;
|
||||
|
||||
const peak = measureGoniometerPeak(xyData);
|
||||
let env = Number.isFinite(state.agcEnv) && state.agcEnv > 0 ? state.agcEnv : 1e-3;
|
||||
if (peak >= env) {
|
||||
const attackAlpha = attackTau > 0 ? (1 - Math.exp(-Math.max(dt, 0) / attackTau)) : 1;
|
||||
env = env + (peak - env) * Math.min(1, attackAlpha || 1);
|
||||
} else {
|
||||
const releaseFactor = Math.pow(10, -releaseDbPerS * Math.max(dt, 0) / 20);
|
||||
env = Math.max(peak, env * releaseFactor);
|
||||
}
|
||||
env = Math.max(1e-6, env);
|
||||
state.agcEnv = env;
|
||||
|
||||
const envDb = linearToDb(env);
|
||||
let gainDb = AGC_TARGET_DB - envDb;
|
||||
if (gainDb > GONIO_GAIN_MAX_DB) gainDb = GONIO_GAIN_MAX_DB;
|
||||
if (gainDb < GONIO_GAIN_MIN_DB) gainDb = GONIO_GAIN_MIN_DB;
|
||||
state.agcGainDb = gainDb;
|
||||
return {
|
||||
gainDb,
|
||||
gain: dbToLinear(gainDb),
|
||||
};
|
||||
}
|
||||
|
||||
function measureGoniometerPeak(xyData) {
|
||||
if (!xyData || !xyData.ready) return 1e-3;
|
||||
let peak = 1e-6;
|
||||
const len = xyData.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const l = xyData.xyL[i];
|
||||
const r = xyData.xyR[i];
|
||||
const M = 0.5 * (l + r);
|
||||
const S = 0.5 * (l - r);
|
||||
const sample = Math.max(Math.abs(M), Math.abs(S));
|
||||
if (sample > peak) peak = sample;
|
||||
}
|
||||
return peak;
|
||||
}
|
||||
|
||||
function linearToDb(value) {
|
||||
const v = Math.max(1e-9, value);
|
||||
return 20 * Math.log10(v);
|
||||
}
|
||||
|
||||
function dbToLinear(db) {
|
||||
return Math.pow(10, db / 20);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Correlation bar helper
|
||||
|
||||
function drawCorrelationBar(g, centerX, y, w, h, val) {
|
||||
const x = Math.floor(centerX - w / 2);
|
||||
g.save();
|
||||
g.strokeStyle = FRAME_COLOR; g.lineWidth = 2; g.strokeRect(x, y, w, h);
|
||||
const mid = x + w / 2;
|
||||
|
||||
g.fillStyle = LABEL_COLOR;
|
||||
g.textAlign = 'right'; g.fillText('-1', x - 6, y + h / 2 + 5);
|
||||
g.textAlign = 'left'; g.fillText('+1', x + w + 6, y + h / 2 + 5);
|
||||
|
||||
const padding = 3;
|
||||
const cubeSize = Math.max(10, Math.min(h - padding * 2, w - padding * 2));
|
||||
const xVal = mid + (val * 0.5) * w;
|
||||
const clampCenter = (v) => {
|
||||
const minC = x + padding + cubeSize * 0.5;
|
||||
const maxC = x + w - padding - cubeSize * 0.5;
|
||||
return Math.max(minC, Math.min(maxC, v));
|
||||
};
|
||||
const cubeX = clampCenter(xVal) - cubeSize / 2;
|
||||
const cubeY = y + (h - cubeSize) / 2;
|
||||
const cubeColor = resolveCorrColor(val);
|
||||
g.fillStyle = 'rgba(255,255,255,0.08)';
|
||||
g.fillRect(x + 1, y + 1, w - 2, h - 2);
|
||||
g.fillStyle = cubeColor;
|
||||
g.fillRect(cubeX, cubeY, cubeSize, cubeSize);
|
||||
g.strokeStyle = '#0ff';
|
||||
g.lineWidth = 1;
|
||||
g.strokeRect(cubeX, cubeY, cubeSize, cubeSize);
|
||||
g.beginPath(); g.moveTo(mid, y); g.lineTo(mid, y + h); g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function resolveCorrColor(val) {
|
||||
const t = Math.max(-1, Math.min(1, val));
|
||||
if (t <= -0.33) return WARN_COLOR;
|
||||
if (t >= 0.33) return OK_COLOR;
|
||||
return MID_COLOR;
|
||||
}
|
||||
|
||||
function drawMeterOutline(g, meterRect) {
|
||||
g.save();
|
||||
g.strokeStyle = FRAME_COLOR;
|
||||
g.lineWidth = 2;
|
||||
g.strokeRect(meterRect.x, meterRect.y, meterRect.w, meterRect.h);
|
||||
g.restore();
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// views/panel.js — Zentrales Mehrmeter-Panel (wie in deinem Original)
|
||||
// Nutzt bis zu fünf HUD-Slots und zeichnet mittig ein breites Panel mit Rahmen.
|
||||
|
||||
import { DEFAULT_TOP_INSET, FRAME_COLOR, FRAME_COLOR_DIM, LABEL_COLOR } from '../core/theme.js';
|
||||
|
||||
export const id = 'panel';
|
||||
|
||||
export function init() {
|
||||
return { staticLayer: null };
|
||||
}
|
||||
export function resize() {}
|
||||
export function destroy(state) {
|
||||
state.staticLayer = null;
|
||||
}
|
||||
|
||||
export async function render(env, state) {
|
||||
const { ctx: g, rect, config: CONFIG, meters } = env;
|
||||
|
||||
const W = rect.w, H = rect.h;
|
||||
const mWidth = Math.max(200, Math.floor(W * 1));
|
||||
const mPX = 0;
|
||||
const topInset = Number.isFinite(env?.topInset) ? Number(env.topInset) : DEFAULT_TOP_INSET;
|
||||
const mTop = Number.isFinite(env?.topInset) ? Math.max(0, Math.floor(topInset + 10)) : 80;
|
||||
const mBot = H - 10;
|
||||
const labelY = mTop - 25; // aktuell ungenutzt, Meter zeichnen eigene Labels
|
||||
|
||||
// Slots nebeneinander (leere Slots werden ausgeblendet)
|
||||
const inner = 0;
|
||||
const gap = 12;
|
||||
const meterPadTop = 18;
|
||||
const meterPadBottom = 10;
|
||||
const slotsRaw = env.slots?.('panel') || ['vu','ppm-ebu','tp','rms','lufs'];
|
||||
const slots = slotsRaw.filter((v) => v && v !== 'none');
|
||||
const n = slots.length;
|
||||
const setX0 = mPX + inner;
|
||||
if (!n) {
|
||||
g.save();
|
||||
g.fillStyle = LABEL_COLOR;
|
||||
g.textAlign = 'left';
|
||||
g.font = 'bold 16px ui-monospace, monospace';
|
||||
g.fillText('Keine Slots aktiv', setX0 + 10, mTop + 20);
|
||||
g.restore();
|
||||
return;
|
||||
}
|
||||
const setW = Math.floor((mWidth - (n - 1) * gap - inner * 2) / n);
|
||||
|
||||
const staticLayout = {
|
||||
mPX,
|
||||
mTop,
|
||||
mWidth,
|
||||
H,
|
||||
gap,
|
||||
slots: n,
|
||||
setX0,
|
||||
setW,
|
||||
meterPadTop,
|
||||
meterPadBottom,
|
||||
dividersEnabled: !!CONFIG.PANEL_DIVIDERS_ENABLED,
|
||||
hasSlots: n > 0,
|
||||
};
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = setX0 + i * (setW + gap);
|
||||
const rectM = {
|
||||
x,
|
||||
y: mTop + meterPadTop,
|
||||
w: setW,
|
||||
h: Math.max(0, (mBot - mTop) - meterPadTop - meterPadBottom)
|
||||
};
|
||||
if (i > 0 && CONFIG.PANEL_DIVIDERS_ENABLED) {
|
||||
const dividerX = x - gap / 2;
|
||||
g.save();
|
||||
g.strokeStyle = FRAME_COLOR_DIM;
|
||||
g.lineWidth = 1;
|
||||
g.setLineDash([4, 3]);
|
||||
g.beginPath();
|
||||
g.moveTo(dividerX, mTop + meterPadTop - 6);
|
||||
g.lineTo(dividerX, mBot - meterPadBottom + 6);
|
||||
g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
try {
|
||||
await meters.draw(g, rectM, slots[i], CONFIG);
|
||||
} catch (e) {
|
||||
// Slot neutral markieren, damit die anderen nicht leiden
|
||||
g.save();
|
||||
g.strokeStyle = 'rgba(200,80,80,.8)';
|
||||
g.setLineDash([6,4]);
|
||||
g.strokeRect(rectM.x+0.5, rectM.y+0.5, rectM.w-1, rectM.h-1);
|
||||
g.setLineDash([]);
|
||||
g.restore();
|
||||
}
|
||||
}
|
||||
|
||||
drawCachedPanelStaticLayer(g, state, staticLayout);
|
||||
}
|
||||
|
||||
function drawCachedPanelStaticLayer(g, state, layout) {
|
||||
const {
|
||||
mPX,
|
||||
mTop,
|
||||
mWidth,
|
||||
H,
|
||||
gap,
|
||||
slots,
|
||||
setX0,
|
||||
setW,
|
||||
meterPadTop,
|
||||
meterPadBottom,
|
||||
dividersEnabled,
|
||||
} = layout;
|
||||
|
||||
const key = [
|
||||
Math.round(mPX),
|
||||
Math.round(mTop),
|
||||
Math.round(mWidth),
|
||||
Math.round(H),
|
||||
gap,
|
||||
slots,
|
||||
Math.round(setX0),
|
||||
Math.round(setW),
|
||||
meterPadTop,
|
||||
meterPadBottom,
|
||||
dividersEnabled ? 1 : 0,
|
||||
].join('|');
|
||||
|
||||
const strokePad = 2;
|
||||
const width = Math.max(1, Math.round(mWidth + strokePad * 2));
|
||||
const height = Math.max(1, Math.round((H - (mTop - 10)) + strokePad * 2));
|
||||
const needsRebuild = !state.staticLayer
|
||||
|| state.staticLayer.key !== key
|
||||
|| state.staticLayer.width !== width
|
||||
|| state.staticLayer.height !== height;
|
||||
|
||||
if (needsRebuild) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const cg = canvas.getContext('2d');
|
||||
if (cg) {
|
||||
cg.save();
|
||||
cg.translate(strokePad - mPX, strokePad - (mTop - 10));
|
||||
cg.strokeStyle = FRAME_COLOR;
|
||||
cg.lineWidth = 2;
|
||||
cg.strokeRect(mPX, mTop - 10, mWidth, H - (mTop - 10));
|
||||
|
||||
if (dividersEnabled) {
|
||||
const mBot = H - 10;
|
||||
for (let i = 1; i < slots; i++) {
|
||||
const x = setX0 + i * (setW + gap);
|
||||
const dividerX = x - gap / 2;
|
||||
cg.save();
|
||||
cg.strokeStyle = FRAME_COLOR_DIM;
|
||||
cg.lineWidth = 1;
|
||||
cg.setLineDash([4, 3]);
|
||||
cg.beginPath();
|
||||
cg.moveTo(dividerX, mTop + meterPadTop - 6);
|
||||
cg.lineTo(dividerX, mBot - meterPadBottom + 6);
|
||||
cg.stroke();
|
||||
cg.restore();
|
||||
}
|
||||
}
|
||||
cg.restore();
|
||||
}
|
||||
state.staticLayer = { key, canvas, width, height };
|
||||
}
|
||||
|
||||
g.drawImage(state.staticLayer.canvas, mPX - strokePad, (mTop - 10) - strokePad);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,971 @@
|
||||
// IN USE
|
||||
// views/phase_wheel.js — Phase/Frequency Wheel (polar phase display) + meter panel
|
||||
|
||||
import { DEFAULT_TOP_INSET, FRAME_COLOR, PANEL_BG } from '../core/theme.js';
|
||||
|
||||
export const id = 'phase-wheel';
|
||||
|
||||
const FRAME = { left: 0, top: DEFAULT_TOP_INSET, right: 0, bottom: 0 };
|
||||
const WHEEL_METER_GAP = 8;
|
||||
const METER_WIDTH = 420;
|
||||
const METER_GAP = 8;
|
||||
const METER_SLOTS = 3;
|
||||
const METER_PAD_TOP = 30;
|
||||
const METER_PAD_BOTTOM = 20;
|
||||
const METER_EXTRA_BOTTOM_PAD = 6;
|
||||
const TARGET_POINTS = 1024;
|
||||
const COLOR_STOPS = [
|
||||
{ t: 0.0, color: [0, 0, 50] },
|
||||
{ t: 0.3, color: [0, 120, 180] },
|
||||
{ t: 0.6, color: [0, 205, 120] },
|
||||
{ t: 0.8, color: [210, 220, 0] },
|
||||
{ t: 1.0, color: [255, 120, 0] },
|
||||
];
|
||||
const RING_DBFS = [0, -8, -16, -24, -32, -40];
|
||||
const RING_PPM_DIN = [+5, 0, -10, -20, -30, -40, -50];
|
||||
const PHASE_GAIN_MIN_DB = -35;
|
||||
const PHASE_GAIN_MAX_DB = 35;
|
||||
const PHASE_ALIGN_TARGET = Math.pow(10, -15 / 20);
|
||||
const PHASE_AGC_TARGET_DB = linearToDb(PHASE_ALIGN_TARGET);
|
||||
const PHASE_AGC_BASE_GAIN = 1 / PHASE_ALIGN_TARGET;
|
||||
const PHASE_AGC_ATTACK_S = 0.02;
|
||||
const PHASE_AGC_RELEASE_DB_PER_S = 12;
|
||||
const PHASE_LEVEL_THRESHOLD_DB = -60;
|
||||
const PHASE_LEVEL_THRESHOLD = dbToLinear(PHASE_LEVEL_THRESHOLD_DB);
|
||||
const PHASE_IDLE_DECAY = 0.85;
|
||||
const PHASE_PHASE_SMOOTH_ALPHA = 0.08;
|
||||
const PHASE_RADIUS_SMOOTH_ALPHA = 0.18;
|
||||
const PHASE_BANDPASS_LOW_HZ = 300;
|
||||
const PHASE_BANDPASS_HIGH_HZ = 5000;
|
||||
const PHASE_TRAIL_FADE_MS = 2000;
|
||||
const PHASE_TRAIL_MIN_STEP_MS = 1000 / 45;
|
||||
const PHASE_TRAIL_MIN_DIST_PX = 1.5;
|
||||
const PHASE_SECTORS = [
|
||||
{ startDeg: -30, endDeg: 30, color: 'rgba(72,210,150,0.3)' },
|
||||
{ startDeg: 30, endDeg: 60, color: 'rgba(255,214,120,0.25)' },
|
||||
{ startDeg: -60, endDeg: -30, color: 'rgba(255,214,120,0.25)' },
|
||||
{ startDeg: 60, endDeg: 180, color: 'rgba(255,120,120,0.25)' },
|
||||
{ startDeg: -180, endDeg: -60, color: 'rgba(255,120,120,0.25)' },
|
||||
];
|
||||
|
||||
// Numerisch stabilere Hilbert-Kernel-Erstellung
|
||||
const HILBERT_KERNEL = buildHilbertKernel(33);
|
||||
const HILBERT_HALF = (HILBERT_KERNEL.length - 1) / 2;
|
||||
|
||||
// Lookup-Tables für häufig verwendete Werte
|
||||
const ANGLE_COS = new Float32Array(360);
|
||||
const ANGLE_SIN = new Float32Array(360);
|
||||
for (let i = 0; i < 360; i++) {
|
||||
const rad = (i * Math.PI) / 180;
|
||||
ANGLE_COS[i] = Math.cos(rad);
|
||||
ANGLE_SIN[i] = Math.sin(rad);
|
||||
}
|
||||
|
||||
export function init() {
|
||||
return {
|
||||
traceBuffer: new Float32Array(0),
|
||||
ampBuffer: new Float32Array(0),
|
||||
filteredL: new Float32Array(0),
|
||||
filteredR: new Float32Array(0),
|
||||
currentPhase: null,
|
||||
currentRadius: 0,
|
||||
smoothPhase: null,
|
||||
smoothRadius: 0,
|
||||
phaseAgcEnv: 1e-3,
|
||||
phaseAgcGainDb: 0,
|
||||
phaseAgcLastTs: 0,
|
||||
bandpass: createBandpassState(),
|
||||
bufferGrowthCount: 0,
|
||||
maxBufferSize: 0,
|
||||
phaseTrail: [],
|
||||
staticLayerCanvas: null,
|
||||
staticLayerKey: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function destroy() {
|
||||
// Cleanup - Setze Referenzen für Garbage Collection frei
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resize() {}
|
||||
|
||||
export async function render(env, state) {
|
||||
const { ctx: g, rect, config: CONFIG, audio, utils, meters } = env;
|
||||
const slotsRaw = env.slots?.(id) || ['vu', 'ppm-ebu', 'tp'];
|
||||
const slots = slotsRaw.filter((v) => v && v !== 'none');
|
||||
const topInset = Number.isFinite(env?.topInset) ? Number(env.topInset) : FRAME.top;
|
||||
const layout = computeLayout(rect, slots.length, topInset);
|
||||
const now = getNow();
|
||||
|
||||
drawStaticLayer(g, state, rect, layout, CONFIG, slots.length);
|
||||
const xyData = extractXYData(audio);
|
||||
const gainCtrl = resolvePhaseGain(state, xyData, CONFIG);
|
||||
if (xyData.ready) {
|
||||
const trace = buildWheelTrace(state, xyData, layout.wheel, gainCtrl.gain, CONFIG, audio);
|
||||
renderWheel(g, trace, layout.wheel, state, CONFIG, now);
|
||||
} else {
|
||||
decayPhasePointer(state);
|
||||
trimPhaseTrail(state, CONFIG, now);
|
||||
drawPhaseTrail(g, layout.wheel, state, CONFIG, now);
|
||||
drawPhasePointer(g, layout.wheel, state);
|
||||
drawIdleMessage(g, layout.wheel);
|
||||
}
|
||||
|
||||
if (slots.length) {
|
||||
const panelSlotWidth = computePanelSlotWidth(layout.meter.w, slots.length);
|
||||
await renderMeterPanel(g, layout.meter, slots, CONFIG, meters, panelSlotWidth);
|
||||
drawMeterOutline(g, layout.meter);
|
||||
}
|
||||
drawPhaseAngleLabel(g, layout.wheelPanel, layout.wheel, state, now);
|
||||
}
|
||||
|
||||
function computeLayout(rect, slotCount = METER_SLOTS, topInset = FRAME.top) {
|
||||
const plotX = FRAME.left;
|
||||
const plotY = Number.isFinite(topInset) ? topInset : FRAME.top;
|
||||
const innerWidth = Math.max(200, rect.w - plotX - FRAME.right);
|
||||
const minPlotW = 200;
|
||||
const n = Math.max(0, Math.min(METER_SLOTS, slotCount | 0));
|
||||
let meterW = n > 0 ? resolveMeterWidth(rect.w, n) : 0;
|
||||
let plotW = innerWidth - (n > 0 ? (meterW + METER_GAP) : 0);
|
||||
if (plotW < minPlotW) {
|
||||
plotW = minPlotW;
|
||||
meterW = n > 0 ? Math.max(0, innerWidth - plotW - METER_GAP) : 0;
|
||||
}
|
||||
const plotH = Math.max(160, rect.h - plotY - FRAME.bottom);
|
||||
const wheelSize = Math.min(plotW, plotH);
|
||||
const wheelPanel = {
|
||||
x: plotX,
|
||||
y: plotY,
|
||||
w: plotW,
|
||||
h: plotH,
|
||||
};
|
||||
const meterX = plotX + plotW + (n > 0 ? METER_GAP : 0);
|
||||
const wheel = {
|
||||
x: wheelPanel.x + (wheelPanel.w - wheelSize) / 2,
|
||||
y: plotY + (plotH - wheelSize) / 2,
|
||||
w: wheelSize,
|
||||
h: wheelSize,
|
||||
cx: wheelPanel.x + wheelPanel.w / 2,
|
||||
cy: plotY + plotH / 2,
|
||||
radius: wheelSize / 2 - 8,
|
||||
};
|
||||
const meter = {
|
||||
x: meterX,
|
||||
y: plotY,
|
||||
w: meterW,
|
||||
h: plotH,
|
||||
};
|
||||
return { wheelPanel, wheel, meter };
|
||||
}
|
||||
|
||||
function drawStaticLayer(g, state, rect, layout, CONFIG, slotCount) {
|
||||
const layer = ensureStaticLayer(state, rect, layout, CONFIG, slotCount);
|
||||
if (!layer) {
|
||||
drawWheelBackground(g, layout.wheelPanel, layout.wheel, CONFIG);
|
||||
if (slotCount) drawMeterBackground(g, layout.meter);
|
||||
return;
|
||||
}
|
||||
g.drawImage(layer, 0, 0, rect.w, rect.h);
|
||||
}
|
||||
|
||||
function ensureStaticLayer(state, rect, layout, CONFIG, slotCount) {
|
||||
const w = Math.max(1, rect.w | 0);
|
||||
const h = Math.max(1, rect.h | 0);
|
||||
const key = [
|
||||
w, h,
|
||||
layout.wheelPanel.x, layout.wheelPanel.y, layout.wheelPanel.w, layout.wheelPanel.h,
|
||||
layout.wheel.x, layout.wheel.y, layout.wheel.w, layout.wheel.h, layout.wheel.radius,
|
||||
layout.meter.x, layout.meter.y, layout.meter.w, layout.meter.h,
|
||||
slotCount,
|
||||
!!CONFIG?.PANEL_DIVIDERS_ENABLED,
|
||||
CONFIG?.PHASE_AMPLITUDE_MODE || 'bandpass',
|
||||
].join('|');
|
||||
if (state.staticLayerCanvas && state.staticLayerKey === key) {
|
||||
return state.staticLayerCanvas;
|
||||
}
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
if (!ctx) return null;
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
ctx.font = 'bold 14px ui-monospace, monospace';
|
||||
drawWheelBackground(ctx, layout.wheelPanel, layout.wheel, CONFIG);
|
||||
if (slotCount) {
|
||||
drawMeterBackground(ctx, layout.meter);
|
||||
drawMeterPanelDividers(ctx, layout.meter, slotCount, CONFIG, computePanelSlotWidth(layout.meter.w, slotCount));
|
||||
}
|
||||
state.staticLayerCanvas = canvas;
|
||||
state.staticLayerKey = key;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function resolveMeterWidth(totalWidth, slotCount) {
|
||||
if (slotCount >= 3) return Math.max(METER_WIDTH, Math.round(totalWidth * 0.35));
|
||||
return slotCount === 2 ? 280 : 160;
|
||||
}
|
||||
|
||||
function drawWheelBackground(g, panel, wheel, CONFIG) {
|
||||
const ringDbValues = getRingDbValues(CONFIG);
|
||||
g.save();
|
||||
g.fillStyle = PANEL_BG;
|
||||
g.fillRect(panel.x, panel.y, panel.w, panel.h);
|
||||
g.strokeStyle = FRAME_COLOR;
|
||||
g.lineWidth = 2;
|
||||
g.strokeRect(panel.x, panel.y, panel.w, panel.h);
|
||||
g.translate(wheel.cx, wheel.cy);
|
||||
drawPhaseSectors(g, wheel);
|
||||
const rings = ringDbValues.length;
|
||||
for (let i = 0; i < rings; i++) {
|
||||
const frac = ringFraction(i, rings);
|
||||
const r = wheel.radius * frac;
|
||||
g.strokeStyle = 'rgba(0,231,255,0.2)';
|
||||
g.lineWidth = i === 0 ? 1.5 : 1;
|
||||
g.setLineDash(i === 0 ? [] : [4, 4]);
|
||||
g.beginPath();
|
||||
g.arc(0, 0, r, 0, Math.PI * 2);
|
||||
g.stroke();
|
||||
}
|
||||
g.setLineDash([]);
|
||||
g.strokeStyle = 'rgba(0,231,255,0.35)';
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const angle = (Math.PI / 2) * i;
|
||||
g.beginPath();
|
||||
g.moveTo(0, 0);
|
||||
g.lineTo(Math.cos(angle) * wheel.radius, Math.sin(angle) * wheel.radius);
|
||||
g.stroke();
|
||||
}
|
||||
drawAmplitudeScale(g, wheel, ringDbValues, CONFIG);
|
||||
g.fillStyle = '#bcd';
|
||||
g.font = '12px ui-monospace, monospace';
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'middle';
|
||||
const labels = [
|
||||
{ text: '0°', angle: -Math.PI / 2 },
|
||||
{ text: '-90°', angle: Math.PI },
|
||||
{ text: '+90°', angle: 0 },
|
||||
{ text: '180°', angle: Math.PI / 2 },
|
||||
];
|
||||
for (const lbl of labels) {
|
||||
const inset = (lbl.text === '+90°' || lbl.text === '-90°') ? 34 : 24;
|
||||
const x = Math.cos(lbl.angle) * (wheel.radius - inset);
|
||||
const y = Math.sin(lbl.angle) * (wheel.radius - inset);
|
||||
g.fillText(lbl.text, x, y);
|
||||
}
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawPhaseSectors(g, wheel) {
|
||||
const radius = Math.max(10, wheel.radius - 6);
|
||||
const width = 12;
|
||||
for (const sector of PHASE_SECTORS) {
|
||||
g.save();
|
||||
g.strokeStyle = sector.color;
|
||||
g.lineWidth = width;
|
||||
g.beginPath();
|
||||
g.arc(
|
||||
0,
|
||||
0,
|
||||
radius,
|
||||
degToCanvas(sector.startDeg),
|
||||
degToCanvas(sector.endDeg),
|
||||
false
|
||||
);
|
||||
g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
}
|
||||
|
||||
function drawIdleMessage(g, wheel) {
|
||||
g.save();
|
||||
g.fillStyle = '#bcd';
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'middle';
|
||||
g.font = 'bold 16px ui-monospace, monospace';
|
||||
g.fillText('Waiting for audio…', wheel.cx, wheel.cy);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawAmplitudeScale(g, wheel, ringDbValues, CONFIG) {
|
||||
const axisAngle = (3 * Math.PI) / 4;
|
||||
const mode = getPhaseAmplitudeMode(CONFIG);
|
||||
g.save();
|
||||
g.fillStyle = '#9fe';
|
||||
g.font = '11px ui-monospace, monospace';
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'middle';
|
||||
const rings = Array.isArray(ringDbValues) ? ringDbValues.length : 0;
|
||||
for (let i = 0; i < rings; i++) {
|
||||
const db = ringDbValues[i];
|
||||
const r = wheel.radius * ringFraction(i, rings);
|
||||
const x = Math.cos(axisAngle) * (r + 14);
|
||||
const y = Math.sin(axisAngle) * (r + 14);
|
||||
g.fillText(formatAmplitudeLabel(db, mode), x, y);
|
||||
}
|
||||
const titleX = Math.cos(axisAngle) * (wheel.radius + 34);
|
||||
const titleY = Math.sin(axisAngle) * (wheel.radius + 34);
|
||||
g.fillStyle = 'rgba(170,220,220,0.9)';
|
||||
g.font = 'bold 11px ui-monospace, monospace';
|
||||
g.fillText(mode === 'ppm-din' ? 'PPM DIN' : 'dBFS', titleX, titleY);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function degToCanvas(deg) {
|
||||
return (deg * Math.PI) / 180 - Math.PI / 2;
|
||||
}
|
||||
|
||||
function radToDeg(rad) {
|
||||
return (rad * 180) / Math.PI;
|
||||
}
|
||||
|
||||
function extractXYData(audio) {
|
||||
const xyL = audio?.xyL;
|
||||
const xyR = audio?.xyR;
|
||||
const isVec = (v) => v && (Array.isArray(v) || ArrayBuffer.isView(v));
|
||||
const ready = !!(audio?.alive && isVec(xyL) && isVec(xyR) && xyL.length && xyR.length);
|
||||
return {
|
||||
ready,
|
||||
xyL,
|
||||
xyR,
|
||||
length: ready ? Math.min(xyL.length, xyR.length) : 0,
|
||||
sampleRate: audio?.sampleRate || 48000,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWheelTrace(state, xyData, wheel, gain = 1, CONFIG, audio) {
|
||||
if (!xyData.ready || !xyData.length) {
|
||||
decayPhasePointer(state);
|
||||
return null;
|
||||
}
|
||||
const filtered = preparePhaseFilteredBuffers(state, xyData);
|
||||
const amplitudeMode = getPhaseAmplitudeMode(CONFIG);
|
||||
const ringDbValues = getRingDbValues(CONFIG);
|
||||
const ppmRadiusNorm = amplitudeMode === 'ppm-din'
|
||||
? computePpmDinRadiusNorm(audio, CONFIG, ringDbValues)
|
||||
: 0;
|
||||
const targetPoints = Math.min(TARGET_POINTS, xyData.length);
|
||||
const step = Math.max(1, Math.floor(xyData.length / targetPoints));
|
||||
const radius = wheel.radius;
|
||||
let ampIdx = 0;
|
||||
let sumSin = 0;
|
||||
let sumCos = 0;
|
||||
let sumRadius = 0;
|
||||
let levelAcc = 0;
|
||||
const gainLinear = Number.isFinite(gain) ? gain : 1;
|
||||
|
||||
for (let i = 0; i < xyData.length; i += step) {
|
||||
const lRe = clamp1(filtered.L[i]);
|
||||
const rRe = clamp1(filtered.R[i]);
|
||||
const lIm = hilbertAt(filtered.L, i);
|
||||
const rIm = hilbertAt(filtered.R, i);
|
||||
const phaseL = Math.atan2(lIm, lRe);
|
||||
const phaseR = Math.atan2(rIm, rRe);
|
||||
let phaseDiff = phaseL - phaseR;
|
||||
if (!Number.isFinite(phaseDiff)) continue;
|
||||
phaseDiff = wrapAngle(phaseDiff);
|
||||
const angle = phaseDiff - Math.PI / 2;
|
||||
const magL = Math.min(1, Math.hypot(lRe, lIm));
|
||||
const magR = Math.min(1, Math.hypot(rRe, rIm));
|
||||
const amp = Math.min(1, 0.5 * (magL + magR));
|
||||
const ampScaled = Math.min(1, amp * gainLinear);
|
||||
const radiusNorm = amplitudeMode === 'ppm-din'
|
||||
? ppmRadiusNorm
|
||||
: linearToRadiusNorm(ampScaled, ringDbValues);
|
||||
ampIdx++;
|
||||
sumSin += Math.sin(angle);
|
||||
sumCos += Math.cos(angle);
|
||||
sumRadius += radiusNorm;
|
||||
levelAcc += amp * amp;
|
||||
}
|
||||
|
||||
if (ampIdx > 0) {
|
||||
const invCount = 1 / ampIdx;
|
||||
const avgAngle = Math.atan2(sumSin * invCount, sumCos * invCount);
|
||||
const avgRadius = amplitudeMode === 'ppm-din' ? ppmRadiusNorm : (sumRadius * invCount);
|
||||
const blockLevel = Math.sqrt(levelAcc * invCount);
|
||||
if (blockLevel >= PHASE_LEVEL_THRESHOLD) {
|
||||
const prevPhase = Number.isFinite(state.smoothPhase) ? state.smoothPhase : avgAngle;
|
||||
const prevRadius = Number.isFinite(state.smoothRadius) ? state.smoothRadius : avgRadius;
|
||||
state.currentPhase = avgAngle;
|
||||
state.currentRadius = avgRadius;
|
||||
state.smoothPhase = smoothAngle(prevPhase, avgAngle, PHASE_PHASE_SMOOTH_ALPHA);
|
||||
state.smoothRadius = lerp(prevRadius, avgRadius, PHASE_RADIUS_SMOOTH_ALPHA);
|
||||
} else {
|
||||
decayPhasePointer(state);
|
||||
}
|
||||
} else {
|
||||
decayPhasePointer(state);
|
||||
}
|
||||
return { count: ampIdx, radius };
|
||||
}
|
||||
|
||||
function renderWheel(g, trace, wheel, state, CONFIG, timestamp) {
|
||||
g.save();
|
||||
g.lineWidth = 1.5;
|
||||
g.globalAlpha = 0.95;
|
||||
if (trace && trace.count > 1) {
|
||||
// reserved for future trace rendering
|
||||
}
|
||||
g.restore();
|
||||
updatePhaseTrail(state, wheel, CONFIG, timestamp);
|
||||
drawPhaseTrail(g, wheel, state, CONFIG, timestamp);
|
||||
drawPhasePointer(g, wheel, state);
|
||||
}
|
||||
|
||||
function drawPhasePointer(g, wheel, state) {
|
||||
if (!Number.isFinite(state?.smoothPhase) || !Number.isFinite(state?.smoothRadius)) return;
|
||||
const phase = state.smoothPhase;
|
||||
const radiusNorm = Math.max(0, state.smoothRadius);
|
||||
if (radiusNorm <= 0.002) return;
|
||||
const length = radiusNorm * wheel.radius;
|
||||
g.save();
|
||||
g.translate(wheel.cx, wheel.cy);
|
||||
g.strokeStyle = '#ffe36e';
|
||||
g.fillStyle = '#ffe36e';
|
||||
g.lineWidth = 3;
|
||||
g.beginPath();
|
||||
g.moveTo(0, 0);
|
||||
g.lineTo(Math.cos(phase) * length, Math.sin(phase) * length);
|
||||
g.stroke();
|
||||
g.beginPath();
|
||||
g.arc(0, 0, 4, 0, Math.PI * 2);
|
||||
g.fill();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawPhaseAngleLabel(g, wheelPanel, wheel, state, nowMs) {
|
||||
const hasPhase = Number.isFinite(state?.smoothPhase) && Number.isFinite(state?.smoothRadius) && state.smoothRadius > 0.002;
|
||||
const now = Number.isFinite(nowMs) ? nowMs : getNow();
|
||||
let degText = '--°';
|
||||
if (hasPhase) {
|
||||
const prevTs = Number.isFinite(state?._phaseLabelLastTs) ? state._phaseLabelLastTs : now;
|
||||
const dt = Math.max(0, Math.min(0.25, (now - prevTs) / 1000));
|
||||
// Die Zahl soll dem Zeiger folgen. Der Zeiger ist bereits geglättet (smoothPhase).
|
||||
// Hier nur eine leichte, adaptive Glättung + Snap bei großen Sprüngen, damit Zahl und Zeiger
|
||||
// bei schnellen Änderungen nicht auseinanderlaufen.
|
||||
const targetPhase = state.smoothPhase;
|
||||
const prevPhase = Number.isFinite(state?._phaseLabelPhase) ? state._phaseLabelPhase : targetPhase;
|
||||
const diff = wrapAngle(targetPhase - prevPhase);
|
||||
const absDiff = Math.abs(diff);
|
||||
const snap = absDiff > (Math.PI / 3); // >60°: sofort folgen
|
||||
const tau = 0.18; // schneller als vorher, damit der Wert "mitkommt"
|
||||
const alpha = (snap || dt <= 0) ? 1 : (1 - Math.exp(-dt / tau));
|
||||
const labelPhase = smoothAngle(prevPhase, targetPhase, alpha);
|
||||
state._phaseLabelPhase = labelPhase;
|
||||
state._phaseLabelLastTs = now;
|
||||
|
||||
let deg = radToDeg(labelPhase) + 90;
|
||||
while (deg <= -180) deg += 360;
|
||||
while (deg > 180) deg -= 360;
|
||||
const minIntervalMs = 1000 / 30; // bis zu 30 updates/s (sonst wirkt es "hinterher")
|
||||
const prevText = typeof state?._phaseLabelText === 'string' ? state._phaseLabelText : null;
|
||||
const prevDisplayTs = Number.isFinite(state?._phaseLabelDisplayTs) ? state._phaseLabelDisplayTs : 0;
|
||||
const nextText = `${deg > 0 ? '+' : ''}${deg.toFixed(0)}°`;
|
||||
if (!prevText || snap || (now - prevDisplayTs) >= minIntervalMs) {
|
||||
degText = nextText;
|
||||
state._phaseLabelText = degText;
|
||||
state._phaseLabelDisplayTs = now;
|
||||
} else {
|
||||
degText = prevText;
|
||||
}
|
||||
} else {
|
||||
if (state) {
|
||||
state._phaseLabelPhase = null;
|
||||
state._phaseLabelLastTs = now;
|
||||
state._phaseLabelText = '--°';
|
||||
state._phaseLabelDisplayTs = now;
|
||||
}
|
||||
}
|
||||
const boxPadding = 7;
|
||||
const boxW = 96;
|
||||
const boxH = 42;
|
||||
const panelX = Number.isFinite(wheelPanel?.x) ? wheelPanel.x : 0;
|
||||
const panelY = Number.isFinite(wheelPanel?.y) ? wheelPanel.y : wheel.y;
|
||||
const boxX = panelX + 12;
|
||||
const boxY = panelY + 12;
|
||||
g.save();
|
||||
g.fillStyle = 'rgba(0, 0, 0, 0.55)';
|
||||
g.strokeStyle = 'rgba(0, 231, 255, 0.65)';
|
||||
g.lineWidth = 1;
|
||||
g.fillRect(boxX, boxY, boxW, boxH);
|
||||
g.strokeRect(boxX, boxY, boxW, boxH);
|
||||
g.fillStyle = '#ffe36e';
|
||||
g.font = 'bold 15px ui-monospace, monospace';
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'middle';
|
||||
g.fillText('Phase', boxX + boxW / 2, boxY + boxPadding + 4);
|
||||
g.font = 'bold 19px ui-monospace, monospace';
|
||||
g.fillText(degText, boxX + boxW / 2, boxY + boxH - boxPadding - 2);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function computePanelSlotWidth(canvasWidth, slotCount = METER_SLOTS) {
|
||||
const n = Math.max(1, Math.min(METER_SLOTS, slotCount | 0));
|
||||
const avail = Math.max(200, canvasWidth);
|
||||
const totalGap = (n - 1) * 12;
|
||||
const usable = avail - totalGap;
|
||||
return Math.floor(usable / n);
|
||||
}
|
||||
|
||||
async function renderMeterPanel(g, meterLayout, slots, CONFIG, meters, panelSlotWidth) {
|
||||
const slotCount = Array.isArray(slots) ? slots.length : 0;
|
||||
if (!slotCount || meterLayout?.w <= 0) return;
|
||||
const gap = 12;
|
||||
const slotW = panelSlotWidth;
|
||||
const totalSlotW = slotW * slotCount + gap * (slotCount - 1);
|
||||
const startX = meterLayout.x + Math.max(0, Math.floor((meterLayout.w - totalSlotW) / 2));
|
||||
|
||||
for (let i = 0; i < slotCount; i++) {
|
||||
const rectM = {
|
||||
x: startX + i * (slotW + gap),
|
||||
y: meterLayout.y + METER_PAD_TOP,
|
||||
w: slotW,
|
||||
h: Math.max(40, meterLayout.h - METER_PAD_TOP - METER_PAD_BOTTOM - METER_EXTRA_BOTTOM_PAD),
|
||||
};
|
||||
|
||||
try {
|
||||
await meters.draw(g, rectM, slots[i], CONFIG);
|
||||
} catch (e) {
|
||||
console.warn(`Meter ${slots[i]} draw error:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawMeterPanelDividers(g, meterLayout, slotCount, CONFIG, panelSlotWidth) {
|
||||
if (!meterLayout?.w || !slotCount || !CONFIG?.PANEL_DIVIDERS_ENABLED) return;
|
||||
const gap = 12;
|
||||
const slotW = panelSlotWidth;
|
||||
const totalSlotW = slotW * slotCount + gap * (slotCount - 1);
|
||||
const startX = meterLayout.x + Math.max(0, Math.floor((meterLayout.w - totalSlotW) / 2));
|
||||
for (let i = 1; i < slotCount; i++) {
|
||||
const dividerX = startX + i * slotW + (i - 1) * gap + gap / 2;
|
||||
g.save();
|
||||
g.strokeStyle = 'rgba(0,231,255,0.4)';
|
||||
g.lineWidth = 1;
|
||||
g.setLineDash([4, 3]);
|
||||
g.beginPath();
|
||||
g.moveTo(dividerX, meterLayout.y + 6);
|
||||
g.lineTo(dividerX, meterLayout.y + meterLayout.h - 6);
|
||||
g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
}
|
||||
|
||||
function drawMeterOutline(g, meterRect) {
|
||||
g.save();
|
||||
g.strokeStyle = FRAME_COLOR;
|
||||
g.lineWidth = 2;
|
||||
g.strokeRect(meterRect.x, meterRect.y, meterRect.w, meterRect.h);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawMeterBackground(g, meterRect) {
|
||||
g.save();
|
||||
g.fillStyle = PANEL_BG;
|
||||
g.fillRect(meterRect.x, meterRect.y, meterRect.w, meterRect.h);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function updatePhaseTrail(state, wheel, CONFIG, now) {
|
||||
if (!state.phaseTrail) state.phaseTrail = [];
|
||||
if (!CONFIG?.PHASE_TRAIL_ENABLED) {
|
||||
state.phaseTrail.length = 0;
|
||||
return;
|
||||
}
|
||||
const radiusNorm = Math.max(0, Number(state?.smoothRadius) || 0);
|
||||
if (radiusNorm > 0.002 && Number.isFinite(state?.smoothPhase)) {
|
||||
const length = radiusNorm * wheel.radius;
|
||||
const x = wheel.cx + Math.cos(state.smoothPhase) * length;
|
||||
const y = wheel.cy + Math.sin(state.smoothPhase) * length;
|
||||
const color = trailColorForAngle(state.smoothPhase);
|
||||
const last = state.phaseTrail.length ? state.phaseTrail[state.phaseTrail.length - 1] : null;
|
||||
if (!last) {
|
||||
state.phaseTrail.push({ x, y, t: now, color });
|
||||
} else {
|
||||
const dt = now - last.t;
|
||||
const dx = x - last.x;
|
||||
const dy = y - last.y;
|
||||
const dist2 = dx * dx + dy * dy;
|
||||
if (dt >= PHASE_TRAIL_MIN_STEP_MS || dist2 >= PHASE_TRAIL_MIN_DIST_PX * PHASE_TRAIL_MIN_DIST_PX || last.color !== color) {
|
||||
state.phaseTrail.push({ x, y, t: now, color });
|
||||
} else {
|
||||
last.x = x;
|
||||
last.y = y;
|
||||
last.t = now;
|
||||
last.color = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
trimPhaseTrail(state, CONFIG, now);
|
||||
}
|
||||
|
||||
function trimPhaseTrail(state, CONFIG, now) {
|
||||
if (!state.phaseTrail) state.phaseTrail = [];
|
||||
if (!CONFIG?.PHASE_TRAIL_ENABLED) {
|
||||
state.phaseTrail.length = 0;
|
||||
return;
|
||||
}
|
||||
const cutoff = now - PHASE_TRAIL_FADE_MS;
|
||||
let write = 0;
|
||||
for (let i = 0; i < state.phaseTrail.length; i++) {
|
||||
const pt = state.phaseTrail[i];
|
||||
if (pt.t >= cutoff) {
|
||||
state.phaseTrail[write++] = pt;
|
||||
}
|
||||
}
|
||||
state.phaseTrail.length = write;
|
||||
}
|
||||
|
||||
function drawPhaseTrail(g, wheel, state, CONFIG, now) {
|
||||
if (!CONFIG?.PHASE_TRAIL_ENABLED) return;
|
||||
const pts = state.phaseTrail;
|
||||
if (!pts || pts.length < 2) return;
|
||||
g.save();
|
||||
g.lineCap = 'round';
|
||||
g.lineWidth = 2.5;
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const prev = pts[i - 1];
|
||||
const curr = pts[i];
|
||||
const age = Math.max(0, now - curr.t);
|
||||
const alpha = Math.max(0, 1 - age / PHASE_TRAIL_FADE_MS);
|
||||
if (alpha <= 0) continue;
|
||||
g.globalAlpha = alpha;
|
||||
g.strokeStyle = curr.color;
|
||||
g.beginPath();
|
||||
g.moveTo(prev.x, prev.y);
|
||||
g.lineTo(curr.x, curr.y);
|
||||
g.stroke();
|
||||
}
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function trailColorForAngle(angle) {
|
||||
let deg = radToDeg(angle) + 90;
|
||||
while (deg <= -180) deg += 360;
|
||||
while (deg > 180) deg -= 360;
|
||||
const centered = deg;
|
||||
if (Math.abs(centered) <= 30) return '#48d296';
|
||||
if (Math.abs(centered) <= 60) return '#ffd678';
|
||||
return '#ff7a78';
|
||||
}
|
||||
|
||||
function clamp1(value) {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.max(-1, Math.min(1, value));
|
||||
}
|
||||
|
||||
function ringFraction(index, ringCount) {
|
||||
const n = Math.max(1, ringCount | 0);
|
||||
const idx = Math.max(0, Math.min(n - 1, index | 0));
|
||||
return (n - idx) / n;
|
||||
}
|
||||
|
||||
function getPhaseAmplitudeMode(CONFIG) {
|
||||
return CONFIG?.PHASE_AMPLITUDE_MODE === 'ppm-din' ? 'ppm-din' : 'bandpass';
|
||||
}
|
||||
|
||||
function getRingDbValues(CONFIG) {
|
||||
return getPhaseAmplitudeMode(CONFIG) === 'ppm-din' ? RING_PPM_DIN : RING_DBFS;
|
||||
}
|
||||
|
||||
function formatAmplitudeLabel(db, mode) {
|
||||
const num = Number(db);
|
||||
if (!Number.isFinite(num)) return '';
|
||||
if (mode === 'ppm-din') return num > 0 ? `+${num}` : `${num}`;
|
||||
return `${num} dB`;
|
||||
}
|
||||
|
||||
function dbToRadiusNorm(db, ringDbValues) {
|
||||
if (!Array.isArray(ringDbValues) || !ringDbValues.length) return 0;
|
||||
const n = ringDbValues.length;
|
||||
const maxDb = ringDbValues[0];
|
||||
const minDb = ringDbValues[n - 1];
|
||||
if (!Number.isFinite(db)) return ringFraction(n - 1, n);
|
||||
if (db >= maxDb) return ringFraction(0, n);
|
||||
if (db <= minDb) return ringFraction(n - 1, n);
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
const hiDb = ringDbValues[i];
|
||||
const loDb = ringDbValues[i + 1];
|
||||
if (db <= hiDb && db >= loDb) {
|
||||
const span = Math.max(1e-10, hiDb - loDb);
|
||||
const t = (db - loDb) / span;
|
||||
const hiFrac = ringFraction(i, n);
|
||||
const loFrac = ringFraction(i + 1, n);
|
||||
return loFrac + (hiFrac - loFrac) * t;
|
||||
}
|
||||
}
|
||||
return ringFraction(n - 1, n);
|
||||
}
|
||||
|
||||
function linearToRadiusNorm(amp, ringDbValues) {
|
||||
const ampDb = linearToDb(Math.max(1e-10, Math.min(1, amp)));
|
||||
return dbToRadiusNorm(ampDb, ringDbValues);
|
||||
}
|
||||
|
||||
function mapPpmRawToDin(raw, cfg) {
|
||||
const minDb = Number.isFinite(cfg?.PPM_DIN_BOTTOM) ? cfg.PPM_DIN_BOTTOM : -50;
|
||||
const maxDb = Number.isFinite(cfg?.PPM_DIN_TOP) ? cfg.PPM_DIN_TOP : +5;
|
||||
if (!Number.isFinite(raw)) return minDb;
|
||||
const base = Number.isFinite(cfg?.PPM_REF_DBFS_PEAK_FOR_0_DBU) ? cfg.PPM_REF_DBFS_PEAK_FOR_0_DBU : -15;
|
||||
const effOff = (cfg?.PPM_DIN_MODE === 'al_minus6' ? -6 : -9) + (Number(cfg?.PPM_DIN_TRIM_DB) || 0);
|
||||
const mapped = (raw - base) + effOff;
|
||||
return Math.max(minDb, Math.min(maxDb, mapped));
|
||||
}
|
||||
|
||||
function computePpmDinRadiusNorm(audio, cfg, ringDbValues) {
|
||||
const rawL = Number.isFinite(audio?.ppmDinL) ? audio.ppmDinL : audio?.ppmL;
|
||||
const rawR = Number.isFinite(audio?.ppmDinR) ? audio.ppmDinR : audio?.ppmR;
|
||||
const l = mapPpmRawToDin(rawL, cfg);
|
||||
const r = mapPpmRawToDin(rawR, cfg);
|
||||
const db = Math.max(l, r);
|
||||
return dbToRadiusNorm(db, ringDbValues);
|
||||
}
|
||||
|
||||
function createBandpassState() {
|
||||
return {
|
||||
sampleRate: 0,
|
||||
hpAlpha: 0,
|
||||
lpAlpha: 0,
|
||||
channels: {
|
||||
L: { hpX: 0, hpY: 0, lpY: 0 },
|
||||
R: { hpX: 0, hpY: 0, lpY: 0 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function ensureBandpassCoeffs(state, sampleRate) {
|
||||
if (!state.bandpass) state.bandpass = createBandpassState();
|
||||
const sr = Math.max(8000, Math.round(sampleRate) || 48000);
|
||||
if (state.bandpass.sampleRate === sr) return;
|
||||
state.bandpass.sampleRate = sr;
|
||||
state.bandpass.hpAlpha = computeHighpassAlpha(sr, PHASE_BANDPASS_LOW_HZ);
|
||||
state.bandpass.lpAlpha = computeLowpassAlpha(sr, PHASE_BANDPASS_HIGH_HZ);
|
||||
}
|
||||
|
||||
function computeHighpassAlpha(sampleRate, cutoff) {
|
||||
const rc = 1 / (2 * Math.PI * Math.max(1, cutoff));
|
||||
const dt = 1 / Math.max(1, sampleRate);
|
||||
return Math.max(0, Math.min(1, rc / (rc + dt)));
|
||||
}
|
||||
|
||||
function computeLowpassAlpha(sampleRate, cutoff) {
|
||||
const rc = 1 / (2 * Math.PI * Math.max(1, cutoff));
|
||||
const dt = 1 / Math.max(1, sampleRate);
|
||||
return Math.max(0, Math.min(1, dt / (rc + dt)));
|
||||
}
|
||||
|
||||
function ensureFilteredBuffers(state, length) {
|
||||
const neededLength = Math.ceil(length * 1.1); // 10% Puffer für Stabilität
|
||||
if (!state.filteredL || state.filteredL.length < neededLength) {
|
||||
state.filteredL = new Float32Array(neededLength);
|
||||
}
|
||||
if (!state.filteredR || state.filteredR.length < neededLength) {
|
||||
state.filteredR = new Float32Array(neededLength);
|
||||
}
|
||||
return { L: state.filteredL, R: state.filteredR };
|
||||
}
|
||||
|
||||
function applyBandpassSample(sample, channelState, bandpassState) {
|
||||
const hpAlpha = bandpassState.hpAlpha;
|
||||
const lpAlpha = bandpassState.lpAlpha;
|
||||
|
||||
if (!Number.isFinite(sample)) sample = 0;
|
||||
|
||||
const hpY = hpAlpha * (channelState.hpY + sample - channelState.hpX);
|
||||
channelState.hpY = Number.isFinite(hpY) ? hpY : 0;
|
||||
channelState.hpX = sample;
|
||||
|
||||
const lpY = lpAlpha * hpY + (1 - lpAlpha) * channelState.lpY;
|
||||
channelState.lpY = Number.isFinite(lpY) ? lpY : 0;
|
||||
|
||||
return lpY;
|
||||
}
|
||||
|
||||
function preparePhaseFilteredBuffers(state, xyData) {
|
||||
ensureBandpassCoeffs(state, xyData.sampleRate || 48000);
|
||||
const filtered = ensureFilteredBuffers(state, xyData.length);
|
||||
|
||||
// Reset channel states if they contain NaN/Infinity
|
||||
if (!Number.isFinite(state.bandpass.channels.L.hpY)) {
|
||||
state.bandpass.channels.L = { hpX: 0, hpY: 0, lpY: 0 };
|
||||
}
|
||||
if (!Number.isFinite(state.bandpass.channels.R.hpY)) {
|
||||
state.bandpass.channels.R = { hpX: 0, hpY: 0, lpY: 0 };
|
||||
}
|
||||
|
||||
for (let i = 0; i < xyData.length; i++) {
|
||||
filtered.L[i] = applyBandpassSample(
|
||||
xyData.xyL[i],
|
||||
state.bandpass.channels.L,
|
||||
state.bandpass
|
||||
);
|
||||
filtered.R[i] = applyBandpassSample(
|
||||
xyData.xyR[i],
|
||||
state.bandpass.channels.R,
|
||||
state.bandpass
|
||||
);
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function decayPhasePointer(state) {
|
||||
const prevPhase = Number.isFinite(state.currentPhase) ? state.currentPhase : 0;
|
||||
const prevRadius = Number.isFinite(state.currentRadius) ? state.currentRadius : 0;
|
||||
const decayedRadius = prevRadius * PHASE_IDLE_DECAY;
|
||||
state.currentPhase = prevPhase;
|
||||
state.currentRadius = decayedRadius;
|
||||
const prevSmoothPhase = Number.isFinite(state.smoothPhase) ? state.smoothPhase : prevPhase;
|
||||
const prevSmoothRadius = Number.isFinite(state.smoothRadius) ? state.smoothRadius : decayedRadius;
|
||||
state.smoothPhase = smoothAngle(prevSmoothPhase, prevPhase, PHASE_PHASE_SMOOTH_ALPHA * 0.5);
|
||||
state.smoothRadius = lerp(prevSmoothRadius, decayedRadius, PHASE_RADIUS_SMOOTH_ALPHA);
|
||||
}
|
||||
|
||||
function resolvePhaseGain(state, xyData, CONFIG) {
|
||||
const gainDb = clampPhaseGain(CONFIG?.PHASE_DISPLAY_GAIN_DB ?? 0);
|
||||
const allowAgc = CONFIG?.PHASE_AGC_ENABLED && getPhaseAmplitudeMode(CONFIG) !== 'ppm-din';
|
||||
if (allowAgc && xyData?.ready) {
|
||||
const auto = computePhaseAgcGain(state, xyData);
|
||||
return { gainDb: auto.gainDb, gain: auto.gain * PHASE_AGC_BASE_GAIN };
|
||||
}
|
||||
state.phaseAgcGainDb = gainDb;
|
||||
return { gainDb, gain: dbToLinear(gainDb) };
|
||||
}
|
||||
|
||||
function wrapAngle(rad) {
|
||||
if (!Number.isFinite(rad)) return 0;
|
||||
|
||||
// Verbesserte Winkel-Normalisierung mit besserer numerischer Stabilität
|
||||
const TWO_PI = Math.PI * 2;
|
||||
const normalized = ((rad % TWO_PI) + TWO_PI) % TWO_PI;
|
||||
|
||||
// Sicherstellen, dass der Winkel im Bereich [-π, π] liegt
|
||||
return normalized > Math.PI ? normalized - TWO_PI : normalized;
|
||||
}
|
||||
|
||||
function smoothAngle(prev, next, alpha) {
|
||||
if (!Number.isFinite(prev)) return next;
|
||||
if (!Number.isFinite(next)) return prev;
|
||||
const diff = wrapAngle(next - prev);
|
||||
return wrapAngle(prev + diff * Math.max(0, Math.min(1, alpha)));
|
||||
}
|
||||
|
||||
function lerp(a, b, t) {
|
||||
if (!Number.isFinite(a)) return b;
|
||||
if (!Number.isFinite(b)) return a;
|
||||
const clampedT = Math.max(0, Math.min(1, t));
|
||||
return a + (b - a) * clampedT;
|
||||
}
|
||||
|
||||
function hilbertAt(buffer, idx) {
|
||||
if (!buffer || idx < 0 || idx >= buffer.length) return 0;
|
||||
|
||||
let acc = 0;
|
||||
for (let k = 0; k < HILBERT_KERNEL.length; k++) {
|
||||
const src = idx + k - HILBERT_HALF;
|
||||
if (src < 0 || src >= buffer.length) continue;
|
||||
const sample = buffer[src];
|
||||
const kernel = HILBERT_KERNEL[k];
|
||||
if (Number.isFinite(sample) && Number.isFinite(kernel)) {
|
||||
acc += sample * kernel;
|
||||
}
|
||||
}
|
||||
return Number.isFinite(acc) ? acc : 0;
|
||||
}
|
||||
|
||||
function buildHilbertKernel(size = 33) {
|
||||
const taps = size % 2 === 0 ? size + 1 : size;
|
||||
const mid = (taps - 1) / 2;
|
||||
const kernel = new Float32Array(taps);
|
||||
|
||||
for (let n = 0; n < taps; n++) {
|
||||
const k = n - mid;
|
||||
|
||||
// Verbesserte numerische Stabilität + even k = 0 wie idealer Hilbert-Kernel
|
||||
if (Math.abs(k) < 1e-10 || k % 2 === 0) {
|
||||
kernel[n] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
const window = 0.54 - 0.46 * Math.cos((2 * Math.PI * n) / Math.max(1, taps - 1));
|
||||
const value = (2 / (Math.PI * k)) * window;
|
||||
|
||||
// Sicherstellen, dass der Wert finite ist
|
||||
kernel[n] = Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
return kernel;
|
||||
}
|
||||
|
||||
function computePhaseAgcGain(state, xyData) {
|
||||
const now = getNow();
|
||||
const dt = state.phaseAgcLastTs ? Math.max(0, (now - state.phaseAgcLastTs) / 1000) : 0;
|
||||
state.phaseAgcLastTs = now;
|
||||
|
||||
const peak = measurePhasePeak(xyData);
|
||||
let env = Number.isFinite(state.phaseAgcEnv) && state.phaseAgcEnv > 0 ? state.phaseAgcEnv : 1e-3;
|
||||
|
||||
if (peak >= env) {
|
||||
const alpha = 1 - Math.exp(-Math.max(dt, 0) / Math.max(0.001, PHASE_AGC_ATTACK_S));
|
||||
env = env + (peak - env) * Math.min(1, alpha || 1);
|
||||
} else {
|
||||
const releaseFactor = Math.pow(10, -PHASE_AGC_RELEASE_DB_PER_S * Math.max(dt, 0) / 20);
|
||||
env = Math.max(peak, env * releaseFactor);
|
||||
}
|
||||
|
||||
env = Math.max(1e-8, env); // Verbesserter minimaler Wert
|
||||
state.phaseAgcEnv = env;
|
||||
|
||||
const envDb = linearToDb(env);
|
||||
let gainDb = PHASE_AGC_TARGET_DB - envDb;
|
||||
|
||||
if (gainDb > PHASE_GAIN_MAX_DB) gainDb = PHASE_GAIN_MAX_DB;
|
||||
if (gainDb < PHASE_GAIN_MIN_DB) gainDb = PHASE_GAIN_MIN_DB;
|
||||
|
||||
state.phaseAgcGainDb = gainDb;
|
||||
return { gainDb, gain: dbToLinear(gainDb) };
|
||||
}
|
||||
|
||||
function measurePhasePeak(xyData) {
|
||||
if (!xyData || !xyData.ready) return 1e-4; // Verbesserter Default-Wert
|
||||
|
||||
let peak = 1e-8; // Höhere Präzision
|
||||
const len = Math.min(xyData.length, 1000); // Begrenzung für Performance
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const sample = Math.max(
|
||||
Math.abs(xyData.xyL[i] || 0),
|
||||
Math.abs(xyData.xyR[i] || 0)
|
||||
);
|
||||
if (sample > peak) peak = sample;
|
||||
}
|
||||
|
||||
return Math.max(1e-8, peak); // Sicherstellen, dass nicht 0 zurückgegeben wird
|
||||
}
|
||||
|
||||
function clampPhaseGain(db) {
|
||||
let val = Number(db);
|
||||
if (!Number.isFinite(val)) val = 0;
|
||||
if (val < PHASE_GAIN_MIN_DB) val = PHASE_GAIN_MIN_DB;
|
||||
if (val > PHASE_GAIN_MAX_DB) val = PHASE_GAIN_MAX_DB;
|
||||
return val;
|
||||
}
|
||||
|
||||
function getNow() {
|
||||
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
|
||||
return performance.now();
|
||||
}
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function linearToDb(value) {
|
||||
if (value <= 1e-10) return -200; // Explizite Behandlung sehr kleiner Werte
|
||||
const result = 20 * Math.log10(value);
|
||||
return Number.isFinite(result) ? result : -200;
|
||||
}
|
||||
|
||||
function dbToLinear(db) {
|
||||
if (!Number.isFinite(db)) return 0;
|
||||
if (db < -200) return 0;
|
||||
const result = Math.pow(10, db / 20);
|
||||
return Number.isFinite(result) ? result : 0;
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
// 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';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function splitRectToRows(rect) {
|
||||
const w = Math.max(0, rect?.w || 0);
|
||||
const h = Math.max(0, rect?.h || 0);
|
||||
const gap = Math.max(0, ROW_GAP);
|
||||
const topH = Math.max(0, Math.floor((h - gap) / 2));
|
||||
const bottomH = Math.max(0, h - gap - topH);
|
||||
return {
|
||||
top: { x: rect.x, y: rect.y, w, h: topH },
|
||||
bottom: { x: rect.x, y: rect.y + topH + gap, w, h: bottomH },
|
||||
};
|
||||
}
|
||||
|
||||
function unionRectHoriz(a, b) {
|
||||
if (!a && !b) return null;
|
||||
if (!a) return { ...b };
|
||||
if (!b) return { ...a };
|
||||
const left = Math.min(a.x, b.x);
|
||||
const right = Math.max(a.x + a.w, b.x + b.w);
|
||||
const top = Math.min(a.y, b.y);
|
||||
const bottom = Math.max(a.y + a.h, b.y + b.h);
|
||||
return { x: left, y: top, w: Math.max(0, right - left), h: Math.max(0, bottom - top) };
|
||||
}
|
||||
|
||||
function ensureChildRectSize(env, rect, child) {
|
||||
if (!child || child.id === 'none' || !rect) return;
|
||||
const sig = `${Math.max(0, rect.w | 0)}x${Math.max(0, rect.h | 0)}`;
|
||||
if (child._lastSizeSig === sig) return;
|
||||
child._lastSizeSig = sig;
|
||||
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];
|
||||
if (mod && typeof mod.destroy === 'function') {
|
||||
try { mod.destroy(child.state); } catch (e) { console.warn('Quad child destroy error:', e); }
|
||||
}
|
||||
}
|
||||
|
||||
function initChild(env, childId) {
|
||||
const id = sanitizeChildId(childId, 'none');
|
||||
if (id === 'none') return { id: 'none', state: {} };
|
||||
const mod = CHILD_VIEWS[id];
|
||||
const state = (mod && typeof mod.init === 'function') ? (mod.init(env) || {}) : {};
|
||||
return { id, state };
|
||||
}
|
||||
|
||||
function resizeChild(env, rect, child) {
|
||||
if (!child || child.id === 'none') return;
|
||||
const mod = CHILD_VIEWS[child.id];
|
||||
if (!mod || typeof mod.resize !== 'function') return;
|
||||
try { mod.resize({ rect }, child.state); } catch (e) { console.warn('Quad child resize error:', e); }
|
||||
}
|
||||
|
||||
async function renderChild(env, rect, child, opts = {}) {
|
||||
const { ctx: g } = env;
|
||||
if (!child || child.id === 'none') return;
|
||||
const mod = CHILD_VIEWS[child.id];
|
||||
if (!mod || typeof mod.render !== 'function') return;
|
||||
const topInset = Number.isFinite(Number(opts.topInset)) ? Number(opts.topInset) : null;
|
||||
await withClippedSubRect(g, rect, async () => {
|
||||
const plotOnlySlots = (viewId) => {
|
||||
if (viewId === 'peak-history' || viewId === 'classic-needles' || viewId === 'panel') {
|
||||
const configured = env?.slots?.(viewId);
|
||||
if (Array.isArray(configured) && configured.length) return configured;
|
||||
}
|
||||
if (viewId === 'peak-history') return ['ppm-din'];
|
||||
if (viewId === 'classic-needles') return ['vu'];
|
||||
if (viewId === 'panel') return ['vu', 'ppm-ebu', 'ppm-din', 'tp', 'rms'];
|
||||
return ['none'];
|
||||
};
|
||||
const subEnv = Object.assign({}, env, {
|
||||
rect: { x: 0, y: 0, w: rect.w, h: rect.h },
|
||||
embedded: true,
|
||||
containerView: 'quad-view',
|
||||
slots: plotOnlySlots,
|
||||
embeddedOffsetX: rect.x,
|
||||
embeddedOffsetY: rect.y,
|
||||
});
|
||||
if (topInset !== null) subEnv.topInset = topInset;
|
||||
await mod.render(subEnv, child.state);
|
||||
});
|
||||
}
|
||||
|
||||
export function init(env) {
|
||||
const tlId = sanitizePlotId(env?.config?.QUAD_VIEW_TL, 'phase-wheel');
|
||||
const trId = sanitizePlotId(env?.config?.QUAD_VIEW_TR, 'realtime');
|
||||
const blId = sanitizePlotId(env?.config?.QUAD_VIEW_BL, 'goniometer-rtw');
|
||||
const brId = sanitizePlotId(env?.config?.QUAD_VIEW_BR, 'none');
|
||||
const state = {
|
||||
staticLayers: new Map(),
|
||||
tl: initChild(env, tlId),
|
||||
tr: initChild(env, trId),
|
||||
bl: initChild(env, blId),
|
||||
br: initChild(env, brId),
|
||||
popup: initChild(env, 'none'),
|
||||
lastTlId: tlId,
|
||||
lastTrId: trId,
|
||||
lastBlId: blId,
|
||||
lastBrId: brId,
|
||||
lastPopupId: 'none',
|
||||
lastRectSig: '',
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
export function destroy(state) {
|
||||
if (state) state.staticLayers = null;
|
||||
destroyChild(state?.tl);
|
||||
destroyChild(state?.tr);
|
||||
destroyChild(state?.bl);
|
||||
destroyChild(state?.br);
|
||||
destroyChild(state?.popup);
|
||||
}
|
||||
|
||||
export function resize({ rect }, state) {
|
||||
if (!state || !rect) return;
|
||||
const sig = `${rect.w}x${rect.h}`;
|
||||
if (state.lastRectSig === sig) return;
|
||||
state.lastRectSig = sig;
|
||||
const w = Math.max(0, rect.w);
|
||||
const h = Math.max(0, rect.h);
|
||||
const contentY = CONTENT_TOP;
|
||||
const contentH = Math.max(0, h - CONTENT_TOP - CONTENT_BOTTOM);
|
||||
const gap = OUTER_GAP;
|
||||
const half = Math.floor((w - gap) / 2);
|
||||
const leftCol = { x: 0, y: contentY, w: Math.max(0, half), h: contentH };
|
||||
const rightCol = { x: Math.max(0, half + gap), y: contentY, w: Math.max(0, w - (half + gap)), h: contentH };
|
||||
const leftRows = splitRectToRows(leftCol);
|
||||
const rightRows = splitRectToRows(rightCol);
|
||||
resizeChild(null, { x: 0, y: 0, w: leftRows.top.w, h: leftRows.top.h }, state.tl);
|
||||
resizeChild(null, { x: 0, y: 0, w: rightRows.top.w, h: rightRows.top.h }, state.tr);
|
||||
resizeChild(null, { x: 0, y: 0, w: leftRows.bottom.w, h: leftRows.bottom.h }, state.bl);
|
||||
resizeChild(null, { x: 0, y: 0, w: rightRows.bottom.w, h: rightRows.bottom.h }, state.br);
|
||||
resizeChild(null, { x: 0, y: 0, w, h }, state.popup);
|
||||
}
|
||||
|
||||
export async function render(env, state) {
|
||||
const { ctx: g, rect, config: CONFIG } = env;
|
||||
const contentY = CONTENT_TOP;
|
||||
const contentH = Math.max(0, rect.h - CONTENT_TOP - CONTENT_BOTTOM);
|
||||
const popupCfg = env?.quadPopup;
|
||||
const popupWanted = popupCfg && popupCfg.open ? sanitizeChildId(popupCfg.viewId, 'none') : 'none';
|
||||
|
||||
// Popup-Modus: rendere die View in Originalgröße (vollflächig),
|
||||
// ohne Quad-Hintergrund/Layout. So sieht es exakt wie die Einzel-View aus.
|
||||
if (popupWanted !== 'none' && state) {
|
||||
if (state.lastPopupId !== popupWanted) {
|
||||
destroyChild(state.popup);
|
||||
state.popup = initChild(env, popupWanted);
|
||||
state.lastPopupId = popupWanted;
|
||||
resizeChild(null, { x: 0, y: 0, w: rect.w, h: rect.h }, state.popup);
|
||||
}
|
||||
|
||||
state._quadHit = {
|
||||
contentY: 0,
|
||||
quadrants: [],
|
||||
meters: [],
|
||||
popupBox: { x: 0, y: 0, w: rect.w, h: rect.h },
|
||||
};
|
||||
|
||||
const mod = CHILD_VIEWS[state.popup.id];
|
||||
if (mod && typeof mod.render === 'function') {
|
||||
const subEnv = Object.assign({}, env, {
|
||||
rect: { x: 0, y: 0, w: rect.w, h: rect.h },
|
||||
embedded: false,
|
||||
});
|
||||
await mod.render(subEnv, state.popup.state);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (state && state.lastPopupId !== 'none') {
|
||||
destroyChild(state.popup);
|
||||
state.popup = initChild(env, 'none');
|
||||
state.lastPopupId = 'none';
|
||||
}
|
||||
|
||||
const desiredTlId = sanitizePlotId(CONFIG?.QUAD_VIEW_TL, 'phase-wheel');
|
||||
const desiredTrId = sanitizePlotId(CONFIG?.QUAD_VIEW_TR, 'realtime');
|
||||
const desiredBlId = sanitizePlotId(CONFIG?.QUAD_VIEW_BL, 'goniometer-rtw');
|
||||
const desiredBrId = sanitizePlotId(CONFIG?.QUAD_VIEW_BR, 'none');
|
||||
|
||||
if (state.lastTlId !== desiredTlId) {
|
||||
destroyChild(state.tl);
|
||||
state.tl = initChild(env, desiredTlId);
|
||||
state.lastTlId = desiredTlId;
|
||||
}
|
||||
if (state.lastTrId !== desiredTrId) {
|
||||
destroyChild(state.tr);
|
||||
state.tr = initChild(env, desiredTrId);
|
||||
state.lastTrId = desiredTrId;
|
||||
}
|
||||
if (state.lastBlId !== desiredBlId) {
|
||||
destroyChild(state.bl);
|
||||
state.bl = initChild(env, desiredBlId);
|
||||
state.lastBlId = desiredBlId;
|
||||
}
|
||||
if (state.lastBrId !== desiredBrId) {
|
||||
destroyChild(state.br);
|
||||
state.br = initChild(env, desiredBrId);
|
||||
state.lastBrId = desiredBrId;
|
||||
}
|
||||
|
||||
const slots = readQuadMeters(CONFIG);
|
||||
const layout = computeBaseLayout(rect, slots);
|
||||
const leftColRaw = layout?.plots?.left || { x: 0, y: 0, w: Math.floor(rect.w / 2), h: rect.h };
|
||||
const rightColRaw = layout?.plots?.right || { x: Math.floor(rect.w / 2), y: 0, w: rect.w - Math.floor(rect.w / 2), h: rect.h };
|
||||
const leftCol = { x: leftColRaw.x, y: contentY, w: leftColRaw.w, h: contentH };
|
||||
const rightCol = { x: rightColRaw.x, y: contentY, w: rightColRaw.w, h: contentH };
|
||||
const leftRows = splitRectToRows(leftCol);
|
||||
const rightRows = splitRectToRows(rightCol);
|
||||
|
||||
const canRowMerge = !layout?.meters?.center;
|
||||
const topLeftActive = desiredTlId !== 'none';
|
||||
const topRightActive = desiredTrId !== 'none';
|
||||
const bottomLeftActive = desiredBlId !== 'none';
|
||||
const bottomRightActive = desiredBrId !== 'none';
|
||||
|
||||
let tlRect = { ...leftRows.top };
|
||||
let trRect = { ...rightRows.top };
|
||||
let blRect = { ...leftRows.bottom };
|
||||
let brRect = { ...rightRows.bottom };
|
||||
|
||||
if (canRowMerge) {
|
||||
if (topLeftActive && !topRightActive) {
|
||||
tlRect = unionRectHoriz(tlRect, trRect);
|
||||
trRect = null;
|
||||
} else if (!topLeftActive && topRightActive) {
|
||||
trRect = unionRectHoriz(tlRect, trRect);
|
||||
tlRect = null;
|
||||
}
|
||||
|
||||
if (bottomLeftActive && !bottomRightActive) {
|
||||
blRect = unionRectHoriz(blRect, brRect);
|
||||
brRect = null;
|
||||
} else if (!bottomLeftActive && bottomRightActive) {
|
||||
brRect = unionRectHoriz(blRect, brRect);
|
||||
blRect = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (state) {
|
||||
state._quadHit = {
|
||||
contentY,
|
||||
quadrants: [
|
||||
{ key: 'tl', rect: tlRect, viewId: desiredTlId },
|
||||
{ key: 'tr', rect: trRect, viewId: desiredTrId },
|
||||
{ key: 'bl', rect: blRect, viewId: desiredBlId },
|
||||
{ key: 'br', rect: brRect, viewId: desiredBrId },
|
||||
],
|
||||
meters: [layout?.meters?.left, layout?.meters?.center, layout?.meters?.right].filter(Boolean).map((r) => ({ ...r })),
|
||||
popupBox: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (tlRect) {
|
||||
if (state.tl?.id === 'none') drawEmptyPlot(state, g, tlRect, 'OL: (leer)');
|
||||
else {
|
||||
ensureChildRectSize(null, tlRect, state.tl);
|
||||
await renderChild(env, tlRect, state.tl, { topInset: 0 });
|
||||
}
|
||||
}
|
||||
if (trRect) {
|
||||
if (state.tr?.id === 'none') drawEmptyPlot(state, g, trRect, 'OR: (leer)');
|
||||
else {
|
||||
ensureChildRectSize(null, trRect, state.tr);
|
||||
await renderChild(env, trRect, state.tr, { topInset: 0 });
|
||||
}
|
||||
}
|
||||
if (blRect) {
|
||||
if (state.bl?.id === 'none') drawEmptyPlot(state, g, blRect, 'UL: (leer)');
|
||||
else {
|
||||
ensureChildRectSize(null, blRect, state.bl);
|
||||
await renderChild(env, blRect, state.bl, { topInset: 0 });
|
||||
}
|
||||
}
|
||||
if (brRect) {
|
||||
if (state.br?.id === 'none') drawEmptyPlot(state, g, brRect, 'UR: (leer)');
|
||||
else {
|
||||
ensureChildRectSize(null, brRect, state.br);
|
||||
await renderChild(env, brRect, state.br, { topInset: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
if (layout?.meters?.left) await drawMetersPanel(env, state, layout.meters.left, layout.meterIds.left, layout.slotW);
|
||||
if (layout?.meters?.center) await drawMetersPanel(env, state, layout.meters.center, layout.meterIds.center, layout.slotW);
|
||||
if (layout?.meters?.right) await drawMetersPanel(env, state, layout.meters.right, layout.meterIds.right, layout.slotW);
|
||||
}
|
||||
@@ -0,0 +1,941 @@
|
||||
import { getRtwCenters, resolveRtwBpoValue } from '../core/rtw_centers.js';
|
||||
import { DEFAULT_TOP_INSET, FRAME_COLOR, MID_COLOR, PANEL_BG, WARN_COLOR } from '../core/theme.js';
|
||||
|
||||
// views/realtime.js — IEC-konformer Real-Time Analyzer
|
||||
|
||||
const METER_WIDTH = 140;
|
||||
const METER_GAP = 8;
|
||||
const METER_PAD_TOP = 15;
|
||||
const METER_PAD_BOTTOM = 5;
|
||||
const METER_SLOT_SHRINK = 24; // reduziert nutzbare Höhe leicht, damit Slot niedriger wirkt
|
||||
const METER_EXTRA_BOTTOM_PAD = 5;
|
||||
const FLOOR_DB = -120;
|
||||
const RTA_X_TICKS = [20, 31.5, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
|
||||
const DEFAULT_RTA_BAR_BASE_COLOR = MID_COLOR;
|
||||
const RTA_PEAK_COLOR = WARN_COLOR;
|
||||
const EMBEDDED_RTA_HIDE_20HZ_WIDTH = 420;
|
||||
const STATIC_LABEL_PAD_LEFT = 40;
|
||||
const STATIC_LABEL_PAD_BOTTOM = 28;
|
||||
const STATIC_STROKE_PAD = 2;
|
||||
|
||||
const PEAK_HOLD_MAP = new Map(Object.entries({
|
||||
off: 0,
|
||||
auto: null,
|
||||
'1s': 1,
|
||||
'2s': 2,
|
||||
'4s': 4,
|
||||
'10s': 10,
|
||||
'20s': 20,
|
||||
'30s': 30,
|
||||
manual: Infinity,
|
||||
}));
|
||||
|
||||
const INTEGRATION_TAU = {
|
||||
impulse: 0.035,
|
||||
fast: 0.125,
|
||||
slow: 1.0,
|
||||
peak: 0.01,
|
||||
};
|
||||
|
||||
|
||||
export const id = 'realtime';
|
||||
|
||||
export function init() {
|
||||
return {
|
||||
bandKey: null,
|
||||
bands: [],
|
||||
mapping: [],
|
||||
ewma: new Float32Array(0),
|
||||
peakHold: new Float32Array(0),
|
||||
lastPeakTime: [],
|
||||
displayLevels: new Float32Array(0),
|
||||
levelBuffer: new Float32Array(0),
|
||||
rtBarLevels: new Float32Array(0),
|
||||
rtBarPeakTimes: [],
|
||||
lastRtBarTs: 0,
|
||||
lastDisplayUpdate: 0,
|
||||
holdSampleTs: 0,
|
||||
lastIntegrationTs: 0,
|
||||
configSig: '',
|
||||
staticLayers: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
export function resize() {}
|
||||
export function destroy(state) {
|
||||
if (state) state.staticLayers = null;
|
||||
}
|
||||
|
||||
export async function render(env, state) {
|
||||
const { ctx: g, rect, config: CONFIG, audio, utils, meters } = env;
|
||||
const nyq = audio.nyq || 24000;
|
||||
const analyser = audio.getAnalyser?.();
|
||||
const buf = audio.getFreqBuffer?.();
|
||||
const useIirEngine = (CONFIG.RTA_ENGINE || 'fft') === 'iir';
|
||||
const rtaData = typeof audio.getRtaData === 'function'
|
||||
? audio.getRtaData()
|
||||
: null;
|
||||
const useNativeFftEngine = !useIirEngine && rtaData && rtaData.engine === 'fft';
|
||||
const nativeRtaPacket = (useIirEngine || useNativeFftEngine) ? rtaData : null;
|
||||
const displayRtaPacket = (nativeRtaPacket && CONFIG.RTA_BAR_LAYOUT === 'rtw')
|
||||
? selectLocalRtwPacket(nativeRtaPacket, CONFIG.RTA_BPO_MODE || '1_6')
|
||||
: nativeRtaPacket;
|
||||
|
||||
const topInset = Number.isFinite(env?.topInset) ? Number(env.topInset) : DEFAULT_TOP_INSET;
|
||||
const PLOT = { left: 43, top: topInset, right: 0, bottom: 18 };
|
||||
const plotX = PLOT.left;
|
||||
const plotY = PLOT.top;
|
||||
const slotList = env.slots ? env.slots('realtime') : null;
|
||||
const activeMeter = (slotList && slotList[0]) || 'vu';
|
||||
const showMeter = activeMeter !== 'none';
|
||||
const plotW = Math.max(0, (rect.w - PLOT.right) - (showMeter ? (METER_WIDTH + METER_GAP) : 0) - plotX);
|
||||
const plotH = rect.h - PLOT.top - PLOT.bottom;
|
||||
|
||||
const range = getDbRange();
|
||||
const freqBounds = getFreqBounds(CONFIG, nyq, displayRtaPacket);
|
||||
const hide20HzTick = shouldHide20HzTick(env, plotW);
|
||||
const freqTicks = RTA_X_TICKS.filter((f) => {
|
||||
if (hide20HzTick && f === 20) return false;
|
||||
return f >= freqBounds.min && f <= freqBounds.max * 1.05;
|
||||
});
|
||||
const gridConfig = Object.assign({}, CONFIG, {
|
||||
DBFS_TOP: range.top,
|
||||
DBFS_BOTTOM: range.bottom,
|
||||
Y_TICKS: range.ticks,
|
||||
});
|
||||
const alignBase = Number.isFinite(CONFIG.DIGITAL_REF_DBFS_RMS)
|
||||
? CONFIG.DIGITAL_REF_DBFS_RMS
|
||||
: -18;
|
||||
const gainOffset = useIirEngine
|
||||
? Number(CONFIG.RTA_DISPLAY_GAIN_IIR_DB || 0)
|
||||
: 0;
|
||||
const showAlignMarker = CONFIG.AL_MARKERS_ENABLED !== false;
|
||||
const refDb = showAlignMarker ? alignBase + gainOffset : null;
|
||||
const gutterL = Number.isFinite(CONFIG?.AXIS_GUTTER_LEFT)
|
||||
? Math.max(8, Number(CONFIG.AXIS_GUTTER_LEFT))
|
||||
: 14;
|
||||
const plotPadLeft = gutterL + STATIC_LABEL_PAD_LEFT;
|
||||
const plotPadBottom = STATIC_LABEL_PAD_BOTTOM;
|
||||
|
||||
drawCachedStaticLayer(
|
||||
state,
|
||||
g,
|
||||
'rta-plot',
|
||||
[
|
||||
plotW,
|
||||
plotH,
|
||||
range.top,
|
||||
range.bottom,
|
||||
freqBounds.min,
|
||||
freqBounds.max,
|
||||
freqTicks.join(','),
|
||||
refDb ?? 'none',
|
||||
hide20HzTick ? 1 : 0,
|
||||
plotPadLeft,
|
||||
plotPadBottom,
|
||||
].join('|'),
|
||||
{ x: plotX - plotPadLeft, y: plotY, w: plotW + plotPadLeft, h: plotH + plotPadBottom },
|
||||
(lg) => {
|
||||
utils.drawDbfsGridRect(
|
||||
lg,
|
||||
plotPadLeft,
|
||||
0,
|
||||
plotW,
|
||||
plotH,
|
||||
gridConfig,
|
||||
freqBounds.max,
|
||||
{
|
||||
freqTicks,
|
||||
freqMin: freqBounds.min,
|
||||
refDb,
|
||||
refStyle: 'rgba(0,231,255,0.35)',
|
||||
refDash: [3, 4],
|
||||
},
|
||||
);
|
||||
redrawPlotEdges(lg, plotPadLeft, 0, plotW, plotH, CONFIG);
|
||||
},
|
||||
);
|
||||
|
||||
const configSig = buildConfigSignature(CONFIG, nyq, buf?.length || 0);
|
||||
if (state.configSig !== configSig) {
|
||||
resetState(state);
|
||||
state.configSig = configSig;
|
||||
}
|
||||
|
||||
if (!audio.alive) {
|
||||
drawWaiting(g, plotX, plotY);
|
||||
} else {
|
||||
const binCount = buf?.length || analyser?.frequencyBinCount || 2048;
|
||||
ensureBands(state, utils, CONFIG, nyq, binCount, freqBounds, range, displayRtaPacket);
|
||||
if (!state.mapping.length) {
|
||||
drawWaiting(g, plotX, plotY);
|
||||
} else if (useIirEngine) {
|
||||
const ballisticsData = mapIirLevels(displayRtaPacket, CONFIG, range, state.mapping.length);
|
||||
const baseLevels = ballisticsData?.primary;
|
||||
if (!baseLevels || !baseLevels.length) {
|
||||
drawWaiting(g, plotX, plotY);
|
||||
} else {
|
||||
const integrated = baseLevels;
|
||||
const displayBase = applyDisplayHold(state, integrated, CONFIG, range);
|
||||
const display = (CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'bars'
|
||||
? applyRealtimeBarBallistics(state, displayBase, CONFIG, range)
|
||||
: displayBase;
|
||||
applyPeakHold(state, integrated, CONFIG, range);
|
||||
state.displayLevels = display;
|
||||
state.currentRange = range;
|
||||
if (typeof window !== 'undefined') window.__RTA_STATE__ = state;
|
||||
renderSpectrum(
|
||||
g,
|
||||
state,
|
||||
display,
|
||||
plotX,
|
||||
plotY,
|
||||
plotW,
|
||||
plotH,
|
||||
CONFIG,
|
||||
range,
|
||||
freqBounds,
|
||||
ballisticsData?.overlay || null,
|
||||
ballisticsData?.mode || CONFIG.RTA_BALLISTICS_MODE || 'average'
|
||||
);
|
||||
}
|
||||
} else if (useNativeFftEngine) {
|
||||
const nativeLevels = mapNativeFftLevels(displayRtaPacket, CONFIG, range, state.mapping.length);
|
||||
if (!nativeLevels || !nativeLevels.length) {
|
||||
drawWaiting(g, plotX, plotY);
|
||||
} else {
|
||||
const integrated = applyIntegration(state, nativeLevels, CONFIG, range);
|
||||
const displayBase = applyDisplayHold(state, integrated, CONFIG, range);
|
||||
const display = (CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'bars'
|
||||
? applyRealtimeBarBallistics(state, displayBase, CONFIG, range)
|
||||
: displayBase;
|
||||
applyPeakHold(state, integrated, CONFIG, range);
|
||||
state.displayLevels = display;
|
||||
state.currentRange = range;
|
||||
if (typeof window !== 'undefined') window.__RTA_STATE__ = state;
|
||||
renderSpectrum(
|
||||
g,
|
||||
state,
|
||||
display,
|
||||
plotX,
|
||||
plotY,
|
||||
plotW,
|
||||
plotH,
|
||||
CONFIG,
|
||||
range,
|
||||
freqBounds,
|
||||
null,
|
||||
CONFIG.RTA_BALLISTICS_MODE || 'average'
|
||||
);
|
||||
}
|
||||
} else if (!analyser || !buf) {
|
||||
drawWaiting(g, plotX, plotY);
|
||||
} else {
|
||||
analyser.getFloatFrequencyData(buf);
|
||||
const levels = computeBandLevels(state, utils, buf, CONFIG, range);
|
||||
const integrated = applyIntegration(state, levels, CONFIG, range);
|
||||
const displayBase = applyDisplayHold(state, integrated, CONFIG, range);
|
||||
const display = (CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'bars'
|
||||
? applyRealtimeBarBallistics(state, displayBase, CONFIG, range)
|
||||
: displayBase;
|
||||
applyPeakHold(state, integrated, CONFIG, range);
|
||||
state.displayLevels = display;
|
||||
state.currentRange = range;
|
||||
if (typeof window !== 'undefined') window.__RTA_STATE__ = state;
|
||||
renderSpectrum(
|
||||
g,
|
||||
state,
|
||||
display,
|
||||
plotX,
|
||||
plotY,
|
||||
plotW,
|
||||
plotH,
|
||||
CONFIG,
|
||||
range,
|
||||
freqBounds,
|
||||
null,
|
||||
CONFIG.RTA_BALLISTICS_MODE || 'average'
|
||||
);
|
||||
}
|
||||
}
|
||||
if (showMeter) {
|
||||
await drawMeter(env, state, plotX, plotY, plotW, plotH);
|
||||
}
|
||||
}
|
||||
|
||||
function drawWaiting(g, plotX, plotY) {
|
||||
g.fillStyle = '#9aa';
|
||||
const y = plotY >= 40 ? (plotY - 26) : (plotY + 22);
|
||||
g.fillText('Warte auf Audio …', plotX + 10, y);
|
||||
}
|
||||
|
||||
function shouldHide20HzTick(env, plotW) {
|
||||
const containerView = env?.containerView;
|
||||
const embeddedInMultiView = env?.embedded === true
|
||||
&& (containerView === 'split-view' || containerView === 'quad-view');
|
||||
return embeddedInMultiView && plotW < EMBEDDED_RTA_HIDE_20HZ_WIDTH;
|
||||
}
|
||||
|
||||
function buildConfigSignature(CONFIG, nyq, binCount) {
|
||||
return [
|
||||
CONFIG.RTA_FREQ_RANGE,
|
||||
CONFIG.RTA_BPO_MODE,
|
||||
CONFIG.RTA_WEIGHTING,
|
||||
CONFIG.RTA_BAR_LAYOUT,
|
||||
CONFIG.RTA_INTEGRATION,
|
||||
CONFIG.RTA_PEAK_HOLD_MODE,
|
||||
CONFIG.RTA_PEAK_HOLD_SEC,
|
||||
CONFIG.RTA_PEAK_DECAY_DB_PER_S,
|
||||
CONFIG.RTA_DISPLAY_HOLD_SEC,
|
||||
CONFIG.REALTIME_RENDER_STYLE,
|
||||
CONFIG.REALTIME_BAR_HOLD_MS,
|
||||
CONFIG.REALTIME_BAR_DECAY_DB_PER_S,
|
||||
CONFIG.RTA_ENGINE,
|
||||
CONFIG.RTA_IIR_ORDER,
|
||||
CONFIG.RTA_IIR_TAU_FAST,
|
||||
CONFIG.RTA_IIR_TAU_SLOW,
|
||||
CONFIG.RTA_DISPLAY_GAIN_FFT_DB,
|
||||
CONFIG.RTA_DISPLAY_GAIN_IIR_DB,
|
||||
nyq,
|
||||
binCount,
|
||||
].join('|');
|
||||
}
|
||||
|
||||
function resetState(state) {
|
||||
state.bandKey = null;
|
||||
state.bands = [];
|
||||
state.mapping = [];
|
||||
state.ewma = new Float32Array(0);
|
||||
state.peakHold = new Float32Array(0);
|
||||
state.lastPeakTime = [];
|
||||
state.displayLevels = new Float32Array(0);
|
||||
state.levelBuffer = new Float32Array(0);
|
||||
state.rtBarLevels = new Float32Array(0);
|
||||
state.rtBarPeakTimes = [];
|
||||
state.lastRtBarTs = 0;
|
||||
state.lastDisplayUpdate = 0;
|
||||
state.holdSampleTs = 0;
|
||||
state.lastIntegrationTs = 0;
|
||||
}
|
||||
|
||||
function ensureBands(state, utils, CONFIG, nyq, binCount, freqBounds, range, rtaPacket) {
|
||||
const freqRange = CONFIG.RTA_FREQ_RANGE === 'lf' ? 'lf' : 'norm';
|
||||
const bpo = CONFIG.RTA_BPO_MODE || '1_6';
|
||||
const engine = CONFIG.RTA_ENGINE || 'fft';
|
||||
const layoutMode = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'rtw' : 'iec';
|
||||
const rtaCenters = (layoutMode === 'rtw') ? getRtwCenters(bpo) : null;
|
||||
const centerKey = rtaCenters
|
||||
? `${rtaCenters.length}:${rtaCenters[0]}:${rtaCenters[rtaCenters.length - 1]}`
|
||||
: 'static';
|
||||
const key = `${layoutMode}|${freqRange}|${bpo}|${nyq}|${binCount}|${engine}|${centerKey}`;
|
||||
if (state.bandKey === key && state.mapping.length) return;
|
||||
|
||||
let bands;
|
||||
if (layoutMode === 'rtw') {
|
||||
bands = buildFixedRtwBands(bpo, freqBounds, nyq, rtaCenters);
|
||||
if (!bands.length) {
|
||||
bands = utils.makeFractionalOctaveBands(nyq, bpo, {
|
||||
fMin: freqBounds.min,
|
||||
fMax: freqBounds.max,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
bands = utils.makeFractionalOctaveBands(nyq, bpo, {
|
||||
fMin: freqBounds.min,
|
||||
fMax: freqBounds.max,
|
||||
});
|
||||
}
|
||||
const mapping = utils.buildBandBinMapping(bands, nyq, binCount);
|
||||
state.bandKey = key;
|
||||
state.bands = bands;
|
||||
state.mapping = mapping;
|
||||
const len = mapping.length;
|
||||
state.ewma = new Float32Array(len);
|
||||
state.ewma.fill(range.bottom);
|
||||
state.peakHold = new Float32Array(len);
|
||||
state.peakHold.fill(range.bottom);
|
||||
state.lastPeakTime = new Array(len).fill(performance.now());
|
||||
state.displayLevels = new Float32Array(len);
|
||||
state.displayLevels.fill(range.bottom);
|
||||
state.rtBarLevels = new Float32Array(len);
|
||||
state.rtBarLevels.fill(range.bottom);
|
||||
state.rtBarPeakTimes = new Array(len).fill(performance.now());
|
||||
}
|
||||
|
||||
function computeBandLevels(state, utils, buf, CONFIG, range) {
|
||||
const weighting = CONFIG.RTA_WEIGHTING || 'z';
|
||||
const weightFn = (freq) => utils.weightingDb(freq, weighting);
|
||||
const raw = utils.computeBandLevels(state.mapping, buf, {
|
||||
floor: FLOOR_DB,
|
||||
weightingFn: weightFn,
|
||||
});
|
||||
const gain = Number(CONFIG.RTA_DISPLAY_GAIN_FFT_DB ?? 0) || 0;
|
||||
if (!state.levelBuffer || state.levelBuffer.length !== raw.length) {
|
||||
state.levelBuffer = new Float32Array(raw.length);
|
||||
}
|
||||
const out = state.levelBuffer;
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
out[i] = clamp(raw[i] + gain, range.bottom, range.top);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function applyIntegration(state, levels, CONFIG, range) {
|
||||
const mode = (CONFIG.RTA_INTEGRATION && INTEGRATION_TAU[CONFIG.RTA_INTEGRATION])
|
||||
? CONFIG.RTA_INTEGRATION
|
||||
: 'fast';
|
||||
const tau = INTEGRATION_TAU[mode] || 0.125;
|
||||
const now = performance.now();
|
||||
const last = state.lastIntegrationTs || now;
|
||||
const dt = Math.max(1 / 240, (now - last) / 1000);
|
||||
state.lastIntegrationTs = now;
|
||||
const alpha = (tau > 0 && dt > 0) ? 1 - Math.exp(-dt / tau) : 1;
|
||||
|
||||
if (!state.ewma || state.ewma.length !== levels.length) {
|
||||
state.ewma = new Float32Array(levels.length);
|
||||
state.ewma.fill(range.bottom);
|
||||
}
|
||||
const buffer = state.ewma;
|
||||
for (let i = 0; i < levels.length; i++) {
|
||||
const prev = buffer[i];
|
||||
buffer[i] = prev + (levels[i] - prev) * alpha;
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function applyPeakHold(state, levels, CONFIG, range) {
|
||||
const mode = CONFIG.RTA_PEAK_HOLD_MODE || 'auto';
|
||||
const len = levels.length;
|
||||
const mapped = PEAK_HOLD_MAP.get(mode);
|
||||
const holdSec = Number.isFinite(mapped)
|
||||
? Math.max(0, mapped)
|
||||
: Math.max(0, Number(CONFIG.RTA_PEAK_HOLD_SEC) || 0);
|
||||
const decayRate = Math.max(0, Number(CONFIG.RTA_PEAK_DECAY_DB_PER_S) || 0);
|
||||
const now = performance.now();
|
||||
const dt = Math.max(0, (now - (state.holdSampleTs || now)) / 1000);
|
||||
state.holdSampleTs = now;
|
||||
|
||||
if (!state.peakHold || state.peakHold.length !== len) {
|
||||
state.peakHold = new Float32Array(len);
|
||||
state.lastPeakTime = new Array(len).fill(now);
|
||||
}
|
||||
const buffer = state.peakHold;
|
||||
|
||||
if (mode === 'off') {
|
||||
for (let i = 0; i < len; i++) buffer[i] = clamp(levels[i], range.bottom, range.top);
|
||||
state.lastPeakTime = new Array(len).fill(now);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const current = clamp(levels[i], range.bottom, range.top);
|
||||
if (current >= buffer[i] - 0.05) {
|
||||
buffer[i] = current;
|
||||
state.lastPeakTime[i] = now;
|
||||
continue;
|
||||
}
|
||||
if (mapped === Infinity || mode === 'manual') continue;
|
||||
let allowDecay = mode === 'off';
|
||||
if (mode === 'auto') {
|
||||
allowDecay = (now - (state.lastPeakTime[i] || 0)) >= holdSec * 1000;
|
||||
}
|
||||
if (allowDecay && decayRate > 0) {
|
||||
buffer[i] = Math.max(current, buffer[i] - decayRate * dt);
|
||||
}
|
||||
buffer[i] = clamp(buffer[i], range.bottom, range.top);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function applyDisplayHold(state, levels, CONFIG, range) {
|
||||
const holdSec = Math.max(0, Number(CONFIG.RTA_DISPLAY_HOLD_SEC) || 0);
|
||||
const len = levels.length;
|
||||
const now = performance.now();
|
||||
if (!state.displayLevels || state.displayLevels.length !== len) {
|
||||
state.displayLevels = new Float32Array(len);
|
||||
state.lastDisplayUpdate = 0;
|
||||
}
|
||||
const buffer = state.displayLevels;
|
||||
|
||||
if (holdSec <= 0) {
|
||||
for (let i = 0; i < len; i++) {
|
||||
buffer[i] = clamp(levels[i], range.bottom, range.top);
|
||||
}
|
||||
state.lastDisplayUpdate = now;
|
||||
return buffer;
|
||||
}
|
||||
|
||||
const dt = (now - (state.lastDisplayUpdate || 0)) / 1000;
|
||||
if (dt >= holdSec) {
|
||||
for (let i = 0; i < len; i++) {
|
||||
buffer[i] = clamp(levels[i], range.bottom, range.top);
|
||||
}
|
||||
state.lastDisplayUpdate = now;
|
||||
return buffer;
|
||||
}
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const incoming = clamp(levels[i], range.bottom, range.top);
|
||||
buffer[i] = Math.max(buffer[i], incoming);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function applyRealtimeBarBallistics(state, levels, CONFIG, range) {
|
||||
const holdMs = Math.max(0, Number(CONFIG.REALTIME_BAR_HOLD_MS) || 0);
|
||||
const decayRate = Math.max(0, Number(CONFIG.REALTIME_BAR_DECAY_DB_PER_S) || 0);
|
||||
const len = levels.length;
|
||||
const now = performance.now();
|
||||
const dt = Math.max(0, (now - (state.lastRtBarTs || now)) / 1000);
|
||||
state.lastRtBarTs = now;
|
||||
|
||||
if (!state.rtBarLevels || state.rtBarLevels.length !== len) {
|
||||
state.rtBarLevels = new Float32Array(len);
|
||||
state.rtBarLevels.fill(range.bottom);
|
||||
state.rtBarPeakTimes = new Array(len).fill(now);
|
||||
}
|
||||
const buffer = state.rtBarLevels;
|
||||
if (!Array.isArray(state.rtBarPeakTimes) || state.rtBarPeakTimes.length !== len) {
|
||||
state.rtBarPeakTimes = new Array(len).fill(now);
|
||||
}
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const incoming = clamp(levels[i], range.bottom, range.top);
|
||||
if (incoming >= buffer[i] - 0.05) {
|
||||
buffer[i] = incoming;
|
||||
state.rtBarPeakTimes[i] = now;
|
||||
continue;
|
||||
}
|
||||
|
||||
const heldLongEnough = (now - (state.rtBarPeakTimes[i] || 0)) >= holdMs;
|
||||
if (!heldLongEnough) continue;
|
||||
|
||||
if (decayRate > 0) {
|
||||
buffer[i] = Math.max(incoming, buffer[i] - decayRate * dt);
|
||||
} else {
|
||||
buffer[i] = incoming;
|
||||
}
|
||||
buffer[i] = clamp(buffer[i], range.bottom, range.top);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function renderSpectrum(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlayPeaks, ballisticsMode) {
|
||||
const layout = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'rtw' : 'iec';
|
||||
const overlay = (ballisticsMode === 'both' && isVectorLike(overlayPeaks) && overlayPeaks.length === state.mapping.length)
|
||||
? overlayPeaks
|
||||
: null;
|
||||
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);
|
||||
} else {
|
||||
renderIecBars(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlay);
|
||||
}
|
||||
}
|
||||
|
||||
function renderIecBars(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlayPeaks) {
|
||||
const baseY = plotY + plotH;
|
||||
const zeroY = mapDbToY(0, range, plotY, plotH);
|
||||
const logMin = Math.log10(freqBounds.min);
|
||||
const logSpan = Math.max(1e-6, Math.log10(freqBounds.max) - logMin);
|
||||
const hasOverlay = isVectorLike(overlayPeaks) && overlayPeaks.length === state.mapping.length;
|
||||
for (let i = 0; i < state.mapping.length; i++) {
|
||||
const band = state.mapping[i];
|
||||
const level = levels[i];
|
||||
const xLeft = mapLogX(band.fLo, plotX, plotW, logMin, logSpan);
|
||||
const xRight = mapLogX(band.fHi, plotX, plotW, logMin, logSpan);
|
||||
const width = Math.max(2, xRight - xLeft - 1);
|
||||
drawBar(g, xLeft, width, level, zeroY, baseY, plotY, plotH, range, CONFIG);
|
||||
if (hasOverlay && Number.isFinite(overlayPeaks[i])) {
|
||||
drawPeakOverlay(g, xLeft, width, overlayPeaks[i], plotY, plotH, range);
|
||||
}
|
||||
if (CONFIG.RTA_PEAK_HOLD_MODE !== 'off') {
|
||||
drawHold(g, xLeft, width, state.peakHold[i], plotY, plotH, range);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderRtwBars(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, overlayPeaks) {
|
||||
const baseY = plotY + plotH;
|
||||
const zeroY = mapDbToY(0, range, plotY, plotH);
|
||||
const slotW = plotW / Math.max(1, state.mapping.length);
|
||||
const hasOverlay = isVectorLike(overlayPeaks) && overlayPeaks.length === state.mapping.length;
|
||||
for (let i = 0; i < state.mapping.length; i++) {
|
||||
const level = levels[i];
|
||||
const x = plotX + i * slotW + slotW * 0.15;
|
||||
const width = Math.max(2, slotW * 0.7);
|
||||
drawBar(g, x, width, level, zeroY, baseY, plotY, plotH, range, CONFIG);
|
||||
if (hasOverlay && Number.isFinite(overlayPeaks[i])) {
|
||||
drawPeakOverlay(g, x, width, overlayPeaks[i], plotY, plotH, range);
|
||||
}
|
||||
if (CONFIG.RTA_PEAK_HOLD_MODE !== 'off') {
|
||||
drawHold(g, x, width, state.peakHold[i], plotY, plotH, range);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderLine(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlayPeaks) {
|
||||
const logMin = Math.log10(freqBounds.min);
|
||||
const logSpan = Math.max(1e-6, Math.log10(freqBounds.max) - logMin);
|
||||
g.save();
|
||||
g.strokeStyle = '#36bdf8';
|
||||
g.lineWidth = 1.6;
|
||||
g.beginPath();
|
||||
let moved = false;
|
||||
for (let i = 0; i < state.mapping.length; i++) {
|
||||
const band = state.mapping[i];
|
||||
const x = mapLogX(band.center, plotX, plotW, logMin, logSpan);
|
||||
const y = mapDbToY(levels[i], range, plotY, plotH);
|
||||
if (!moved) {
|
||||
g.moveTo(x, y);
|
||||
moved = true;
|
||||
} else {
|
||||
g.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
g.stroke();
|
||||
g.restore();
|
||||
|
||||
if (isVectorLike(overlayPeaks) && overlayPeaks.length === state.mapping.length) {
|
||||
const markerWidth = Math.max(2, plotW / Math.max(40, state.mapping.length * 4));
|
||||
for (let i = 0; i < state.mapping.length; i++) {
|
||||
if (!Number.isFinite(overlayPeaks[i])) continue;
|
||||
const band = state.mapping[i];
|
||||
const x = mapLogX(band.center, plotX, plotW, logMin, logSpan) - markerWidth / 2;
|
||||
drawPeakOverlay(g, x, markerWidth, overlayPeaks[i], plotY, plotH, range);
|
||||
}
|
||||
}
|
||||
|
||||
// Peak markers
|
||||
const barWidth = Math.max(2, plotW / Math.max(10, state.mapping.length * 2));
|
||||
if (CONFIG.RTA_PEAK_HOLD_MODE !== 'off') {
|
||||
for (let i = 0; i < state.mapping.length; i++) {
|
||||
const band = state.mapping[i];
|
||||
const x = mapLogX(band.center, plotX, plotW, logMin, logSpan) - barWidth / 2;
|
||||
drawHold(g, x, barWidth, state.peakHold[i], plotY, plotH, range);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawBar(g, x, width, level, zeroY, baseY, plotY, plotH, range, CONFIG) {
|
||||
const baseColor = CONFIG?.RTA_BAR_BASE_COLOR || DEFAULT_RTA_BAR_BASE_COLOR;
|
||||
const y = mapDbToY(level, range, plotY, plotH);
|
||||
if (level <= 0) {
|
||||
g.fillStyle = baseColor;
|
||||
g.fillRect(x, y, width, Math.max(0, baseY - y));
|
||||
} else {
|
||||
g.fillStyle = baseColor;
|
||||
g.fillRect(x, zeroY, width, Math.max(0, baseY - zeroY));
|
||||
g.fillStyle = RTA_PEAK_COLOR;
|
||||
g.fillRect(x, y, width, Math.max(0, zeroY - y));
|
||||
}
|
||||
g.globalAlpha = 0.12;
|
||||
g.fillStyle = '#fff';
|
||||
g.fillRect(x, y, width, 2);
|
||||
g.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawHold(g, x, width, value, plotY, plotH, range) {
|
||||
if (!Number.isFinite(value)) return;
|
||||
if (value <= (range.bottom + 0.05)) return;
|
||||
const yHold = mapDbToY(value, range, plotY, plotH);
|
||||
g.fillStyle = '#fff';
|
||||
g.fillRect(x, yHold - 1, width, 2);
|
||||
}
|
||||
|
||||
function drawPeakOverlay(g, x, width, value, plotY, plotH, range) {
|
||||
if (!Number.isFinite(value)) return;
|
||||
const y = mapDbToY(value, range, plotY, plotH);
|
||||
g.save();
|
||||
g.strokeStyle = FRAME_COLOR;
|
||||
g.lineWidth = 1.4;
|
||||
g.beginPath();
|
||||
g.moveTo(x, y);
|
||||
g.lineTo(x + width, y);
|
||||
g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function redrawPlotEdges(g, x, y, w, h, CONFIG) {
|
||||
const gutterL = Number.isFinite(CONFIG?.AXIS_GUTTER_LEFT)
|
||||
? Math.max(8, Number(CONFIG.AXIS_GUTTER_LEFT))
|
||||
: 14;
|
||||
g.strokeStyle = FRAME_COLOR;
|
||||
g.lineWidth = 2;
|
||||
g.beginPath();
|
||||
g.moveTo(x - gutterL, y);
|
||||
g.lineTo(x + w, y);
|
||||
g.stroke();
|
||||
g.beginPath();
|
||||
g.moveTo(x - gutterL, y + h);
|
||||
g.lineTo(x + w, y + h);
|
||||
g.stroke();
|
||||
}
|
||||
|
||||
async function drawMeter(env, state, plotX, plotY, plotW, plotH) {
|
||||
const meterRect = { x: plotX + plotW + METER_GAP, y: plotY, w: METER_WIDTH, h: plotH };
|
||||
const { ctx: g, config: CONFIG, meters } = env;
|
||||
drawCachedStaticLayer(
|
||||
state,
|
||||
g,
|
||||
'rta-meter-shell',
|
||||
`${meterRect.w}x${meterRect.h}`,
|
||||
{ x: meterRect.x - STATIC_STROKE_PAD, y: meterRect.y - STATIC_STROKE_PAD, w: meterRect.w + STATIC_STROKE_PAD * 2, h: meterRect.h + STATIC_STROKE_PAD * 2 },
|
||||
(lg) => {
|
||||
lg.fillStyle = PANEL_BG;
|
||||
lg.fillRect(STATIC_STROKE_PAD, STATIC_STROKE_PAD, meterRect.w, meterRect.h);
|
||||
lg.strokeStyle = FRAME_COLOR;
|
||||
lg.lineWidth = 2;
|
||||
lg.strokeRect(STATIC_STROKE_PAD + 0.5, STATIC_STROKE_PAD + 0.5, meterRect.w - 1, meterRect.h - 1);
|
||||
});
|
||||
const slotList = env.slots ? env.slots('realtime') : null;
|
||||
const active = (slotList && slotList[0]) || 'vu';
|
||||
if (active === 'none') return;
|
||||
const shrink = Math.max(0, METER_SLOT_SHRINK);
|
||||
const innerHeight = Math.max(40, meterRect.h - METER_PAD_TOP - METER_PAD_BOTTOM - shrink - METER_EXTRA_BOTTOM_PAD);
|
||||
const innerOffset = shrink / 2;
|
||||
const innerRect = {
|
||||
x: meterRect.x,
|
||||
y: meterRect.y + METER_PAD_TOP + innerOffset,
|
||||
w: meterRect.w,
|
||||
h: innerHeight,
|
||||
};
|
||||
g.save();
|
||||
g.lineWidth = 2;
|
||||
g.beginPath();
|
||||
g.rect(meterRect.x + g.lineWidth / 2, meterRect.y + g.lineWidth / 2, meterRect.w - g.lineWidth, meterRect.h - g.lineWidth);
|
||||
g.clip();
|
||||
await meters.draw(g, innerRect, active, CONFIG);
|
||||
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);
|
||||
return x0 + Math.max(0, Math.min(1, frac)) * w;
|
||||
}
|
||||
|
||||
function mapDbToY(value, range, plotY, plotH) {
|
||||
const top = range.top;
|
||||
const bottom = range.bottom;
|
||||
const clamped = Math.max(bottom, Math.min(top, value));
|
||||
const t = (clamped - bottom) / (top - bottom || 1);
|
||||
return plotY + plotH - t * plotH;
|
||||
}
|
||||
|
||||
function getDbRange() {
|
||||
const top = 9;
|
||||
const bottom = -36;
|
||||
const ticks = [];
|
||||
for (let v = bottom; v <= top + 1e-6; v += 9) {
|
||||
ticks.push(v);
|
||||
}
|
||||
if (!ticks.includes(top)) ticks.push(top);
|
||||
return { top, bottom, ticks };
|
||||
}
|
||||
|
||||
function getFreqBounds(CONFIG, nyq, rtaData) {
|
||||
let min = CONFIG.RTA_FREQ_RANGE === 'lf' ? 5 : 20;
|
||||
let max = CONFIG.RTA_FREQ_RANGE === 'lf' ? 5000 : 20000;
|
||||
if (rtaData) {
|
||||
if (Number.isFinite(rtaData.freqMin)) min = Math.max(min, rtaData.freqMin);
|
||||
if (Number.isFinite(rtaData.freqMax)) max = Math.min(max, rtaData.freqMax);
|
||||
}
|
||||
return { min, max: Math.min(max, nyq) };
|
||||
}
|
||||
|
||||
function buildFixedRtwBands(bpoMode, freqBounds, nyq, overrideCenters) {
|
||||
const centers = isVectorLike(overrideCenters) && overrideCenters.length
|
||||
? overrideCenters.slice()
|
||||
: getRtwCenters(bpoMode);
|
||||
if (!centers.length) return [];
|
||||
const n = resolveRtwBpoValue(bpoMode);
|
||||
const factor = Math.pow(2, 1 / (2 * n));
|
||||
const minF = freqBounds.min;
|
||||
const maxF = Math.min(freqBounds.max, nyq);
|
||||
const bands = [];
|
||||
for (const fc of centers) {
|
||||
if (!Number.isFinite(fc)) continue;
|
||||
if (fc < minF || fc > maxF) continue;
|
||||
const fLo = Math.max(fc / factor, minF);
|
||||
const fHi = Math.min(fc * factor, maxF);
|
||||
if (fHi <= fLo) continue;
|
||||
bands.push({ center: fc, fLo, fHi });
|
||||
}
|
||||
return bands;
|
||||
}
|
||||
|
||||
function selectLocalRtwPacket(packet, bpoMode) {
|
||||
if (!packet || !isVectorLike(packet.centers) || !packet.centers.length) return packet;
|
||||
const desiredCenters = getRtwCenters(bpoMode);
|
||||
if (!desiredCenters.length) return packet;
|
||||
if (packet.centers.length === desiredCenters.length) return packet;
|
||||
|
||||
const keyFor = (value) => Number(value).toFixed(1);
|
||||
const indexByCenter = new Map();
|
||||
for (let i = 0; i < packet.centers.length; i++) {
|
||||
indexByCenter.set(keyFor(packet.centers[i]), i);
|
||||
}
|
||||
|
||||
const pick = [];
|
||||
const selectedCenters = [];
|
||||
const used = new Set();
|
||||
for (const center of desiredCenters) {
|
||||
let idx = indexByCenter.get(keyFor(center));
|
||||
if (!Number.isInteger(idx)) {
|
||||
let bestIdx = -1;
|
||||
let bestErr = Infinity;
|
||||
for (let i = 0; i < packet.centers.length; i++) {
|
||||
if (used.has(i)) continue;
|
||||
const candidate = Number(packet.centers[i]);
|
||||
if (!Number.isFinite(candidate)) continue;
|
||||
const relErr = Math.abs(candidate - center) / Math.max(center, 1);
|
||||
if (relErr < bestErr) {
|
||||
bestErr = relErr;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
if (bestIdx >= 0 && bestErr <= 0.04) {
|
||||
idx = bestIdx;
|
||||
}
|
||||
}
|
||||
if (Number.isInteger(idx) && !used.has(idx)) {
|
||||
used.add(idx);
|
||||
pick.push(idx);
|
||||
selectedCenters.push(Number(packet.centers[idx]));
|
||||
}
|
||||
}
|
||||
if (!pick.length) return packet;
|
||||
|
||||
const pickVector = (src) => {
|
||||
if (!isVectorLike(src)) return [];
|
||||
return pick.map((idx) => Number(src[idx]));
|
||||
};
|
||||
|
||||
return {
|
||||
...packet,
|
||||
centers: selectedCenters,
|
||||
bands_avg: pickVector(packet.bands_avg || packet.bands),
|
||||
bands_peak: pickVector(packet.bands_peak),
|
||||
bands: pickVector(packet.bands || packet.bands_avg),
|
||||
};
|
||||
}
|
||||
|
||||
function mapIirLevels(packet, CONFIG, range, expectedLen) {
|
||||
if (!packet || packet.engine !== 'iir') return null;
|
||||
const bottom = (range && Number.isFinite(range.bottom)) ? range.bottom : FLOOR_DB;
|
||||
const top = (range && Number.isFinite(range.top)) ? range.top : 9;
|
||||
const gain = Number(CONFIG.RTA_DISPLAY_GAIN_IIR_DB ?? 0) || 0;
|
||||
const ensureLen = (arr) => (isVectorLike(arr) && (!expectedLen || arr.length === expectedLen)) ? arr : null;
|
||||
const avgRaw = ensureLen(packet.bands_avg || packet.bands);
|
||||
const peakRaw = ensureLen(packet.bands_peak);
|
||||
const requestedMode = (CONFIG.RTA_BALLISTICS_MODE === 'peak' || CONFIG.RTA_BALLISTICS_MODE === 'both') ? CONFIG.RTA_BALLISTICS_MODE : 'average';
|
||||
|
||||
let modeResolved = requestedMode;
|
||||
if (modeResolved === 'both' && !(avgRaw && peakRaw)) {
|
||||
modeResolved = avgRaw ? 'average' : (peakRaw ? 'peak' : 'average');
|
||||
}
|
||||
if (modeResolved === 'peak' && !peakRaw) {
|
||||
modeResolved = avgRaw ? 'average' : null;
|
||||
}
|
||||
if (modeResolved === 'average' && !avgRaw) {
|
||||
modeResolved = peakRaw ? 'peak' : null;
|
||||
}
|
||||
if (!modeResolved) return null;
|
||||
|
||||
const sourcePrimary = modeResolved === 'peak' ? peakRaw : avgRaw;
|
||||
if (!sourcePrimary) return null;
|
||||
|
||||
const convert = (src) => {
|
||||
const out = new Float32Array(src.length);
|
||||
for (let i = 0; i < src.length; i++) {
|
||||
const v = Number(src[i]);
|
||||
const val = Number.isFinite(v) ? v : bottom;
|
||||
out[i] = clamp(val + gain, bottom, top);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const primary = convert(sourcePrimary);
|
||||
let overlay = null;
|
||||
let modeForRenderer = modeResolved;
|
||||
|
||||
if (requestedMode === 'both' && avgRaw && peakRaw) {
|
||||
overlay = convert(peakRaw);
|
||||
modeForRenderer = 'both';
|
||||
}
|
||||
if (modeForRenderer === 'both' && !overlay) {
|
||||
modeForRenderer = modeResolved === 'peak' ? 'peak' : 'average';
|
||||
}
|
||||
|
||||
return {
|
||||
primary,
|
||||
overlay: (modeForRenderer === 'both') ? overlay : null,
|
||||
mode: modeForRenderer,
|
||||
};
|
||||
}
|
||||
|
||||
function mapNativeFftLevels(packet, CONFIG, range, expectedLen) {
|
||||
if (!packet || packet.engine !== 'fft') return null;
|
||||
const bottom = (range && Number.isFinite(range.bottom)) ? range.bottom : FLOOR_DB;
|
||||
const top = (range && Number.isFinite(range.top)) ? range.top : 9;
|
||||
const gain = Number(CONFIG.RTA_DISPLAY_GAIN_FFT_DB ?? 0) || 0;
|
||||
const src = isVectorLike(packet.bands_avg || packet.bands)
|
||||
? (packet.bands_avg || packet.bands)
|
||||
: null;
|
||||
if (!src || (expectedLen && src.length !== expectedLen)) return null;
|
||||
const out = new Float32Array(src.length);
|
||||
for (let i = 0; i < src.length; i++) {
|
||||
const v = Number(src[i]);
|
||||
out[i] = clamp((Number.isFinite(v) ? v : bottom) + gain, bottom, top);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isVectorLike(v) {
|
||||
return !!(v && (Array.isArray(v) || ArrayBuffer.isView(v)));
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.resetRealTimeAnalyzerPeakHold = function resetRealTimeAnalyzerPeakHold() {
|
||||
const state = window.__RTA_STATE__;
|
||||
if (!state || !state.peakHold || !state.peakHold.length) return;
|
||||
const floor = (state.currentRange && state.currentRange.bottom) || FLOOR_DB;
|
||||
state.peakHold = new Float32Array(state.peakHold.length);
|
||||
state.peakHold.fill(floor);
|
||||
state.displayLevels = new Float32Array(state.peakHold.length);
|
||||
state.displayLevels.fill(floor);
|
||||
state.lastPeakTime = new Array(state.peakHold.length).fill(performance.now());
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(val, min, max) {
|
||||
return Math.min(max, Math.max(min, val));
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
// views/recorder.js — Recorder-View im XY-Layout-Stil (links Recorder-Panel, rechts 3 Slots)
|
||||
import { drawCachedStaticLayer } from './static_layer.js';
|
||||
|
||||
export const id = 'recorder';
|
||||
|
||||
const FRAME = { left: 0, top: 70, right: 0, bottom: 0 };
|
||||
const METER_SLOTS = 3;
|
||||
const METER_GAP = 8;
|
||||
const SLOT_GAP = 12;
|
||||
const METER_WIDTH = 420;
|
||||
const METER_SLOT_SHRINK = 24;
|
||||
const METER_PAD_TOP = 1;
|
||||
const METER_PAD_BOTTOM = -7;
|
||||
const METER_EXTRA_BOTTOM_PAD = 6;
|
||||
const PANEL_BG = 'rgba(5,5,11,0.75)';
|
||||
const SLOT_BG = 'rgba(10,12,18,0.85)';
|
||||
const BORDER = 'rgba(120, 170, 220, 0.7)';
|
||||
|
||||
function isExternalClient() {
|
||||
const host = String(globalThis?.location?.hostname || '').trim().toLowerCase();
|
||||
return !!host && host !== 'localhost' && host !== '127.0.0.1' && host !== '::1' && host !== '[::1]';
|
||||
}
|
||||
|
||||
export function init() { return { staticLayers: new Map(), hudLayoutSig: '' }; }
|
||||
export function resize() {}
|
||||
export function destroy(state) {
|
||||
if (state) state.staticLayers = null;
|
||||
}
|
||||
|
||||
export async function render(env, state) {
|
||||
if (!env || !env.ctx) return;
|
||||
const { ctx: g, rect, recorder, meters, config: CONFIG } = env;
|
||||
const slotsRaw = env.slots?.(id) || [];
|
||||
const slots = slotsRaw.filter((v) => v && v !== 'none');
|
||||
const layout = computeLayout(rect, slots.length);
|
||||
|
||||
drawCachedPlotShell(state, g, layout.plot, CONFIG);
|
||||
drawCachedRecorderPanel(state, g, layout.scope);
|
||||
if (CONFIG.RECORD_SHOW_RECORDER_AB === true) {
|
||||
drawRecorderAbIndicator(g, layout.scope, recorder);
|
||||
}
|
||||
if (slots.length) {
|
||||
await drawMeterPanel(state, g, layout.meter, slots, meters, CONFIG);
|
||||
}
|
||||
positionRecorderHud(state, env.recorderDom, layout.scope);
|
||||
}
|
||||
|
||||
function computeLayout(rect, slotCount = METER_SLOTS) {
|
||||
const plotX = 0;
|
||||
const plotY = FRAME.top;
|
||||
const innerWidth = Math.max(200, rect.w - plotX - FRAME.right);
|
||||
const minPlotW = 200;
|
||||
|
||||
const n = Math.max(0, Math.min(METER_SLOTS, slotCount | 0));
|
||||
const panelSlotsWidth = computePanelSlotsWidth(innerWidth, n);
|
||||
let meterW = n > 0 ? Math.max(panelSlotsWidth, (n === 1 ? 160 : (n === 2 ? 280 : METER_WIDTH))) : 0;
|
||||
let plotW = innerWidth - (n > 0 ? (meterW + METER_GAP) : 0);
|
||||
|
||||
if (plotW < minPlotW) {
|
||||
plotW = minPlotW;
|
||||
meterW = n > 0 ? (innerWidth - plotW - METER_GAP) : 0;
|
||||
}
|
||||
|
||||
if (n > 0) meterW = Math.max(meterW, panelSlotsWidth);
|
||||
if (plotW < 80) plotW = 80;
|
||||
|
||||
const plotH = Math.max(180, rect.h - FRAME.top - FRAME.bottom);
|
||||
const plot = { x: plotX, y: plotY, w: plotW, h: plotH };
|
||||
const scope = computeScopeBox(plot);
|
||||
|
||||
const meter = {
|
||||
x: plot.x + plot.w + (n > 0 ? METER_GAP : 0),
|
||||
y: plot.y,
|
||||
w: meterW,
|
||||
h: plotH,
|
||||
};
|
||||
|
||||
return { plot, scope, meter };
|
||||
}
|
||||
|
||||
function computePanelSlotWidth(canvasWidth, slotCount = METER_SLOTS) {
|
||||
const n = Math.max(1, Math.min(METER_SLOTS, slotCount | 0));
|
||||
const avail = Math.max(1, canvasWidth);
|
||||
const totalGap = (n - 1) * SLOT_GAP;
|
||||
const usable = avail - totalGap;
|
||||
return Math.max(1, Math.floor(usable / n));
|
||||
}
|
||||
|
||||
function computePanelSlotsWidth(innerWidth, slotCount = METER_SLOTS) {
|
||||
const panelSlotWidth = 100;
|
||||
const n = Math.max(0, Math.min(METER_SLOTS, slotCount | 0));
|
||||
return n > 0 ? (panelSlotWidth * n + (n - 1) * SLOT_GAP) : 0;
|
||||
}
|
||||
|
||||
function computeScopeBox(plot) {
|
||||
// Maximiere die Recorder-Box im Plot (kein zusätzlicher Innenabstand)
|
||||
const padding = 0;
|
||||
const w = Math.max(1, plot.w - padding);
|
||||
const h = Math.max(1, plot.h - padding);
|
||||
const x = Math.round(plot.x + padding / 2);
|
||||
const y = Math.round(plot.y + padding / 2);
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
return { x, y, w, h, cx, cy };
|
||||
}
|
||||
|
||||
function drawCachedPlotShell(state, g, plot, CONFIG = {}) {
|
||||
const gutterL = Number.isFinite(CONFIG?.AXIS_GUTTER_LEFT)
|
||||
? Math.max(8, Number(CONFIG.AXIS_GUTTER_LEFT))
|
||||
: 14;
|
||||
drawCachedStaticLayer(state, g, 'recorder-plot-shell', `${gutterL}`, plot, (cg) => {
|
||||
cg.fillStyle = PANEL_BG;
|
||||
cg.fillRect(0, 0, plot.w, plot.h);
|
||||
cg.save();
|
||||
cg.translate(-plot.x, -plot.y);
|
||||
cg.strokeStyle = '#00e7ff';
|
||||
cg.lineWidth = 2;
|
||||
cg.beginPath();
|
||||
cg.moveTo(plot.x - gutterL, plot.y);
|
||||
cg.lineTo(plot.x + plot.w, plot.y);
|
||||
cg.stroke();
|
||||
cg.beginPath();
|
||||
cg.moveTo(plot.x - gutterL, plot.y + plot.h);
|
||||
cg.lineTo(plot.x + plot.w, plot.y + plot.h);
|
||||
cg.stroke();
|
||||
cg.beginPath();
|
||||
cg.moveTo(plot.x, plot.y);
|
||||
cg.lineTo(plot.x, plot.y + plot.h);
|
||||
cg.stroke();
|
||||
cg.beginPath();
|
||||
cg.moveTo(plot.x + plot.w, plot.y);
|
||||
cg.lineTo(plot.x + plot.w, plot.y + plot.h);
|
||||
cg.stroke();
|
||||
cg.restore();
|
||||
});
|
||||
}
|
||||
|
||||
function drawCachedRecorderPanel(state, g, box) {
|
||||
drawCachedStaticLayer(state, g, 'recorder-box-shell', 'frame', box, (cg) => {
|
||||
cg.fillStyle = 'rgba(7, 8, 15, 0.7)';
|
||||
cg.fillRect(0, 0, box.w, box.h);
|
||||
cg.strokeStyle = '#00e7ff';
|
||||
cg.lineWidth = 2;
|
||||
cg.strokeRect(0.5, 0.5, box.w - 1, box.h - 1);
|
||||
});
|
||||
}
|
||||
|
||||
async function drawMeterPanel(state, g, area, slots, meters, CONFIG) {
|
||||
const slotCount = Array.isArray(slots) ? slots.length : 0;
|
||||
if (!slotCount || !area || area.w <= 0 || area.h <= 0) return;
|
||||
drawCachedStaticLayer(state, g, 'recorder-meter-shell', `${slotCount}|${CONFIG?.PANEL_DIVIDERS_ENABLED ? 1 : 0}`, area, (cg) => {
|
||||
cg.fillStyle = PANEL_BG;
|
||||
cg.fillRect(0, 0, area.w, area.h);
|
||||
cg.strokeStyle = '#00e7ff';
|
||||
cg.lineWidth = 2;
|
||||
cg.strokeRect(0.5, 0.5, area.w - 1, area.h - 1);
|
||||
|
||||
if (CONFIG?.PANEL_DIVIDERS_ENABLED) {
|
||||
const gap = SLOT_GAP;
|
||||
const slotW = computePanelSlotWidth(area.w, slotCount);
|
||||
const totalSlotW = slotW * slotCount + gap * (slotCount - 1);
|
||||
const startX = Math.max(0, Math.floor((area.w - totalSlotW) / 2));
|
||||
const slotTopPadding = METER_PAD_TOP + METER_SLOT_SHRINK / 2;
|
||||
const slotBottomPadding = METER_PAD_BOTTOM + METER_EXTRA_BOTTOM_PAD + METER_SLOT_SHRINK / 2;
|
||||
for (let i = 1; i < slotCount; i++) {
|
||||
const rectX = startX + i * (slotW + gap);
|
||||
const dividerX = rectX - gap / 2;
|
||||
cg.save();
|
||||
cg.strokeStyle = 'rgba(0,231,255,0.4)';
|
||||
cg.lineWidth = 1;
|
||||
cg.setLineDash([4, 3]);
|
||||
cg.beginPath();
|
||||
cg.moveTo(dividerX, slotTopPadding - 6);
|
||||
cg.lineTo(dividerX, area.h - slotBottomPadding + 6);
|
||||
cg.stroke();
|
||||
cg.restore();
|
||||
}
|
||||
}
|
||||
});
|
||||
g.save();
|
||||
|
||||
const gap = SLOT_GAP;
|
||||
const slotW = computePanelSlotWidth(area.w, slotCount);
|
||||
const totalSlotW = slotW * slotCount + gap * (slotCount - 1);
|
||||
const startX = area.x + Math.max(0, Math.floor((area.w - totalSlotW) / 2));
|
||||
const slotTopPadding = METER_PAD_TOP + METER_SLOT_SHRINK / 2;
|
||||
const slotBottomPadding = METER_PAD_BOTTOM + METER_EXTRA_BOTTOM_PAD + METER_SLOT_SHRINK / 2;
|
||||
const slotH = Math.max(40, area.h - slotTopPadding - slotBottomPadding);
|
||||
|
||||
for (let i = 0; i < slotCount; i++) {
|
||||
const x = startX + i * (slotW + gap);
|
||||
const rect = { x, y: area.y + slotTopPadding, w: slotW, h: slotH };
|
||||
const slotId = slots[i];
|
||||
if (slotId) {
|
||||
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 innerRect = {
|
||||
x: rect.x,
|
||||
y: rect.y + METER_PAD_TOP + innerOffset,
|
||||
w: rect.w,
|
||||
h: innerHeight,
|
||||
};
|
||||
try { await meters.draw(g, innerRect, slotId, CONFIG); }
|
||||
catch (e) { /* ignore draw errors to keep view alive */ }
|
||||
}
|
||||
}
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function positionRecorderHud(state, dom = {}, scope) {
|
||||
if (!dom || !scope) return;
|
||||
const { hud, label, start, stop, auto, list, timer } = dom;
|
||||
if (!hud) return;
|
||||
const centerX = scope.x + scope.w / 2;
|
||||
const topY = scope.y + 8;
|
||||
// Buttons mittig im unteren Drittel
|
||||
const baseY = scope.y + scope.h - 24 - Math.max(start?.offsetHeight || 0, stop?.offsetHeight || 0, auto?.offsetHeight || 0);
|
||||
const gap = 20;
|
||||
const startW = (start?.offsetWidth || 120);
|
||||
const stopW = (stop?.offsetWidth || 120);
|
||||
const autoW = (auto?.offsetWidth || 120);
|
||||
const totalW = startW + gap + stopW + gap + autoW;
|
||||
const startX = centerX - totalW / 2;
|
||||
|
||||
const listTop = topY + (label?.offsetHeight || 30) + 8;
|
||||
const listBottomReserve = 28;
|
||||
const listMaxHeight = list
|
||||
? Math.max(72, ((timer ? (baseY - (timer.offsetHeight || 18) - 10) : baseY) - listTop - listBottomReserve))
|
||||
: 0;
|
||||
const sig = [
|
||||
scope.x, scope.y, scope.w, scope.h,
|
||||
label?.offsetWidth || 0, label?.offsetHeight || 0,
|
||||
start?.offsetWidth || 0, start?.offsetHeight || 0,
|
||||
stop?.offsetWidth || 0, stop?.offsetHeight || 0,
|
||||
auto?.offsetWidth || 0, auto?.offsetHeight || 0,
|
||||
timer?.offsetWidth || 0, timer?.offsetHeight || 0,
|
||||
listMaxHeight,
|
||||
].join('|');
|
||||
if (state?.hudLayoutSig === sig) return;
|
||||
if (state) state.hudLayoutSig = sig;
|
||||
|
||||
if (label) {
|
||||
label.style.left = `${centerX - (label.offsetWidth || 0) / 2}px`;
|
||||
label.style.top = `${topY}px`;
|
||||
}
|
||||
if (list) {
|
||||
const y = topY + (label?.offsetHeight || 30) + 8;
|
||||
list.classList.toggle('rec-list--external', isExternalClient());
|
||||
list.style.width = `${Math.max(200, scope.w - 45)}px`;
|
||||
list.style.left = `${scope.x + 12}px`;
|
||||
list.style.top = `${y}px`;
|
||||
list.style.maxHeight = `${listMaxHeight}px`;
|
||||
}
|
||||
if (start) {
|
||||
start.style.left = `${startX}px`;
|
||||
start.style.top = `${baseY}px`;
|
||||
}
|
||||
if (stop) {
|
||||
stop.style.left = `${startX + startW + gap}px`;
|
||||
stop.style.top = `${baseY}px`;
|
||||
}
|
||||
if (auto) {
|
||||
auto.style.left = `${startX + startW + gap + stopW + gap}px`;
|
||||
auto.style.top = `${baseY}px`;
|
||||
}
|
||||
if (timer) {
|
||||
const y = baseY - (timer.offsetHeight || 18) - 10;
|
||||
timer.style.left = `${centerX - (timer.offsetWidth || 80) / 2}px`;
|
||||
timer.style.top = `${y}px`;
|
||||
}
|
||||
}
|
||||
|
||||
function drawRecorderAbIndicator(g, box, recorder = {}) {
|
||||
const active = recorder.activeRecorderSlot === 'A' || recorder.activeRecorderSlot === 'B'
|
||||
? recorder.activeRecorderSlot
|
||||
: null;
|
||||
const processing = new Set(Array.isArray(recorder.processingRecorderSlots) ? recorder.processingRecorderSlots : []);
|
||||
const next = recorder.nextRecorderSlot === 'B' ? 'B' : 'A';
|
||||
const colorFor = (slot) => {
|
||||
if (active === slot) return '#ff3b3b';
|
||||
if (!processing.has(slot) && (active || next === slot)) return '#34d399';
|
||||
if (processing.has(slot)) return '#8fd3d4';
|
||||
return 'rgba(143, 211, 212, 0.45)';
|
||||
};
|
||||
|
||||
const top = box.y + 12;
|
||||
const left = box.x + 14;
|
||||
const gap = 18;
|
||||
|
||||
g.save();
|
||||
g.font = 'bold 18px ui-monospace, monospace';
|
||||
g.textAlign = 'left';
|
||||
g.textBaseline = 'top';
|
||||
g.fillStyle = colorFor('A');
|
||||
g.fillText('A', left, top);
|
||||
g.fillStyle = colorFor('B');
|
||||
g.fillText('B', left + gap, top);
|
||||
g.restore();
|
||||
}
|
||||
@@ -0,0 +1,836 @@
|
||||
// views/spectrogram.js — FFT Spectrogram View with optional Worker renderer
|
||||
|
||||
import { DEFAULT_TOP_INSET, FRAME_COLOR, PANEL_BG } from '../core/theme.js';
|
||||
|
||||
const PLOT = { left: 35, top: DEFAULT_TOP_INSET, right: 0, bottom: 0 };
|
||||
const METER_WIDTH = 140;
|
||||
const METER_GAP = 8;
|
||||
const METER_PAD_TOP = 15;
|
||||
const METER_PAD_BOTTOM = 5;
|
||||
const METER_SLOT_SHRINK = 24;
|
||||
const METER_EXTRA_BOTTOM_PAD = 6;
|
||||
const MIN_ROW_COUNT = 16;
|
||||
const RTA_X_TICKS = [20, 31.5, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
|
||||
const SMOOTHING_MAX = 0.2;
|
||||
const TIME_GRID_SPACING = 100;
|
||||
const SPECTROGRAM_ID = 'spectrogram';
|
||||
const SPECTRO_RENDER_SCALE = 0.85;
|
||||
const SUPPORTS_WORKER = typeof window !== 'undefined'
|
||||
&& typeof Worker !== 'undefined'
|
||||
&& typeof HTMLCanvasElement !== 'undefined'
|
||||
&& !!HTMLCanvasElement.prototype.transferControlToOffscreen;
|
||||
|
||||
// Pre-calculated constants
|
||||
const LOG10 = Math.log(10);
|
||||
const DB_TO_LINEAR_FACTOR = LOG10 / 10;
|
||||
const SPECTRO_COLOR_STOPS = [
|
||||
{ t: 0.0, color: [0, 0, 0] },
|
||||
{ t: 0.25, color: [0, 0, 80] },
|
||||
{ t: 0.5, color: [0, 135, 140] },
|
||||
{ t: 0.75, color: [220, 220, 0] },
|
||||
{ t: 1.0, color: [255, 255, 255] },
|
||||
];
|
||||
|
||||
export const id = SPECTROGRAM_ID;
|
||||
|
||||
export function init() {
|
||||
return {
|
||||
useWorker: false, // wird im Render je nach Config gesetzt
|
||||
worker: null,
|
||||
workerReady: false,
|
||||
offscreenWidth: 0,
|
||||
offscreenHeight: 0,
|
||||
spectroCanvasEl: null,
|
||||
spectroCanvasTransferred: false,
|
||||
lastTopDb: null,
|
||||
lastBottomDb: null,
|
||||
lastGamma: null,
|
||||
|
||||
width: 0,
|
||||
height: 0,
|
||||
freqBounds: null,
|
||||
freqMap: [],
|
||||
freqMapKey: '',
|
||||
sampleRate: 48000,
|
||||
binCount: 0,
|
||||
|
||||
history: null,
|
||||
writeIndex: 0,
|
||||
imgData: null,
|
||||
pixelData: null,
|
||||
scrollAccumulator: 0,
|
||||
|
||||
configSig: '',
|
||||
gridCache: null,
|
||||
_initialized: false,
|
||||
_lastScrollTs: 0,
|
||||
lastPhoenixSpectroSeq: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function resize() {}
|
||||
|
||||
export function destroy(state) {
|
||||
teardownWorker(state);
|
||||
state.history = null;
|
||||
state.imgData = null;
|
||||
state.pixelData = null;
|
||||
state.freqMap = [];
|
||||
state.freqMapKey = '';
|
||||
state.gridCache = null;
|
||||
removeSpectroCanvasElement(state);
|
||||
|
||||
if (state.spectroCanvasEl && !state.spectroCanvasTransferred) {
|
||||
state.spectroCanvasEl.width = 0;
|
||||
state.spectroCanvasEl.height = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export async function render(env, state) {
|
||||
if (!state._initialized) {
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
state._initialized = true;
|
||||
}
|
||||
// Config-Flag schaltet Worker ein (default true), wenn Offscreen verfügbar
|
||||
state.useWorker = SUPPORTS_WORKER && !!env.config?.SPECTRO_USE_WORKER;
|
||||
state._dbgNextLog = state._dbgNextLog || 0;
|
||||
|
||||
const { ctx: g, rect, config: CONFIG, audio, meters } = env;
|
||||
const analyzer = audio.getAnalyser?.();
|
||||
let freqBuf = audio.getFreqBuffer?.();
|
||||
const phoenixSpectroSeq = Number(env.audio?.phoenixSpectroSeq || 0);
|
||||
const hasPhoenixSpectro = Number.isFinite(phoenixSpectroSeq) && phoenixSpectroSeq > 0;
|
||||
if (!freqBuf && analyzer) {
|
||||
freqBuf = new Float32Array(analyzer.frequencyBinCount || 2048);
|
||||
}
|
||||
|
||||
const plotX = PLOT.left;
|
||||
const topInset = Number.isFinite(Number(env?.topInset)) ? Number(env.topInset) : PLOT.top;
|
||||
const plotY = topInset;
|
||||
const canvasOffsetX = Number.isFinite(Number(env.embeddedOffsetX)) ? Number(env.embeddedOffsetX) : 0;
|
||||
const canvasOffsetY = Number.isFinite(Number(env.embeddedOffsetY)) ? Number(env.embeddedOffsetY) : 0;
|
||||
const slotList = env.slots ? env.slots(SPECTROGRAM_ID) : null;
|
||||
const activeMeter = (slotList && slotList[0]) || 'ppm-din';
|
||||
const showMeter = activeMeter !== 'none';
|
||||
const plotW = Math.max(16, Math.floor(rect.w - PLOT.left - PLOT.right - (showMeter ? (METER_WIDTH + METER_GAP) : 0)));
|
||||
const plotH = Math.max(MIN_ROW_COUNT, Math.floor(rect.h - plotY - PLOT.bottom));
|
||||
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||
const renderScale = Math.max(0.5, Math.min(1, SPECTRO_RENDER_SCALE));
|
||||
const plotWpx = Math.max(1, Math.floor(plotW * dpr * renderScale));
|
||||
const plotHpx = Math.max(1, Math.floor(plotH * dpr * renderScale));
|
||||
const range = getDbRange(CONFIG);
|
||||
const gamma = Math.max(0.3, Math.min(1.2, Number(CONFIG.SPECTRO_GAMMA ?? 0.9)));
|
||||
state._dbgEnabled = !!CONFIG.SPECTRO_DEBUG;
|
||||
|
||||
layoutSpectroCanvas(state, canvasOffsetX + plotX, canvasOffsetY + plotY, plotW, plotH, plotWpx, plotHpx);
|
||||
drawSpectrogramBackground(g, plotX, plotY, plotW, plotH);
|
||||
|
||||
if (!analyzer || !freqBuf || !freqBuf.length) {
|
||||
drawSpectrogramGrid(g, state, plotX, plotY, plotW, plotH);
|
||||
drawSpectrogramLegendHud(range, gamma);
|
||||
if (showMeter) {
|
||||
await drawMeterPanel(g, plotX, plotY, plotW, plotH, CONFIG, meters, activeMeter);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const width = plotW;
|
||||
const height = Math.max(MIN_ROW_COUNT, plotH);
|
||||
|
||||
const dimensionsChanged = state.width !== width || state.height !== height;
|
||||
state.width = width;
|
||||
state.height = height;
|
||||
|
||||
const fs = analyzer?.context?.sampleRate || audio.sampleRate || Math.max(48000, (audio.nyq || 24000) * 2);
|
||||
const nyq = fs / 2;
|
||||
const freqBounds = getFreqBounds(CONFIG, nyq);
|
||||
const boundsChanged = !state.freqBounds ||
|
||||
state.freqBounds.min !== freqBounds.min ||
|
||||
state.freqBounds.max !== freqBounds.max;
|
||||
|
||||
if (boundsChanged) {
|
||||
state.freqBounds = { ...freqBounds };
|
||||
}
|
||||
|
||||
state.sampleRate = fs;
|
||||
|
||||
if (dimensionsChanged || boundsChanged || state.binCount !== freqBuf.length) {
|
||||
ensureFreqMap(state, height, freqBounds, freqBuf.length, fs);
|
||||
}
|
||||
|
||||
if (state.useWorker) {
|
||||
setupWorker(state, plotWpx, plotHpx, range, gamma, freqBounds);
|
||||
} else {
|
||||
ensureFallbackBuffers(state, width, height, range.bottom, g);
|
||||
}
|
||||
|
||||
const scrollRate = resolveScrollRate(CONFIG);
|
||||
let spectroDirty = true;
|
||||
if (hasPhoenixSpectro) {
|
||||
spectroDirty = phoenixSpectroSeq !== (state.lastPhoenixSpectroSeq || 0);
|
||||
}
|
||||
|
||||
let columnsToEmit = 0;
|
||||
if (hasPhoenixSpectro) {
|
||||
if (spectroDirty) {
|
||||
state.scrollAccumulator = (state.scrollAccumulator || 0) + scrollRate;
|
||||
while (state.scrollAccumulator >= 1) {
|
||||
columnsToEmit += 1;
|
||||
state.scrollAccumulator -= 1;
|
||||
}
|
||||
if (columnsToEmit < 1) {
|
||||
columnsToEmit = 1;
|
||||
state.scrollAccumulator = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const nowTs = performance.now();
|
||||
const dtMs = Math.max(1, nowTs - (state._lastScrollTs || nowTs));
|
||||
state._lastScrollTs = nowTs;
|
||||
const targetFrameMs = 1000 / 60;
|
||||
const frameUnits = dtMs / targetFrameMs;
|
||||
state.scrollAccumulator = (state.scrollAccumulator || 0) + scrollRate * frameUnits;
|
||||
while (state.scrollAccumulator >= 1) {
|
||||
columnsToEmit += 1;
|
||||
state.scrollAccumulator -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (spectroDirty) {
|
||||
analyzer.smoothingTimeConstant = Math.min(SMOOTHING_MAX, Math.max(0, analyzer.smoothingTimeConstant ?? 0));
|
||||
analyzer.getFloatFrequencyData(freqBuf);
|
||||
if (hasPhoenixSpectro) {
|
||||
state.lastPhoenixSpectroSeq = phoenixSpectroSeq;
|
||||
}
|
||||
}
|
||||
|
||||
if (columnsToEmit > 0 && spectroDirty) {
|
||||
const column = buildColumnData(state, freqBuf, range.bottom);
|
||||
const columns = [];
|
||||
for (let i = 0; i < columnsToEmit; i++) {
|
||||
columns.push(i === 0 ? column : new Float32Array(column));
|
||||
}
|
||||
|
||||
if (state.useWorker && state.workerReady) {
|
||||
columns.forEach(col => sendColumnToWorker(state, col));
|
||||
} else {
|
||||
columns.forEach(col => writeColumnToHistory(state, col));
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.useWorker) {
|
||||
drawSpectrogramImage(g, state, plotX, plotY, range, gamma);
|
||||
}
|
||||
|
||||
drawSpectrogramGrid(g, state, plotX, plotY, plotW, plotH);
|
||||
drawSpectrogramLegendHud(range, gamma);
|
||||
if (showMeter) {
|
||||
await drawMeterPanel(g, plotX, plotY, plotW, plotH, CONFIG, meters, activeMeter);
|
||||
}
|
||||
}
|
||||
|
||||
function drawSpectrogramBackground(g, plotX, plotY, plotW, plotH) {
|
||||
g.save();
|
||||
g.clearRect(plotX, plotY, plotW, plotH);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function setupWorker(state, widthPx, heightPx, range, gamma, freqBounds) {
|
||||
const canvasEl = ensureSpectroCanvasElement(state);
|
||||
if (!canvasEl) {
|
||||
state.useWorker = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.worker) {
|
||||
try {
|
||||
const workerUrl = new URL('../workers/spectrogram.worker.js', import.meta.url);
|
||||
const worker = new Worker(workerUrl, { type: 'module' });
|
||||
worker.onmessage = (event) => handleWorkerMessage(state, event);
|
||||
worker.onerror = () => {
|
||||
state.useWorker = false;
|
||||
teardownWorker(state);
|
||||
};
|
||||
const offscreen = canvasEl.transferControlToOffscreen();
|
||||
state.spectroCanvasEl = canvasEl;
|
||||
state.worker = worker;
|
||||
state.workerReady = false;
|
||||
state.offscreenWidth = widthPx;
|
||||
state.offscreenHeight = heightPx;
|
||||
state.spectroCanvasTransferred = true;
|
||||
state.worker.postMessage({
|
||||
type: 'init',
|
||||
canvas: offscreen,
|
||||
width: widthPx,
|
||||
height: heightPx,
|
||||
topDb: range.top,
|
||||
bottomDb: range.bottom,
|
||||
gamma,
|
||||
fMin: freqBounds?.min,
|
||||
fMax: freqBounds?.max,
|
||||
}, [offscreen]);
|
||||
state.lastTopDb = range.top;
|
||||
state.lastBottomDb = range.bottom;
|
||||
state.lastGamma = gamma;
|
||||
} catch (e) {
|
||||
state.useWorker = false;
|
||||
teardownWorker(state);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const needsResize = state.offscreenWidth !== widthPx || state.offscreenHeight !== heightPx;
|
||||
if (needsResize) {
|
||||
state.offscreenWidth = widthPx;
|
||||
state.offscreenHeight = heightPx;
|
||||
state.worker?.postMessage({
|
||||
type: 'resize',
|
||||
width: widthPx,
|
||||
height: heightPx,
|
||||
fMin: freqBounds?.min,
|
||||
fMax: freqBounds?.max,
|
||||
});
|
||||
}
|
||||
|
||||
const needsConfigUpdate = state.lastTopDb !== range.top ||
|
||||
state.lastBottomDb !== range.bottom ||
|
||||
state.lastGamma !== gamma;
|
||||
|
||||
if (needsConfigUpdate) {
|
||||
state.lastTopDb = range.top;
|
||||
state.lastBottomDb = range.bottom;
|
||||
state.lastGamma = gamma;
|
||||
state.worker?.postMessage({
|
||||
type: 'config',
|
||||
topDb: range.top,
|
||||
bottomDb: range.bottom,
|
||||
gamma
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function teardownWorker(state) {
|
||||
if (state.worker) {
|
||||
try {
|
||||
state.worker.postMessage({ type: 'dispose' });
|
||||
setTimeout(() => {
|
||||
try { state.worker.terminate(); } catch (_) {}
|
||||
}, 10);
|
||||
} catch (_) {}
|
||||
}
|
||||
state.worker = null;
|
||||
state.workerReady = false;
|
||||
state.spectroCanvasEl = null;
|
||||
state.spectroCanvasTransferred = false;
|
||||
}
|
||||
|
||||
function handleWorkerMessage(state, event) {
|
||||
const data = event.data || {};
|
||||
if (data.type === 'ready') {
|
||||
state.workerReady = true;
|
||||
} else if (data.type === 'dbg' && state._dbgEnabled) {
|
||||
console.debug('[spectro worker]', data);
|
||||
}
|
||||
}
|
||||
|
||||
function sendColumnToWorker(state, column) {
|
||||
if (!state.worker || !column) return;
|
||||
|
||||
// Prüfe ob Worker bereit ist und nicht überlastet
|
||||
if (!state.workerReady) return;
|
||||
|
||||
try {
|
||||
state.worker.postMessage({ type: 'column', data: column }, [column.buffer]);
|
||||
} catch (e) {
|
||||
state.useWorker = false;
|
||||
}
|
||||
advanceWriteIndex(state);
|
||||
|
||||
// Debug: alle ~1s senden wir ein Status-Log
|
||||
if (state._dbgEnabled && performance.now() >= state._dbgNextLog) {
|
||||
state._dbgNextLog = performance.now() + 1000;
|
||||
console.debug('[spectro main] sent column', { writeIndex: state.writeIndex });
|
||||
}
|
||||
}
|
||||
|
||||
function ensureFallbackBuffers(state, width, height, fillDb, g) {
|
||||
const needsInit = !state.history ||
|
||||
state.width !== width ||
|
||||
state.height !== height ||
|
||||
!state.imgData ||
|
||||
!state.pixelData;
|
||||
|
||||
if (!needsInit) return;
|
||||
|
||||
state.width = width;
|
||||
state.height = height;
|
||||
|
||||
state.history = Array.from({ length: width }, () => {
|
||||
const arr = new Float32Array(height);
|
||||
arr.fill(fillDb);
|
||||
return arr;
|
||||
});
|
||||
|
||||
state.writeIndex = 0;
|
||||
state.scrollAccumulator = 0;
|
||||
state._lastScrollTs = performance.now();
|
||||
state.imgData = g.createImageData(width, height);
|
||||
state.pixelData = state.imgData.data;
|
||||
}
|
||||
|
||||
function ensureFreqMap(state, height, freqBounds, binCount, fs) {
|
||||
const key = [
|
||||
height,
|
||||
freqBounds.min.toFixed(3),
|
||||
freqBounds.max.toFixed(3),
|
||||
binCount,
|
||||
fs
|
||||
].join('|');
|
||||
if (state.freqMapKey === key && state.freqMap && state.freqMap.length === height) {
|
||||
return;
|
||||
}
|
||||
state.freqMap = buildFreqMap(height, freqBounds, fs, binCount);
|
||||
state.freqMapKey = key;
|
||||
state.binCount = binCount;
|
||||
}
|
||||
|
||||
function buildColumnData(state, freqBuf, fallbackDb) {
|
||||
const height = state.height;
|
||||
if (!height) return null;
|
||||
|
||||
const column = new Float32Array(height);
|
||||
const freqMap = state.freqMap || [];
|
||||
const bins = freqBuf.length;
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
const map = freqMap[y];
|
||||
if (!map) {
|
||||
column[y] = fallbackDb;
|
||||
continue;
|
||||
}
|
||||
|
||||
let binLo = map.binLo;
|
||||
let binHi = map.binHi;
|
||||
|
||||
if (!Number.isFinite(binLo)) binLo = 0;
|
||||
if (!Number.isFinite(binHi)) binHi = binLo;
|
||||
|
||||
binLo = Math.max(0, Math.min(bins - 1, binLo));
|
||||
binHi = Math.max(binLo, Math.min(bins - 1, binHi));
|
||||
|
||||
let linearSum = 0;
|
||||
let count = 0;
|
||||
let maxDb = -Infinity;
|
||||
|
||||
for (let bin = binLo; bin <= binHi; bin++) {
|
||||
const dB = freqBuf[bin];
|
||||
if (!Number.isFinite(dB)) continue;
|
||||
|
||||
linearSum += Math.exp(dB * DB_TO_LINEAR_FACTOR);
|
||||
count++;
|
||||
|
||||
if (dB > maxDb) maxDb = dB;
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
const avgLinear = linearSum / count;
|
||||
column[y] = Math.log10(Math.max(avgLinear, 1e-12)) * 10;
|
||||
} else if (Number.isFinite(maxDb)) {
|
||||
column[y] = maxDb;
|
||||
} else {
|
||||
column[y] = fallbackDb;
|
||||
}
|
||||
}
|
||||
|
||||
return column;
|
||||
}
|
||||
|
||||
function writeColumnToHistory(state, column) {
|
||||
if (!state.history || !column) return;
|
||||
const target = state.history[state.writeIndex];
|
||||
if (target) target.set(column);
|
||||
advanceWriteIndex(state);
|
||||
}
|
||||
|
||||
function drawSpectrogramImage(g, state, plotX, plotY, range, gamma) {
|
||||
const img = state.imgData;
|
||||
const pixels = state.pixelData;
|
||||
if (!img || !pixels || !state.history) return;
|
||||
|
||||
const width = state.width;
|
||||
const height = state.height;
|
||||
const totalRange = Math.max(1e-3, range.top - range.bottom);
|
||||
const baseIndex = state.writeIndex;
|
||||
|
||||
const colorStops = SPECTRO_COLOR_STOPS;
|
||||
|
||||
for (let x = 0; x < width; x++) {
|
||||
const historyIdx = (baseIndex + x) % width;
|
||||
const column = state.history[historyIdx];
|
||||
if (!column) continue;
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
const dB = column[y];
|
||||
const clamped = dB < range.bottom ? range.bottom : dB > range.top ? range.top : dB;
|
||||
let norm = (clamped - range.bottom) / totalRange;
|
||||
norm = Math.pow(norm < 0 ? 0 : norm > 1 ? 1 : norm, gamma);
|
||||
|
||||
const [r, gVal, bVal] = colorFromNorm(norm, colorStops);
|
||||
const idx = ((height - 1 - y) * width + x) * 4;
|
||||
pixels[idx + 0] = r;
|
||||
pixels[idx + 1] = gVal;
|
||||
pixels[idx + 2] = bVal;
|
||||
pixels[idx + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
g.putImageData(img, plotX, plotY);
|
||||
}
|
||||
|
||||
function drawSpectrogramGrid(g, state, plotX, plotY, plotW, plotH) {
|
||||
const bounds = state.freqBounds;
|
||||
if (!bounds) return;
|
||||
|
||||
const ticks = RTA_X_TICKS.filter((f) => f >= bounds.min && f <= bounds.max);
|
||||
drawCachedSpectroGrid(state, g, plotX, plotY, plotW, plotH, bounds, ticks);
|
||||
|
||||
g.save();
|
||||
g.fillStyle = '#bcd';
|
||||
g.textAlign = 'right';
|
||||
g.font = '12px monospace';
|
||||
|
||||
for (const freq of ticks) {
|
||||
const y = freqToPixel(freq, state, plotY, plotH, bounds);
|
||||
if (!Number.isFinite(y)) continue;
|
||||
|
||||
if (freq <= Math.max(20, bounds.min * 1.05)) {
|
||||
g.textBaseline = 'alphabetic';
|
||||
g.fillText(formatFreqLabel(freq), plotX - 6, y);
|
||||
} else {
|
||||
g.textBaseline = 'middle';
|
||||
g.fillText(formatFreqLabel(freq), plotX - 6, y);
|
||||
}
|
||||
}
|
||||
|
||||
g.textAlign = 'right';
|
||||
g.textBaseline = 'bottom';
|
||||
g.fillText('Zeit →', plotX + plotW - 6, plotY + plotH - 4);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function resolveScrollRate(CONFIG) {
|
||||
const mode = Number(CONFIG?.SPECTRO_SCROLL_MODE ?? 0);
|
||||
if (mode === 0.5) return 0.5;
|
||||
if (mode === 2) return 2;
|
||||
if (mode === 4) return 4;
|
||||
if (mode === 6) return 6;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function advanceWriteIndex(state) {
|
||||
const width = Math.max(1, state.width || 1);
|
||||
state.writeIndex = (state.writeIndex + 1) % width;
|
||||
}
|
||||
|
||||
function drawSpectrogramLegendHud(range, gamma = 1) {
|
||||
if (!range) return;
|
||||
const wrap = document.getElementById('spectroLegendWrap');
|
||||
const canvas = document.getElementById('spectroLegend');
|
||||
if (!wrap || !canvas) return;
|
||||
if (wrap.style.display === 'none' || wrap.hidden) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||
const cssWidth = Math.max(200, canvas.clientWidth || canvas.width || 200);
|
||||
const cssHeight = Math.max(32, canvas.clientHeight || canvas.height || 32);
|
||||
const width = Math.round(cssWidth * dpr);
|
||||
const height = Math.round(cssHeight * dpr);
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
}
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.save();
|
||||
ctx.scale(dpr, dpr);
|
||||
const w = width / dpr;
|
||||
const h = height / dpr;
|
||||
const span = Math.max(1e-3, range.top - range.bottom);
|
||||
const paddingTop = 0;
|
||||
const paddingSide = 20;
|
||||
const gradientWidth = Math.max(80, w - paddingSide * 2);
|
||||
const gradientHeight = Math.max(10, Math.min(18, h * 0.45));
|
||||
const gradientX = paddingSide;
|
||||
const gradientY = paddingTop;
|
||||
|
||||
const steps = Math.max(1, Math.round(gradientWidth));
|
||||
const stepWidth = gradientWidth / steps;
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const frac = steps <= 1 ? 0 : i / (steps - 1);
|
||||
const gammaFrac = Math.pow(Math.min(1, Math.max(0, frac)), gamma);
|
||||
const [r, gCol, bCol] = colorFromNorm(gammaFrac, SPECTRO_COLOR_STOPS);
|
||||
ctx.fillStyle = `rgb(${r},${gCol},${bCol})`;
|
||||
ctx.fillRect(gradientX + i * stepWidth, gradientY, stepWidth + 1, gradientHeight);
|
||||
}
|
||||
|
||||
const ticks = [range.bottom, range.bottom + span / 2, range.top];
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.6)';
|
||||
ctx.fillStyle = '#e6f9ff';
|
||||
ctx.font = '10px ui-monospace, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
const gap = 9;
|
||||
for (const value of ticks) {
|
||||
const frac = (value - range.bottom) / span;
|
||||
const x = gradientX + Math.min(1, Math.max(0, frac)) * gradientWidth;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, gradientY + gradientHeight);
|
||||
ctx.lineTo(x, gradientY + gradientHeight + 7);
|
||||
ctx.stroke();
|
||||
ctx.fillText(`${formatDbLabel(value)} dB`, x, gradientY + gradientHeight + gap + 1);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawCachedSpectroGrid(state, ctx, plotX, plotY, plotW, plotH, bounds, ticks) {
|
||||
const key = [
|
||||
Math.round(plotW),
|
||||
Math.round(plotH),
|
||||
bounds.min.toFixed(2),
|
||||
bounds.max.toFixed(2),
|
||||
ticks.join(','),
|
||||
TIME_GRID_SPACING
|
||||
].join('|');
|
||||
const needsRebuild = !state.gridCache
|
||||
|| state.gridCache.key !== key
|
||||
|| state.gridCache.width !== Math.round(plotW)
|
||||
|| state.gridCache.height !== Math.round(plotH);
|
||||
|
||||
if (needsRebuild) {
|
||||
const canvas = document.createElement('canvas');
|
||||
const cw = Math.max(1, Math.round(plotW));
|
||||
const ch = Math.max(1, Math.round(plotH));
|
||||
canvas.width = cw;
|
||||
canvas.height = ch;
|
||||
const cg = canvas.getContext('2d');
|
||||
if (cg) {
|
||||
cg.save();
|
||||
cg.scale(cw / plotW, ch / plotH);
|
||||
cg.strokeStyle = FRAME_COLOR;
|
||||
cg.lineWidth = 2;
|
||||
cg.strokeRect(0.5, 0.5, plotW - 1, plotH - 1);
|
||||
|
||||
cg.strokeStyle = 'rgba(0,231,255,0.25)';
|
||||
cg.lineWidth = 1;
|
||||
for (const freq of ticks) {
|
||||
const y = freqToPixel(freq, { height: plotH, freqBounds: bounds }, 0, plotH, bounds);
|
||||
if (!Number.isFinite(y)) continue;
|
||||
cg.beginPath();
|
||||
cg.moveTo(0, y + 0.5);
|
||||
cg.lineTo(plotW, y + 0.5);
|
||||
cg.stroke();
|
||||
}
|
||||
|
||||
// Removed vertical time grid lines for cleaner spectrogram view
|
||||
cg.restore();
|
||||
}
|
||||
state.gridCache = { key, canvas, width: cw, height: ch };
|
||||
}
|
||||
|
||||
ctx.drawImage(state.gridCache.canvas, plotX, plotY, plotW, plotH);
|
||||
}
|
||||
|
||||
function freqToPixel(freq, state, plotY, plotH, bounds) {
|
||||
if (!bounds) return plotY + plotH;
|
||||
|
||||
const minF = Math.max(10, bounds.min);
|
||||
const maxF = Math.max(minF + 1, bounds.max);
|
||||
const logRatio = Math.log(maxF / minF);
|
||||
|
||||
if (!Number.isFinite(logRatio) || logRatio <= 0) return plotY + plotH;
|
||||
|
||||
const frac = Math.log(freq / minF) / logRatio;
|
||||
const height = state.height || plotH;
|
||||
const row = (frac < 0 ? 0 : frac > 1 ? 1 : frac) * Math.max(0, height - 1);
|
||||
|
||||
return plotY + (height - 1 - row);
|
||||
}
|
||||
|
||||
function formatFreqLabel(freq) {
|
||||
if (freq >= 1000) {
|
||||
const val = freq / 1000;
|
||||
return val >= 10 ? `${Math.round(val)}k` : `${val.toFixed(1)}k`;
|
||||
}
|
||||
return String(Math.round(freq));
|
||||
}
|
||||
|
||||
function formatDbLabel(value) {
|
||||
if (!Number.isFinite(value)) return '0';
|
||||
const rounded = Math.abs(value) < 10 ? value.toFixed(1) : Math.round(value);
|
||||
return String(rounded);
|
||||
}
|
||||
|
||||
async function drawMeterPanel(g, plotX, plotY, plotW, plotH, CONFIG, meters, activeMeter) {
|
||||
const meterRect = {
|
||||
x: plotX + plotW + METER_GAP,
|
||||
y: plotY,
|
||||
w: METER_WIDTH,
|
||||
h: plotH
|
||||
};
|
||||
|
||||
g.save();
|
||||
g.fillStyle = PANEL_BG;
|
||||
g.fillRect(meterRect.x, meterRect.y, meterRect.w, meterRect.h);
|
||||
g.restore();
|
||||
|
||||
g.strokeStyle = FRAME_COLOR;
|
||||
g.lineWidth = 2;
|
||||
|
||||
const active = activeMeter || 'ppm-din';
|
||||
if (active === 'none') return;
|
||||
const shrink = Math.max(0, METER_SLOT_SHRINK);
|
||||
const innerHeight = Math.max(40, meterRect.h - METER_PAD_TOP - METER_PAD_BOTTOM - shrink - METER_EXTRA_BOTTOM_PAD);
|
||||
const innerOffset = shrink / 2;
|
||||
|
||||
const innerRect = {
|
||||
x: meterRect.x,
|
||||
y: meterRect.y + METER_PAD_TOP + innerOffset,
|
||||
w: meterRect.w,
|
||||
h: innerHeight,
|
||||
};
|
||||
|
||||
g.save();
|
||||
g.beginPath();
|
||||
g.rect(
|
||||
meterRect.x + g.lineWidth / 2,
|
||||
meterRect.y + g.lineWidth / 2,
|
||||
meterRect.w - g.lineWidth,
|
||||
meterRect.h - g.lineWidth
|
||||
);
|
||||
g.clip();
|
||||
await meters.draw(g, innerRect, active, CONFIG);
|
||||
g.restore();
|
||||
|
||||
g.strokeRect(meterRect.x, meterRect.y, meterRect.w, meterRect.h);
|
||||
}
|
||||
|
||||
function getDbRange(CONFIG) {
|
||||
const BASE_TOP_DB = -9;
|
||||
const top = Number.isFinite(CONFIG.DBFS_TOP) ? CONFIG.DBFS_TOP : BASE_TOP_DB;
|
||||
const bottom = Number.isFinite(CONFIG.DBFS_BOTTOM) ? CONFIG.DBFS_BOTTOM : -63;
|
||||
return { top, bottom };
|
||||
}
|
||||
|
||||
function getFreqBounds(CONFIG, nyq) {
|
||||
const min = CONFIG.RTA_FREQ_RANGE === 'lf' ? 5 : 20;
|
||||
const max = CONFIG.RTA_FREQ_RANGE === 'lf' ? 5000 : 20000;
|
||||
return {
|
||||
min: Math.max(10, min),
|
||||
max: Math.min(Math.max(min + 1, max), nyq)
|
||||
};
|
||||
}
|
||||
|
||||
function buildFreqMap(height, freqBounds, fs, binCount) {
|
||||
const nyq = fs / 2;
|
||||
const minF = Math.max(10, freqBounds.min);
|
||||
const maxF = Math.max(minF + 1, Math.min(freqBounds.max, nyq));
|
||||
const denom = Math.max(1, height - 1);
|
||||
const ratio = maxF / minF;
|
||||
const map = new Array(height);
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
const loPos = denom === 0 ? 0 : (y - 0.5) / denom;
|
||||
const hiPos = denom === 0 ? 0 : (y + 0.5) / denom;
|
||||
const fLo = minF * Math.pow(ratio, loPos < 0 ? 0 : loPos > 1 ? 1 : loPos);
|
||||
const fHi = minF * Math.pow(ratio, hiPos < 0 ? 0 : hiPos > 1 ? 1 : hiPos);
|
||||
const binLo = clampBin(Math.floor(freqToBin(fLo, nyq, binCount)), binCount);
|
||||
const binHi = clampBin(Math.ceil(freqToBin(fHi, nyq, binCount)), binCount);
|
||||
map[y] = {
|
||||
binLo: Math.min(binLo, binHi),
|
||||
binHi: Math.max(binLo, binHi)
|
||||
};
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
function freqToBin(freq, nyq, binCount) {
|
||||
if (!Number.isFinite(freq) || freq <= 0) return 0;
|
||||
const maxIdx = Math.max(1, binCount - 1);
|
||||
return (Math.min(freq, nyq) / nyq) * maxIdx;
|
||||
}
|
||||
|
||||
function clampBin(idx, binCount) {
|
||||
if (!Number.isFinite(idx)) return 0;
|
||||
const maxIdx = Math.max(0, binCount - 1);
|
||||
return idx < 0 ? 0 : idx > maxIdx ? maxIdx : idx;
|
||||
}
|
||||
|
||||
function colorFromNorm(value, stops) {
|
||||
const t = value < 0 ? 0 : value > 1 ? 1 : value;
|
||||
|
||||
for (let i = 1; i < stops.length; i++) {
|
||||
const left = stops[i - 1];
|
||||
const right = stops[i];
|
||||
|
||||
if (t <= right.t) {
|
||||
const span = right.t - left.t || 1;
|
||||
const rel = (t - left.t) / span;
|
||||
return [
|
||||
Math.round(left.color[0] + rel * (right.color[0] - left.color[0])),
|
||||
Math.round(left.color[1] + rel * (right.color[1] - left.color[1])),
|
||||
Math.round(left.color[2] + rel * (right.color[2] - left.color[2])),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
const last = stops[stops.length - 1].color;
|
||||
return [last[0], last[1], last[2]];
|
||||
}
|
||||
|
||||
function layoutSpectroCanvas(state, plotX, plotY, plotW, plotH, widthPx, heightPx) {
|
||||
const canvas = ensureSpectroCanvasElement(state);
|
||||
if (!canvas) return;
|
||||
|
||||
canvas.style.left = `${plotX}px`;
|
||||
canvas.style.top = `${plotY}px`;
|
||||
canvas.style.width = `${plotW}px`;
|
||||
canvas.style.height = `${plotH}px`;
|
||||
|
||||
if (!state.spectroCanvasTransferred) {
|
||||
canvas.width = widthPx;
|
||||
canvas.height = heightPx;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSpectroCanvasElement(state) {
|
||||
if (state.spectroCanvasEl) return state.spectroCanvasEl;
|
||||
|
||||
let canvas = document.getElementById('spectroCanvas');
|
||||
if (!canvas) {
|
||||
canvas = document.createElement('canvas');
|
||||
canvas.id = 'spectroCanvas';
|
||||
canvas.className = 'spectrogram-layer';
|
||||
canvas.style.pointerEvents = 'none';
|
||||
canvas.style.willChange = 'transform';
|
||||
|
||||
const overlay = document.getElementById('cv');
|
||||
if (overlay && overlay.parentNode) {
|
||||
overlay.parentNode.insertBefore(canvas, overlay);
|
||||
} else {
|
||||
document.body.appendChild(canvas);
|
||||
}
|
||||
}
|
||||
|
||||
state.spectroCanvasEl = canvas;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function removeSpectroCanvasElement(state) {
|
||||
const canvas = state.spectroCanvasEl || document.getElementById('spectroCanvas');
|
||||
if (canvas && canvas.parentNode) {
|
||||
canvas.parentNode.removeChild(canvas);
|
||||
}
|
||||
state.spectroCanvasEl = null;
|
||||
state.spectroCanvasTransferred = false;
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
// 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';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function destroyChild(child) {
|
||||
if (!child || child.id === 'none') return;
|
||||
const mod = CHILD_VIEWS[child.id];
|
||||
if (mod && typeof mod.destroy === 'function') {
|
||||
try { mod.destroy(child.state); } catch (e) { console.warn('Split child destroy error:', e); }
|
||||
}
|
||||
}
|
||||
|
||||
function initChild(env, childId) {
|
||||
const id = sanitizeChildId(childId, 'none');
|
||||
if (id === 'none') return { id: 'none', state: {} };
|
||||
const mod = CHILD_VIEWS[id];
|
||||
const state = (mod && typeof mod.init === 'function') ? (mod.init(env) || {}) : {};
|
||||
return { id, state };
|
||||
}
|
||||
|
||||
function resizeChild(env, rect, child) {
|
||||
if (!child || child.id === 'none') return;
|
||||
const mod = CHILD_VIEWS[child.id];
|
||||
if (!mod || typeof mod.resize !== 'function') return;
|
||||
try { mod.resize({ rect }, child.state); } catch (e) { console.warn('Split child resize error:', e); }
|
||||
}
|
||||
|
||||
async function renderChild(env, rect, child) {
|
||||
const { ctx: g } = env;
|
||||
if (!child || child.id === 'none') return;
|
||||
const mod = CHILD_VIEWS[child.id];
|
||||
if (!mod || typeof mod.render !== 'function') return;
|
||||
await withClippedSubRect(g, rect, async () => {
|
||||
const plotOnlySlots = (viewId) => {
|
||||
if (viewId === 'peak-history' || viewId === 'classic-needles' || viewId === 'panel') {
|
||||
const configured = env?.slots?.(viewId);
|
||||
if (Array.isArray(configured) && configured.length) return configured;
|
||||
}
|
||||
if (viewId === 'peak-history') return ['ppm-din'];
|
||||
if (viewId === 'classic-needles') return ['vu'];
|
||||
if (viewId === 'panel') return ['vu', 'ppm-ebu', 'ppm-din', 'tp', 'rms'];
|
||||
return ['none'];
|
||||
};
|
||||
const subEnv = Object.assign({}, env, {
|
||||
rect: { x: 0, y: 0, w: rect.w, h: rect.h },
|
||||
embedded: true,
|
||||
containerView: 'split-view',
|
||||
slots: plotOnlySlots,
|
||||
embeddedOffsetX: rect.x,
|
||||
embeddedOffsetY: rect.y,
|
||||
});
|
||||
await mod.render(subEnv, child.state);
|
||||
});
|
||||
}
|
||||
|
||||
export function init(env) {
|
||||
const leftId = sanitizePlotId(env?.config?.SPLIT_VIEW_LEFT, 'phase-wheel');
|
||||
const rightId = sanitizePlotId(env?.config?.SPLIT_VIEW_RIGHT, 'realtime');
|
||||
const state = {
|
||||
staticLayers: new Map(),
|
||||
left: initChild(env, leftId),
|
||||
right: initChild(env, rightId),
|
||||
popup: initChild(env, 'none'),
|
||||
lastLeftId: leftId,
|
||||
lastRightId: rightId,
|
||||
lastPopupId: 'none',
|
||||
lastRectSig: '',
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
export function destroy(state) {
|
||||
if (state) state.staticLayers = null;
|
||||
destroyChild(state?.left);
|
||||
destroyChild(state?.right);
|
||||
destroyChild(state?.popup);
|
||||
}
|
||||
|
||||
export function resize({ rect }, state) {
|
||||
if (!state || !rect) return;
|
||||
const sig = `${rect.w}x${rect.h}`;
|
||||
if (state.lastRectSig === sig) return;
|
||||
state.lastRectSig = sig;
|
||||
const layout = computeLayout(rect);
|
||||
resizeChild(null, { x: 0, y: 0, w: layout.left.w, h: layout.left.h }, state.left);
|
||||
resizeChild(null, { x: 0, y: 0, w: layout.right.w, h: layout.right.h }, state.right);
|
||||
resizeChild(null, { x: 0, y: 0, w: rect.w, h: rect.h }, state.popup);
|
||||
}
|
||||
|
||||
function computeLayout(rect) {
|
||||
const gap = OUTER_GAP;
|
||||
const w = Math.max(0, rect.w);
|
||||
const h = Math.max(0, rect.h);
|
||||
const half = Math.floor((w - gap) / 2);
|
||||
const left = { x: 0, y: 0, w: Math.max(0, half), h };
|
||||
const right = { x: Math.max(0, half + gap), y: 0, w: Math.max(0, w - (half + gap)), h };
|
||||
return { left, right, gap };
|
||||
}
|
||||
|
||||
export async function render(env, state) {
|
||||
const { ctx: g, rect, config: CONFIG } = env;
|
||||
const popupCfg = env?.splitPopup;
|
||||
const popupWanted = popupCfg && popupCfg.open ? sanitizeChildId(popupCfg.viewId, 'none') : 'none';
|
||||
|
||||
// Popup-Modus: rendere die View in Originalgröße (vollflächig),
|
||||
// ohne Split-Hintergrund/Layout. So sieht es exakt wie die Einzel-View aus.
|
||||
if (popupWanted !== 'none' && state) {
|
||||
if (state.lastPopupId !== popupWanted) {
|
||||
destroyChild(state.popup);
|
||||
state.popup = initChild(env, popupWanted);
|
||||
state.lastPopupId = popupWanted;
|
||||
resizeChild(null, { x: 0, y: 0, w: rect.w, h: rect.h }, state.popup);
|
||||
}
|
||||
state._splitHit = {
|
||||
contentY: 0,
|
||||
plots: null,
|
||||
meters: [],
|
||||
popupBox: { x: 0, y: 0, w: rect.w, h: rect.h },
|
||||
};
|
||||
const mod = CHILD_VIEWS[state.popup.id];
|
||||
if (mod && typeof mod.render === 'function') {
|
||||
const subEnv = Object.assign({}, env, {
|
||||
rect: { x: 0, y: 0, w: rect.w, h: rect.h },
|
||||
embedded: false,
|
||||
});
|
||||
await mod.render(subEnv, state.popup.state);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (state && state.lastPopupId !== 'none') {
|
||||
destroyChild(state.popup);
|
||||
state.popup = initChild(env, 'none');
|
||||
state.lastPopupId = 'none';
|
||||
}
|
||||
|
||||
const desiredLeftId = sanitizePlotId(env?.config?.SPLIT_VIEW_LEFT, 'phase-wheel');
|
||||
const desiredRightId = sanitizePlotId(env?.config?.SPLIT_VIEW_RIGHT, 'realtime');
|
||||
|
||||
if (state.lastLeftId !== desiredLeftId) {
|
||||
destroyChild(state.left);
|
||||
state.left = initChild(env, desiredLeftId);
|
||||
state.lastLeftId = desiredLeftId;
|
||||
}
|
||||
if (state.lastRightId !== desiredRightId) {
|
||||
destroyChild(state.right);
|
||||
state.right = initChild(env, desiredRightId);
|
||||
state.lastRightId = desiredRightId;
|
||||
}
|
||||
|
||||
const slots = readSplitMeters(CONFIG);
|
||||
const layout = computeSplitLayout(rect, slots);
|
||||
const leftPlotRect = layout?.plots?.left || { x: 0, y: 0, w: Math.floor(rect.w / 2), h: rect.h };
|
||||
const rightPlotRect = layout?.plots?.right || { x: Math.floor(rect.w / 2), y: 0, w: rect.w - Math.floor(rect.w / 2), h: rect.h };
|
||||
if (state) {
|
||||
state._splitHit = {
|
||||
contentY: 0,
|
||||
plots: {
|
||||
left: { ...leftPlotRect, viewId: desiredLeftId },
|
||||
right: { ...rightPlotRect, viewId: desiredRightId },
|
||||
},
|
||||
meters: [layout?.meters?.left, layout?.meters?.center, layout?.meters?.right].filter(Boolean).map((r) => ({ ...r })),
|
||||
popupBox: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (state.left?.id === 'none') drawEmptyPlot(state, g, leftPlotRect, 'Links: (leer)');
|
||||
else await renderChild(env, leftPlotRect, state.left);
|
||||
|
||||
if (state.right?.id === 'none') drawEmptyPlot(state, g, rightPlotRect, 'Rechts: (leer)');
|
||||
else await renderChild(env, rightPlotRect, state.right);
|
||||
|
||||
if (layout?.meters?.left) await drawMetersPanel(env, state, layout.meters.left, layout.meterIds.left, layout.slotW);
|
||||
if (layout?.meters?.center) await drawMetersPanel(env, state, layout.meters.center, layout.meterIds.center, layout.slotW);
|
||||
if (layout?.meters?.right) await drawMetersPanel(env, state, layout.meters.right, layout.meterIds.right, layout.slotW);
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
// views/waveform.js — Live waveform display with optional worker renderer
|
||||
|
||||
import { DEFAULT_TOP_INSET, FRAME_COLOR, PANEL_BG } from '../core/theme.js';
|
||||
|
||||
const PLOT = { left: 0, top: DEFAULT_TOP_INSET, right: 0, bottom: 0 };
|
||||
const METER_WIDTH = 140;
|
||||
const METER_GAP = 8;
|
||||
const METER_PAD_TOP = 15;
|
||||
const METER_PAD_BOTTOM = 5;
|
||||
const METER_SLOT_SHRINK = 24;
|
||||
const METER_EXTRA_BOTTOM_PAD = 6;
|
||||
const MIN_HEIGHT = 80;
|
||||
const MODE_STACKED = 'stacked';
|
||||
const MODE_OVERLAY = 'overlay';
|
||||
const MODE_DIFF = 'diff';
|
||||
const WAVEFORM_RENDER_SCALE = 0.9;
|
||||
const SUPPORTS_WORKER = typeof window !== 'undefined'
|
||||
&& typeof Worker !== 'undefined'
|
||||
&& typeof HTMLCanvasElement !== 'undefined'
|
||||
&& !!HTMLCanvasElement.prototype.transferControlToOffscreen;
|
||||
|
||||
export const id = 'waveform';
|
||||
|
||||
export function init() {
|
||||
return {
|
||||
useWorker: SUPPORTS_WORKER,
|
||||
worker: null,
|
||||
workerReady: false,
|
||||
canvasEl: null,
|
||||
canvasTransferred: false,
|
||||
offscreenWidth: 0,
|
||||
offscreenHeight: 0,
|
||||
lastMode: MODE_STACKED,
|
||||
lastWorkerColorsKey: '',
|
||||
lastChannels: 1,
|
||||
diffScratch: new Float32Array(0),
|
||||
gridCache: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function destroy(state) {
|
||||
teardownWorker(state);
|
||||
removeWaveCanvasElement(state);
|
||||
state.gridCache = null;
|
||||
}
|
||||
|
||||
export function resize() {}
|
||||
|
||||
export async function render(env, state) {
|
||||
const { ctx: g, rect, config: CONFIG, audio, meters } = env;
|
||||
const plotX = PLOT.left;
|
||||
const topInset = Number.isFinite(Number(env?.topInset)) ? Number(env.topInset) : PLOT.top;
|
||||
const plotY = topInset;
|
||||
const canvasOffsetX = Number.isFinite(Number(env.embeddedOffsetX)) ? Number(env.embeddedOffsetX) : 0;
|
||||
const canvasOffsetY = Number.isFinite(Number(env.embeddedOffsetY)) ? Number(env.embeddedOffsetY) : 0;
|
||||
const slotList = env.slots ? env.slots(id) : null;
|
||||
const activeMeter = (slotList && slotList[0]) || 'ppm-din';
|
||||
const showMeter = activeMeter !== 'none';
|
||||
const plotW = Math.max(16, Math.floor(rect.w - PLOT.left - PLOT.right - (showMeter ? (METER_WIDTH + METER_GAP) : 0)));
|
||||
const plotH = Math.max(MIN_HEIGHT, Math.floor(rect.h - plotY - PLOT.bottom));
|
||||
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||
const renderScale = Math.max(0.5, Math.min(1, WAVEFORM_RENDER_SCALE));
|
||||
const pixelWidth = Math.max(32, Math.floor(plotW * dpr * renderScale));
|
||||
const pixelHeight = Math.max(32, Math.floor(plotH * dpr * renderScale));
|
||||
layoutWaveCanvas(state, canvasOffsetX + plotX, canvasOffsetY + plotY, plotW, plotH, pixelWidth, pixelHeight);
|
||||
if (!state.useWorker) {
|
||||
detachWaveCanvas(state);
|
||||
}
|
||||
|
||||
const mode = normalizeMode(CONFIG.WAVEFORM_MODE);
|
||||
const windowSec = clampWindow(CONFIG.WAVEFORM_WINDOW_SEC);
|
||||
const envelope = audio?.getWaveformEnvelope
|
||||
? audio.getWaveformEnvelope(pixelWidth, windowSec)
|
||||
: null;
|
||||
let payload = envelope
|
||||
? {
|
||||
minMaxL: envelope.minMaxL,
|
||||
minMaxR: envelope.minMaxR,
|
||||
pixelWidth: envelope.pixelWidth || pixelWidth,
|
||||
cssWidth: plotW,
|
||||
cssHeight: plotH,
|
||||
channels: envelope.channels || 1,
|
||||
mode,
|
||||
config: {
|
||||
leftColor: CONFIG.WAVEFORM_COLOR_LEFT,
|
||||
rightColor: CONFIG.WAVEFORM_COLOR_RIGHT,
|
||||
diffColor: CONFIG.WAVEFORM_COLOR_DIFF,
|
||||
},
|
||||
}
|
||||
: null;
|
||||
if (payload) {
|
||||
state.lastChannels = payload.channels;
|
||||
}
|
||||
|
||||
drawWaveformBackground(g, plotX, plotY, plotW, plotH);
|
||||
|
||||
if (state.useWorker) {
|
||||
setupWaveformWorker(state, pixelWidth, pixelHeight, mode, payload?.config || null);
|
||||
if (state.workerReady && payload) {
|
||||
sendWaveformToWorker(state, payload);
|
||||
payload = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.useWorker && payload) {
|
||||
drawWaveformFallback(g, state, plotX, plotY, plotW, plotH, payload, mode);
|
||||
}
|
||||
|
||||
const gridChannels = mode === MODE_DIFF ? 1 : state.lastChannels;
|
||||
drawWaveformGrid(g, state, plotX, plotY, plotW, plotH, mode, gridChannels);
|
||||
if (showMeter) {
|
||||
await drawMeterPanel(g, plotX, plotY, plotW, plotH, CONFIG, meters, activeMeter);
|
||||
}
|
||||
}
|
||||
|
||||
function drawWaveformBackground(g, plotX, plotY, plotW, plotH) {
|
||||
g.save();
|
||||
g.clearRect(plotX, plotY, plotW, plotH);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawWaveformGrid(g, state, plotX, plotY, plotW, plotH, mode, channelCount) {
|
||||
drawCachedWaveformGrid(g, state, plotX, plotY, plotW, plotH, mode, channelCount);
|
||||
}
|
||||
|
||||
function drawCachedWaveformGrid(g, state, plotX, plotY, plotW, plotH, mode, channelCount) {
|
||||
if (!state) {
|
||||
drawWaveformGridDirect(g, plotX, plotY, plotW, plotH, mode, channelCount);
|
||||
return;
|
||||
}
|
||||
const key = [
|
||||
Math.round(plotW),
|
||||
Math.round(plotH),
|
||||
mode,
|
||||
channelCount,
|
||||
].join('|');
|
||||
const needsRebuild = !state.gridCache
|
||||
|| state.gridCache.key !== key
|
||||
|| state.gridCache.width !== Math.round(plotW)
|
||||
|| state.gridCache.height !== Math.round(plotH);
|
||||
|
||||
if (needsRebuild) {
|
||||
const canvas = document.createElement('canvas');
|
||||
const cw = Math.max(1, Math.round(plotW));
|
||||
const ch = Math.max(1, Math.round(plotH));
|
||||
canvas.width = cw;
|
||||
canvas.height = ch;
|
||||
const cg = canvas.getContext('2d');
|
||||
if (cg) {
|
||||
drawWaveformGridDirect(cg, 0, 0, plotW, plotH, mode, channelCount);
|
||||
}
|
||||
state.gridCache = { key, canvas, width: cw, height: ch };
|
||||
}
|
||||
|
||||
g.drawImage(state.gridCache.canvas, plotX, plotY, plotW, plotH);
|
||||
}
|
||||
|
||||
function drawWaveformGridDirect(g, plotX, plotY, plotW, plotH, mode, channelCount) {
|
||||
const rects = getChannelRects(plotY, plotH, channelCount, mode);
|
||||
g.save();
|
||||
g.strokeStyle = FRAME_COLOR;
|
||||
g.lineWidth = 2;
|
||||
g.strokeRect(plotX, plotY, plotW, plotH);
|
||||
g.lineWidth = 1;
|
||||
g.setLineDash([4, 6]);
|
||||
for (const rect of rects) {
|
||||
const lines = [-1, -0.5, 0.5, 1];
|
||||
g.strokeStyle = 'rgba(0,231,255,0.25)';
|
||||
for (const amp of lines) {
|
||||
const y = ampToY(amp, rect.y, rect.h) + 0.5;
|
||||
g.beginPath();
|
||||
g.moveTo(plotX, y);
|
||||
g.lineTo(plotX + plotW, y);
|
||||
g.stroke();
|
||||
}
|
||||
g.strokeStyle = 'rgba(0,231,255,0.45)';
|
||||
const zeroY = ampToY(0, rect.y, rect.h) + 0.5;
|
||||
g.setLineDash([]);
|
||||
g.beginPath();
|
||||
g.moveTo(plotX, zeroY);
|
||||
g.lineTo(plotX + plotW, zeroY);
|
||||
g.stroke();
|
||||
g.setLineDash([4, 6]);
|
||||
}
|
||||
g.setLineDash([]);
|
||||
g.fillStyle = '#bcd';
|
||||
g.textAlign = 'left';
|
||||
g.textBaseline = 'top';
|
||||
g.font = 'bold 14px ui-monospace, monospace';
|
||||
g.fillText('now', plotX + plotW - 34, plotY + plotH - 18);
|
||||
const showChannelLabels = mode === MODE_STACKED && channelCount > 1;
|
||||
if (showChannelLabels) {
|
||||
g.font = 'bold 38px ui-monospace, monospace';
|
||||
g.textBaseline = 'top';
|
||||
g.textAlign = 'left';
|
||||
g.fillText('L', plotX + 6, rects[0]?.y + 4 || plotY + 4);
|
||||
g.textAlign = 'left';
|
||||
g.textBaseline = 'bottom';
|
||||
g.fillText('R', plotX + 6, plotY + plotH - 6);
|
||||
} else if (mode === MODE_DIFF) {
|
||||
g.font = 'bold 32px ui-monospace, monospace';
|
||||
g.textBaseline = 'top';
|
||||
g.textAlign = 'left';
|
||||
g.fillText('Δ', plotX + 6, rects[0]?.y + 4 || plotY + 4);
|
||||
}
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawWaveformFallback(g, state, plotX, plotY, plotW, plotH, payload, mode) {
|
||||
const cfg = payload.config || {};
|
||||
const displayChannels = mode === MODE_DIFF ? 1 : payload.channels;
|
||||
const rects = getChannelRects(plotY, plotH, displayChannels, mode);
|
||||
const scaleX = payload.pixelWidth ? (plotW / payload.pixelWidth) : 1;
|
||||
const drawChannel = (data, rect, color, alpha = 1) => {
|
||||
if (!data) return;
|
||||
g.save();
|
||||
g.strokeStyle = color;
|
||||
g.globalAlpha = alpha;
|
||||
for (let x = 0; x < payload.pixelWidth; x++) {
|
||||
const idx = x * 2;
|
||||
const min = clampAmp(data[idx]);
|
||||
const max = clampAmp(data[idx + 1]);
|
||||
const xPos = plotX + x * scaleX + 0.5;
|
||||
g.beginPath();
|
||||
g.moveTo(xPos, ampToY(max, rect.y, rect.h));
|
||||
g.lineTo(xPos, ampToY(min, rect.y, rect.h));
|
||||
g.stroke();
|
||||
}
|
||||
g.restore();
|
||||
};
|
||||
|
||||
if (mode === MODE_DIFF) {
|
||||
const rect = rects[0];
|
||||
const diffData = buildDiffMinMax(state, payload.minMaxL, payload.minMaxR, payload.pixelWidth);
|
||||
if (diffData) {
|
||||
const col = cfg.diffColor || FRAME_COLOR;
|
||||
drawChannel(diffData, rect, col, 1);
|
||||
}
|
||||
} else if (mode === MODE_STACKED && payload.channels > 1) {
|
||||
const colL = cfg.leftColor || FRAME_COLOR;
|
||||
const colR = cfg.rightColor || '#ff6b81';
|
||||
drawChannel(payload.minMaxL, rects[0], colL, 1);
|
||||
drawChannel(payload.minMaxR || payload.minMaxL, rects[1], colR, 1);
|
||||
} else {
|
||||
const rect = rects[0];
|
||||
const colL = cfg.leftColor || FRAME_COLOR;
|
||||
const colR = cfg.rightColor || '#ff6b81';
|
||||
drawChannel(payload.minMaxL, rect, colL, 1);
|
||||
if (payload.channels > 1 && payload.minMaxR) {
|
||||
drawChannel(payload.minMaxR, rect, colR, 0.75);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildDiffMinMax(state, minMaxL, minMaxR) {
|
||||
if (!minMaxL || !minMaxR) return null;
|
||||
const pairs = Math.min(minMaxL.length, minMaxR.length) / 2;
|
||||
if (!pairs || pairs <= 0) return null;
|
||||
if (!state.diffScratch || state.diffScratch.length !== pairs * 2) {
|
||||
state.diffScratch = new Float32Array(pairs * 2);
|
||||
}
|
||||
const out = state.diffScratch;
|
||||
for (let i = 0; i < pairs; i++) {
|
||||
const idx = i * 2;
|
||||
const lMin = clampAmp(minMaxL[idx]);
|
||||
const lMax = clampAmp(minMaxL[idx + 1]);
|
||||
const rMin = clampAmp(minMaxR[idx]);
|
||||
const rMax = clampAmp(minMaxR[idx + 1]);
|
||||
|
||||
// Korrigierte Differenz-Berechnung
|
||||
const diffMin = lMin - rMin;
|
||||
const diffMax = lMax - rMax;
|
||||
|
||||
out[idx] = clampAmp(Math.min(diffMin, diffMax));
|
||||
out[idx + 1] = clampAmp(Math.max(diffMin, diffMax));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function setupWaveformWorker(state, width, height, mode, colors) {
|
||||
const workerColors = normalizeWaveformColors(colors);
|
||||
const workerColorsKey = getWaveformColorsKey(workerColors);
|
||||
if (!state.worker) {
|
||||
const canvas = ensureWaveCanvasElement(state);
|
||||
if (!canvas) {
|
||||
state.useWorker = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!state.canvasTransferred) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
}
|
||||
const offscreen = canvas.transferControlToOffscreen();
|
||||
const workerUrl = new URL('../workers/waveform.worker.js', import.meta.url);
|
||||
const worker = new Worker(workerUrl, { type: 'module' });
|
||||
worker.onmessage = (event) => {
|
||||
if (event.data?.type === 'ready') state.workerReady = true;
|
||||
};
|
||||
worker.onerror = (err) => console.warn('Waveform worker error:', err?.message || err);
|
||||
worker.postMessage({
|
||||
type: 'init',
|
||||
canvas: offscreen,
|
||||
width,
|
||||
height,
|
||||
mode,
|
||||
colors: workerColors,
|
||||
}, [offscreen]);
|
||||
state.worker = worker;
|
||||
state.workerReady = false;
|
||||
state.offscreenWidth = width;
|
||||
state.offscreenHeight = height;
|
||||
state.canvasTransferred = true;
|
||||
state.lastMode = mode;
|
||||
state.lastWorkerColorsKey = workerColorsKey;
|
||||
} catch (err) {
|
||||
console.warn('Waveform worker init failed, using fallback:', err);
|
||||
state.useWorker = false;
|
||||
teardownWorker(state);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.offscreenWidth !== width || state.offscreenHeight !== height) {
|
||||
state.offscreenWidth = width;
|
||||
state.offscreenHeight = height;
|
||||
state.worker.postMessage({ type: 'resize', width, height });
|
||||
}
|
||||
if (state.lastMode !== mode || state.lastWorkerColorsKey !== workerColorsKey) {
|
||||
state.lastMode = mode;
|
||||
state.lastWorkerColorsKey = workerColorsKey;
|
||||
state.worker.postMessage({ type: 'config', mode, colors: workerColors });
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWaveformColors(colors) {
|
||||
return {
|
||||
leftColor: colors?.leftColor || FRAME_COLOR,
|
||||
rightColor: colors?.rightColor || '#ff6b81',
|
||||
diffColor: colors?.diffColor || FRAME_COLOR,
|
||||
};
|
||||
}
|
||||
|
||||
function getWaveformColorsKey(colors) {
|
||||
return [colors.leftColor, colors.rightColor, colors.diffColor].join('|');
|
||||
}
|
||||
|
||||
function sendWaveformToWorker(state, payload) {
|
||||
if (!state.worker) return;
|
||||
state.worker.postMessage({
|
||||
type: 'data',
|
||||
channelCount: payload.channels,
|
||||
mode: payload.mode,
|
||||
minMaxL: payload.minMaxL,
|
||||
minMaxR: payload.minMaxR,
|
||||
});
|
||||
}
|
||||
|
||||
function teardownWorker(state) {
|
||||
if (state.worker) {
|
||||
try { state.worker.postMessage({ type: 'dispose' }); } catch (_) {}
|
||||
try { state.worker.terminate(); } catch (_) {}
|
||||
}
|
||||
state.worker = null;
|
||||
state.workerReady = false;
|
||||
state.canvasTransferred = false;
|
||||
state.lastWorkerColorsKey = '';
|
||||
removeWaveCanvasElement(state);
|
||||
}
|
||||
|
||||
function layoutWaveCanvas(state, plotX, plotY, plotW, plotH, widthPx, heightPx) {
|
||||
const canvas = ensureWaveCanvasElement(state);
|
||||
if (!canvas) return;
|
||||
canvas.style.left = `${plotX}px`;
|
||||
canvas.style.top = `${plotY}px`;
|
||||
canvas.style.width = `${plotW}px`;
|
||||
canvas.style.height = `${plotH}px`;
|
||||
canvas.style.display = plotW > 0 && plotH > 0 ? 'block' : 'none';
|
||||
if (!state.canvasTransferred) {
|
||||
canvas.width = widthPx;
|
||||
canvas.height = heightPx;
|
||||
}
|
||||
}
|
||||
|
||||
function detachWaveCanvas(state) {
|
||||
const canvas = ensureWaveCanvasElement(state);
|
||||
if (canvas) {
|
||||
canvas.style.width = '0px';
|
||||
canvas.style.height = '0px';
|
||||
canvas.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function ensureWaveCanvasElement(state) {
|
||||
if (state.canvasEl) return state.canvasEl;
|
||||
let canvas = document.getElementById('waveformCanvas');
|
||||
if (!canvas) {
|
||||
canvas = document.createElement('canvas');
|
||||
canvas.id = 'waveformCanvas';
|
||||
canvas.className = 'waveform-layer';
|
||||
const overlay = document.getElementById('cv');
|
||||
if (overlay && overlay.parentNode) {
|
||||
overlay.parentNode.insertBefore(canvas, overlay);
|
||||
} else {
|
||||
document.body.appendChild(canvas);
|
||||
}
|
||||
}
|
||||
state.canvasEl = canvas;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function removeWaveCanvasElement(state) {
|
||||
const canvas = state.canvasEl || document.getElementById('waveformCanvas');
|
||||
if (canvas && canvas.parentNode) {
|
||||
canvas.parentNode.removeChild(canvas);
|
||||
}
|
||||
state.canvasEl = null;
|
||||
state.canvasTransferred = false;
|
||||
}
|
||||
|
||||
function clampAmp(value) {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.max(-1, Math.min(1, value));
|
||||
}
|
||||
|
||||
function ampToY(value, y, h) {
|
||||
const clamped = clampAmp(value);
|
||||
return y + (1 - ((clamped + 1) * 0.5)) * h;
|
||||
}
|
||||
|
||||
function getChannelRects(plotY, plotH, channelCount, mode) {
|
||||
if (mode === MODE_DIFF) {
|
||||
return [{ y: plotY, h: plotH }];
|
||||
}
|
||||
if (mode === MODE_STACKED && channelCount > 1) {
|
||||
const gap = 8;
|
||||
const half = (plotH - gap) / 2;
|
||||
return [
|
||||
{ y: plotY, h: half },
|
||||
{ y: plotY + half + gap, h: half },
|
||||
];
|
||||
}
|
||||
return [{ y: plotY, h: plotH }];
|
||||
}
|
||||
|
||||
async function drawMeterPanel(g, plotX, plotY, plotW, plotH, CONFIG, meters, activeMeter) {
|
||||
const meterRect = { x: plotX + plotW + METER_GAP, y: plotY, w: METER_WIDTH, h: plotH };
|
||||
g.save();
|
||||
g.fillStyle = PANEL_BG;
|
||||
g.fillRect(meterRect.x, meterRect.y, meterRect.w, meterRect.h);
|
||||
g.restore();
|
||||
g.strokeStyle = FRAME_COLOR;
|
||||
g.lineWidth = 2;
|
||||
const active = activeMeter || 'ppm-din';
|
||||
if (active === 'none') return;
|
||||
const shrink = Math.max(0, METER_SLOT_SHRINK);
|
||||
const innerHeight = Math.max(40, meterRect.h - METER_PAD_TOP - METER_PAD_BOTTOM - shrink - METER_EXTRA_BOTTOM_PAD);
|
||||
const innerOffset = shrink / 2;
|
||||
const innerRect = {
|
||||
x: meterRect.x,
|
||||
y: meterRect.y + METER_PAD_TOP + innerOffset,
|
||||
w: meterRect.w,
|
||||
h: innerHeight,
|
||||
};
|
||||
g.save();
|
||||
g.beginPath();
|
||||
g.rect(meterRect.x + g.lineWidth / 2, meterRect.y + g.lineWidth / 2, meterRect.w - g.lineWidth, meterRect.h - g.lineWidth);
|
||||
g.clip();
|
||||
await meters.draw(g, innerRect, active, CONFIG);
|
||||
g.restore();
|
||||
g.strokeRect(meterRect.x, meterRect.y, meterRect.w, meterRect.h);
|
||||
}
|
||||
|
||||
function clampWindow(value) {
|
||||
if (!Number.isFinite(value)) return 1;
|
||||
const min = 0.1;
|
||||
const max = 15;
|
||||
if (value < min) return min;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeMode(mode) {
|
||||
if (mode === MODE_OVERLAY) return MODE_OVERLAY;
|
||||
if (mode === MODE_DIFF) return MODE_DIFF;
|
||||
return MODE_STACKED;
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
// workers/spectrogram.worker.js
|
||||
// Handles Spectrogram rendering off the main thread using OffscreenCanvas.
|
||||
|
||||
const FRAME_INTERVAL_MS = 1000 / 24;
|
||||
const MAX_QUEUE_AGE_MS = 2000; // wenn länger nichts geflossen ist, trotzdem zeichnen
|
||||
const MAX_PENDING_COLUMNS = 64;
|
||||
const LUT_SIZE = 256;
|
||||
const COLOR_STOPS = [
|
||||
{ t: 0.0, color: [0, 0, 0] },
|
||||
{ t: 0.25, color: [0, 0, 80] },
|
||||
{ t: 0.5, color: [0, 135, 140] },
|
||||
{ t: 0.75, color: [220, 220, 0] },
|
||||
{ t: 1.0, color: [255, 255, 255] },
|
||||
];
|
||||
|
||||
// Pre-calculated constants for performance
|
||||
const LUT_SIZE_MINUS_ONE = LUT_SIZE - 1;
|
||||
|
||||
const state = {
|
||||
canvas: null,
|
||||
ctx: null,
|
||||
width: 0,
|
||||
height: 0,
|
||||
topDb: -9,
|
||||
bottomDb: -90,
|
||||
gamma: 0.9,
|
||||
fMin: 20,
|
||||
fMax: 20000,
|
||||
history: null,
|
||||
writeIndex: 0,
|
||||
imgData: null,
|
||||
pixels: null,
|
||||
lut: null,
|
||||
lastDraw: 0,
|
||||
drawTimer: null,
|
||||
pendingQueue: [],
|
||||
lastColumnTs: 0,
|
||||
};
|
||||
|
||||
self.onmessage = (event) => {
|
||||
const data = event.data || {};
|
||||
switch (data.type) {
|
||||
case 'init':
|
||||
handleInit(data);
|
||||
break;
|
||||
case 'resize':
|
||||
handleResize(data);
|
||||
break;
|
||||
case 'config':
|
||||
handleConfig(data);
|
||||
break;
|
||||
case 'column':
|
||||
handleColumn(data);
|
||||
break;
|
||||
case 'dispose':
|
||||
dispose();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
function handleInit({ canvas, width, height, topDb, bottomDb, gamma, fMin, fMax }) {
|
||||
if (!canvas) return;
|
||||
|
||||
state.canvas = canvas;
|
||||
state.ctx = canvas.getContext('2d', { alpha: false, desynchronized: true });
|
||||
|
||||
if (!state.ctx) {
|
||||
postMessage({ type: 'error', error: 'ctx' });
|
||||
return;
|
||||
}
|
||||
|
||||
state.ctx.imageSmoothingEnabled = false;
|
||||
state.topDb = Number.isFinite(topDb) ? topDb : state.topDb;
|
||||
state.bottomDb = Number.isFinite(bottomDb) ? bottomDb : state.bottomDb;
|
||||
state.fMin = Number.isFinite(fMin) ? fMin : state.fMin;
|
||||
state.fMax = Number.isFinite(fMax) ? fMax : state.fMax;
|
||||
state.gamma = clampGamma(gamma);
|
||||
|
||||
allocateBuffers(width, height);
|
||||
rebuildLut();
|
||||
|
||||
postMessage({ type: 'ready' });
|
||||
}
|
||||
|
||||
function handleResize({ width, height, fMin, fMax }) {
|
||||
if (!state.canvas || !state.ctx) return;
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return;
|
||||
|
||||
allocateBuffers(width, height);
|
||||
|
||||
if (Number.isFinite(fMin)) state.fMin = fMin;
|
||||
if (Number.isFinite(fMax)) state.fMax = fMax;
|
||||
}
|
||||
|
||||
function handleConfig({ topDb, bottomDb, gamma }) {
|
||||
if (Number.isFinite(topDb)) state.topDb = topDb;
|
||||
if (Number.isFinite(bottomDb)) state.bottomDb = bottomDb;
|
||||
if (Number.isFinite(gamma)) state.gamma = clampGamma(gamma);
|
||||
rebuildLut();
|
||||
}
|
||||
|
||||
function handleColumn({ data }) {
|
||||
if (!data || !(data instanceof Float32Array)) return;
|
||||
|
||||
if (!state.history || state.height !== data.length) {
|
||||
allocateBuffers(state.width, data.length);
|
||||
}
|
||||
|
||||
// Ringpuffer: halte nur die letzten wenige Columns, neueste gewinnt
|
||||
state.pendingQueue.push(data);
|
||||
while (state.pendingQueue.length > MAX_PENDING_COLUMNS) state.pendingQueue.shift();
|
||||
state.lastColumnTs = performance.now();
|
||||
maybeDraw();
|
||||
}
|
||||
|
||||
function allocateBuffers(width, height) {
|
||||
const clampedWidth = Math.max(1, Math.floor(width));
|
||||
const clampedHeight = Math.max(1, Math.floor(height));
|
||||
|
||||
// Vermeide unnötige Re-allokation
|
||||
if (state.history &&
|
||||
state.width === clampedWidth &&
|
||||
state.height === clampedHeight) {
|
||||
return; // Keine Größenänderung, behalte bestehende Buffers
|
||||
}
|
||||
|
||||
state.width = clampedWidth;
|
||||
state.height = clampedHeight;
|
||||
|
||||
if (state.canvas) {
|
||||
state.canvas.width = clampedWidth;
|
||||
state.canvas.height = clampedHeight;
|
||||
}
|
||||
|
||||
// Allokiere neuen Buffer nur wenn nötig
|
||||
const neededSize = clampedWidth * clampedHeight;
|
||||
if (!state.history || state.history.length !== neededSize) {
|
||||
state.history = new Float32Array(neededSize);
|
||||
}
|
||||
state.history.fill(state.bottomDb);
|
||||
|
||||
state.writeIndex = 0;
|
||||
|
||||
if (state.ctx) {
|
||||
state.imgData = state.ctx.createImageData(clampedWidth, clampedHeight);
|
||||
state.pixels = state.imgData.data;
|
||||
}
|
||||
}
|
||||
|
||||
function rebuildLut() {
|
||||
state.lut = buildLut(LUT_SIZE);
|
||||
}
|
||||
|
||||
function maybeDraw() {
|
||||
if (!state.ctx || !state.imgData || !state.history) return;
|
||||
|
||||
const now = performance.now();
|
||||
const delta = now - state.lastDraw;
|
||||
|
||||
const tooLongNoColumn = state.lastColumnTs && (now - state.lastColumnTs > MAX_QUEUE_AGE_MS);
|
||||
const readyToDraw = delta >= FRAME_INTERVAL_MS || tooLongNoColumn;
|
||||
|
||||
if (readyToDraw) {
|
||||
if (drawFrame()) {
|
||||
state.lastDraw = now;
|
||||
}
|
||||
} else if (!state.drawTimer) {
|
||||
state.drawTimer = setTimeout(() => {
|
||||
state.drawTimer = null;
|
||||
if (drawFrame()) {
|
||||
state.lastDraw = performance.now();
|
||||
}
|
||||
}, FRAME_INTERVAL_MS - delta);
|
||||
}
|
||||
}
|
||||
|
||||
function drawFrame() {
|
||||
if (!state.ctx || !state.imgData || !state.history || !state.lut) return false;
|
||||
if (!flushPendingColumn()) return false;
|
||||
|
||||
const { width, height, history, pixels, imgData } = state;
|
||||
const range = Math.max(1e-3, state.topDb - state.bottomDb);
|
||||
const lut = state.lut;
|
||||
const base = state.writeIndex;
|
||||
|
||||
const invertedHeight = height - 1;
|
||||
|
||||
for (let x = 0; x < width; x++) {
|
||||
const srcX = (base + x) % width;
|
||||
const srcXOffset = srcX;
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
const dB = history[y * width + srcXOffset];
|
||||
const clamped = dB < state.bottomDb ? state.bottomDb :
|
||||
dB > state.topDb ? state.topDb : dB;
|
||||
|
||||
let norm = (clamped - state.bottomDb) / range;
|
||||
norm = norm < 0 ? 0 : norm > 1 ? 1 : norm;
|
||||
norm = Math.pow(norm, state.gamma);
|
||||
|
||||
const lutIdx = Math.min(LUT_SIZE_MINUS_ONE, Math.round(norm * LUT_SIZE_MINUS_ONE));
|
||||
const destY = invertedHeight - y;
|
||||
const pixelIndex = (destY * width + x) * 4;
|
||||
const lutOffset = lutIdx * 3;
|
||||
|
||||
pixels[pixelIndex + 0] = lut[lutOffset + 0];
|
||||
pixels[pixelIndex + 1] = lut[lutOffset + 1];
|
||||
pixels[pixelIndex + 2] = lut[lutOffset + 2];
|
||||
pixels[pixelIndex + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
state.ctx.putImageData(imgData, 0, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
function flushPendingColumn() {
|
||||
if (!state.pendingQueue.length || !state.history) return false;
|
||||
|
||||
const width = state.width;
|
||||
const height = state.height;
|
||||
let writeIdx = state.writeIndex;
|
||||
const hist = state.history;
|
||||
|
||||
while (state.pendingQueue.length) {
|
||||
const column = state.pendingQueue.shift();
|
||||
if (!column) continue;
|
||||
const col = (column.length === height)
|
||||
? column
|
||||
: normalizeColumn(column, height, state.bottomDb);
|
||||
for (let y = 0; y < height; y++) {
|
||||
const value = col[y];
|
||||
hist[y * width + writeIdx] = Number.isFinite(value) ? value : state.bottomDb;
|
||||
}
|
||||
writeIdx = (writeIdx + 1) % width;
|
||||
}
|
||||
|
||||
state.writeIndex = writeIdx;
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeColumn(column, targetHeight, fillDb) {
|
||||
const adjusted = new Float32Array(targetHeight);
|
||||
const copyLength = Math.min(column.length, targetHeight);
|
||||
adjusted.set(column.subarray(0, copyLength));
|
||||
adjusted.fill(fillDb, copyLength);
|
||||
return adjusted;
|
||||
}
|
||||
|
||||
function buildLut(size) {
|
||||
const lut = new Uint8ClampedArray(size * 3);
|
||||
const sizeMinusOne = size - 1;
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
const t = i / sizeMinusOne;
|
||||
const [r, g, b] = colorFromStops(t);
|
||||
const offset = i * 3;
|
||||
lut[offset + 0] = r;
|
||||
lut[offset + 1] = g;
|
||||
lut[offset + 2] = b;
|
||||
}
|
||||
|
||||
return lut;
|
||||
}
|
||||
|
||||
function colorFromStops(tValue) {
|
||||
const t = tValue < 0 ? 0 : tValue > 1 ? 1 : tValue;
|
||||
|
||||
for (let i = 1; i < COLOR_STOPS.length; i++) {
|
||||
const left = COLOR_STOPS[i - 1];
|
||||
const right = COLOR_STOPS[i];
|
||||
|
||||
if (t <= right.t) {
|
||||
const span = right.t - left.t || 1;
|
||||
const rel = (t - left.t) / span;
|
||||
return [
|
||||
Math.round(left.color[0] + rel * (right.color[0] - left.color[0])),
|
||||
Math.round(left.color[1] + rel * (right.color[1] - left.color[1])),
|
||||
Math.round(left.color[2] + rel * (right.color[2] - left.color[2])),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
const last = COLOR_STOPS[COLOR_STOPS.length - 1].color;
|
||||
return [last[0], last[1], last[2]];
|
||||
}
|
||||
|
||||
function clampGamma(value) {
|
||||
if (!Number.isFinite(value)) return 0.9;
|
||||
if (value < 0.3) return 0.3;
|
||||
if (value > 1.2) return 1.2;
|
||||
return value;
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
if (state.drawTimer) {
|
||||
clearTimeout(state.drawTimer);
|
||||
state.drawTimer = null;
|
||||
}
|
||||
|
||||
if (state.history) {
|
||||
state.history = null;
|
||||
}
|
||||
if (state.imgData) {
|
||||
state.imgData = null;
|
||||
}
|
||||
if (state.pixels) {
|
||||
state.pixels = null;
|
||||
}
|
||||
if (state.lut) {
|
||||
state.lut = null;
|
||||
}
|
||||
state.pendingColumn = null;
|
||||
state.canvas = null;
|
||||
state.ctx = null;
|
||||
|
||||
if (typeof self.close === 'function') {
|
||||
try { self.close(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// workers/waveform.worker.js — Renders min/max waveform data on OffscreenCanvas
|
||||
|
||||
const FRAME_INTERVAL_MS = 1000 / 48;
|
||||
const DEFAULT_COLORS = {
|
||||
leftColor: '#00e7ff',
|
||||
rightColor: '#ff6b81',
|
||||
diffColor: '#00e7ff',
|
||||
};
|
||||
const MODE_STACKED = 'stacked';
|
||||
const MODE_OVERLAY = 'overlay';
|
||||
const MODE_DIFF = 'diff';
|
||||
|
||||
const state = {
|
||||
canvas: null,
|
||||
ctx: null,
|
||||
width: 0,
|
||||
height: 0,
|
||||
mode: MODE_STACKED,
|
||||
colors: { ...DEFAULT_COLORS },
|
||||
pending: null,
|
||||
lastDraw: 0,
|
||||
timer: null,
|
||||
};
|
||||
|
||||
self.onmessage = (event) => {
|
||||
const data = event.data || {};
|
||||
switch (data.type) {
|
||||
case 'init':
|
||||
handleInit(data);
|
||||
break;
|
||||
case 'resize':
|
||||
handleResize(data);
|
||||
break;
|
||||
case 'config':
|
||||
handleConfig(data);
|
||||
break;
|
||||
case 'data':
|
||||
handleData(data);
|
||||
break;
|
||||
case 'dispose':
|
||||
dispose();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
function handleInit({ canvas, width, height, mode, colors }) {
|
||||
if (!canvas) return;
|
||||
state.canvas = canvas;
|
||||
state.ctx = canvas.getContext('2d', { alpha: true, desynchronized: true });
|
||||
state.mode = normalizeMode(mode);
|
||||
state.colors = normalizeColors(colors);
|
||||
handleResize({ width, height });
|
||||
postMessage({ type: 'ready' });
|
||||
}
|
||||
|
||||
function handleResize({ width, height }) {
|
||||
if (!state.canvas || !Number.isFinite(width) || !Number.isFinite(height)) return;
|
||||
state.canvas.width = Math.max(1, Math.floor(width));
|
||||
state.canvas.height = Math.max(1, Math.floor(height));
|
||||
state.width = state.canvas.width;
|
||||
state.height = state.canvas.height;
|
||||
}
|
||||
|
||||
function handleConfig({ mode, colors }) {
|
||||
state.mode = normalizeMode(mode);
|
||||
state.colors = normalizeColors(colors);
|
||||
}
|
||||
|
||||
function handleData({ minMaxL, minMaxR, channelCount }) {
|
||||
state.pending = {
|
||||
minMaxL,
|
||||
minMaxR,
|
||||
channelCount: channelCount || (minMaxR ? 2 : 1),
|
||||
};
|
||||
scheduleDraw();
|
||||
}
|
||||
|
||||
function scheduleDraw() {
|
||||
if (!state.ctx || !state.pending) return;
|
||||
const now = performance.now();
|
||||
const delta = now - state.lastDraw;
|
||||
if (delta >= FRAME_INTERVAL_MS) {
|
||||
drawFrame();
|
||||
state.lastDraw = now;
|
||||
} else if (!state.timer) {
|
||||
state.timer = setTimeout(() => {
|
||||
state.timer = null;
|
||||
drawFrame();
|
||||
state.lastDraw = performance.now();
|
||||
}, FRAME_INTERVAL_MS - delta);
|
||||
}
|
||||
}
|
||||
|
||||
function drawFrame() {
|
||||
if (!state.ctx || !state.pending) return;
|
||||
const { minMaxL, minMaxR, channelCount } = state.pending;
|
||||
state.pending = null;
|
||||
state.ctx.clearRect(0, 0, state.width, state.height);
|
||||
const rects = getChannelRects(state.height, channelCount, state.mode);
|
||||
if (state.mode === MODE_DIFF) {
|
||||
const diffData = buildDiffData(minMaxL, minMaxR);
|
||||
if (diffData) {
|
||||
drawChannel(state.ctx, diffData, rects[0], state.colors.diffColor);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (state.mode === MODE_STACKED && channelCount > 1 && rects.length >= 2) {
|
||||
drawChannel(state.ctx, minMaxL, rects[0], state.colors.leftColor);
|
||||
drawChannel(state.ctx, minMaxR || minMaxL, rects[1], state.colors.rightColor);
|
||||
} else {
|
||||
drawChannel(state.ctx, minMaxL, rects[0], state.colors.leftColor);
|
||||
if (channelCount > 1 && minMaxR) {
|
||||
drawChannel(state.ctx, minMaxR, rects[0], state.colors.rightColor, 0.75);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeColors(colors) {
|
||||
return {
|
||||
leftColor: colors?.leftColor || DEFAULT_COLORS.leftColor,
|
||||
rightColor: colors?.rightColor || DEFAULT_COLORS.rightColor,
|
||||
diffColor: colors?.diffColor || DEFAULT_COLORS.diffColor,
|
||||
};
|
||||
}
|
||||
|
||||
function drawChannel(ctx, data, rect, color, alpha = 1) {
|
||||
if (!data || !data.length) return;
|
||||
const width = data.length / 2;
|
||||
const scaleX = width ? state.width / width : 1;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = color;
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (let x = 0; x < width; x++) {
|
||||
const idx = x * 2;
|
||||
const min = clampAmp(data[idx]);
|
||||
const max = clampAmp(data[idx + 1]);
|
||||
const xPos = x * scaleX + 0.5;
|
||||
const yMax = ampToY(max, rect.y, rect.h);
|
||||
const yMin = ampToY(min, rect.y, rect.h);
|
||||
ctx.moveTo(xPos, yMax);
|
||||
ctx.lineTo(xPos, yMin);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function getChannelRects(totalHeight, channelCount, mode) {
|
||||
if (mode === MODE_DIFF) {
|
||||
return [{ y: 0, h: totalHeight }];
|
||||
}
|
||||
if (mode === MODE_STACKED && channelCount > 1) {
|
||||
const gap = 6;
|
||||
const half = (totalHeight - gap) / 2;
|
||||
return [
|
||||
{ y: 0, h: half },
|
||||
{ y: half + gap, h: half },
|
||||
];
|
||||
}
|
||||
return [{ y: 0, h: totalHeight }];
|
||||
}
|
||||
|
||||
function ampToY(value, y, h) {
|
||||
const v = clampAmp(value);
|
||||
return y + (1 - ((v + 1) * 0.5)) * h;
|
||||
}
|
||||
|
||||
function clampAmp(value) {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.max(-1, Math.min(1, value));
|
||||
}
|
||||
|
||||
function normalizeMode(mode) {
|
||||
if (mode === MODE_OVERLAY) return MODE_OVERLAY;
|
||||
if (mode === MODE_DIFF) return MODE_DIFF;
|
||||
return MODE_STACKED;
|
||||
}
|
||||
|
||||
function buildDiffData(minMaxL, minMaxR) {
|
||||
if (!minMaxL || !minMaxR) return null;
|
||||
const pairs = Math.min(minMaxL.length, minMaxR.length) / 2;
|
||||
if (!pairs) return null;
|
||||
const out = new Float32Array(pairs * 2);
|
||||
for (let i = 0; i < pairs; i++) {
|
||||
const idx = i * 2;
|
||||
const lMin = clampAmp(minMaxL[idx]);
|
||||
const lMax = clampAmp(minMaxL[idx + 1]);
|
||||
const rMin = clampAmp(minMaxR[idx]);
|
||||
const rMax = clampAmp(minMaxR[idx + 1]);
|
||||
|
||||
// Korrigierte Differenz-Berechnung
|
||||
const diffMin = lMin - rMin;
|
||||
const diffMax = lMax - rMax;
|
||||
|
||||
out[idx] = clampAmp(Math.min(diffMin, diffMax));
|
||||
out[idx + 1] = clampAmp(Math.max(diffMin, diffMax));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
if (state.timer) {
|
||||
clearTimeout(state.timer);
|
||||
state.timer = null;
|
||||
}
|
||||
state.pending = null;
|
||||
state.canvas = null;
|
||||
state.ctx = null;
|
||||
}
|
||||
Reference in New Issue
Block a user