From e499bac928d3cc3881d85989e10b054fca969c0b Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:56:19 +0200 Subject: [PATCH] Initial Phoenix analyzer --- .gitignore | 21 + Cargo.lock | 1024 ++++++ Cargo.toml | 21 + RASPBERRY_PI_SETUP.txt | 108 + README.md | 241 ++ .../system/analyzer-kiosk-volumio.service | 26 + etc/systemd/system/analyzer-kiosk.service | 36 + .../system/analyzer-web-volumio.service | 16 + etc/systemd/system/analyzer-web.service | 18 + kiosk-start.sh | 223 ++ .../phoenix-global-config.analyzer.json | 35 + .../phoenix-global-config.volumio.json | 35 + scripts/restart_phoenix_services.sh | 225 ++ scripts/update_phoenix_from_zip.sh | 102 + session.sh | 164 + session_volumio.sh | 230 ++ src/audio.rs | 2104 +++++++++++++ src/config.rs | 154 + src/main.rs | 59 + src/model.rs | 301 ++ src/routes.rs | 984 ++++++ src/state.rs | 573 ++++ toggle-ext/bg.js | 16 + toggle-ext/content.js | 1 + toggle-ext/manifest.json | 7 + www/assets/dvdlogo.svg | 1 + www/core/audio.js | 920 ++++++ www/core/config.js | 1252 ++++++++ www/core/registry.js | 228 ++ www/core/rtw_centers.js | 43 + www/core/screensaver.js | 642 ++++ www/core/theme.js | 11 + www/core/utils.js | 318 ++ www/index.html | 1892 ++++++++++++ www/main.js | 2740 +++++++++++++++++ www/meters/hifi_peak.js | 334 ++ www/meters/lufs.js | 156 + www/meters/ppm_din.js | 571 ++++ www/meters/ppm_ebu.js | 404 +++ www/meters/rms.js | 503 +++ www/meters/scale_helpers.js | 44 + www/meters/static_layer.js | 49 + www/meters/stopwatch.js | 693 +++++ www/meters/tp.js | 317 ++ www/meters/vu.js | 398 +++ www/styles.css | 417 +++ www/ui/options.js | 1683 ++++++++++ www/views/classic_needles.js | 868 ++++++ www/views/clock.js | 449 +++ www/views/goniometer_rtw.js | 905 ++++++ www/views/panel.js | 169 + www/views/peak_history.js | 1010 ++++++ www/views/phase_wheel.js | 971 ++++++ www/views/quad_view.js | 588 ++++ www/views/realtime.js | 941 ++++++ www/views/recorder.js | 300 ++ www/views/spectrogram.js | 836 +++++ www/views/split_view.js | 471 +++ www/views/static_layer.js | 24 + www/views/waveform.js | 487 +++ www/workers/spectrogram.worker.js | 322 ++ www/workers/waveform.worker.js | 212 ++ 62 files changed, 28893 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 RASPBERRY_PI_SETUP.txt create mode 100644 README.md create mode 100644 etc/systemd/system/analyzer-kiosk-volumio.service create mode 100644 etc/systemd/system/analyzer-kiosk.service create mode 100644 etc/systemd/system/analyzer-web-volumio.service create mode 100644 etc/systemd/system/analyzer-web.service create mode 100644 kiosk-start.sh create mode 100644 reference-config/phoenix-global-config.analyzer.json create mode 100644 reference-config/phoenix-global-config.volumio.json create mode 100644 scripts/restart_phoenix_services.sh create mode 100644 scripts/update_phoenix_from_zip.sh create mode 100644 session.sh create mode 100644 session_volumio.sh create mode 100644 src/audio.rs create mode 100644 src/config.rs create mode 100644 src/main.rs create mode 100644 src/model.rs create mode 100644 src/routes.rs create mode 100644 src/state.rs create mode 100644 toggle-ext/bg.js create mode 100644 toggle-ext/content.js create mode 100644 toggle-ext/manifest.json create mode 100644 www/assets/dvdlogo.svg create mode 100644 www/core/audio.js create mode 100644 www/core/config.js create mode 100644 www/core/registry.js create mode 100644 www/core/rtw_centers.js create mode 100644 www/core/screensaver.js create mode 100644 www/core/theme.js create mode 100644 www/core/utils.js create mode 100644 www/index.html create mode 100644 www/main.js create mode 100644 www/meters/hifi_peak.js create mode 100644 www/meters/lufs.js create mode 100644 www/meters/ppm_din.js create mode 100644 www/meters/ppm_ebu.js create mode 100644 www/meters/rms.js create mode 100644 www/meters/scale_helpers.js create mode 100644 www/meters/static_layer.js create mode 100644 www/meters/stopwatch.js create mode 100644 www/meters/tp.js create mode 100644 www/meters/vu.js create mode 100644 www/styles.css create mode 100644 www/ui/options.js create mode 100644 www/views/classic_needles.js create mode 100644 www/views/clock.js create mode 100644 www/views/goniometer_rtw.js create mode 100644 www/views/panel.js create mode 100644 www/views/peak_history.js create mode 100644 www/views/phase_wheel.js create mode 100644 www/views/quad_view.js create mode 100644 www/views/realtime.js create mode 100644 www/views/recorder.js create mode 100644 www/views/spectrogram.js create mode 100644 www/views/split_view.js create mode 100644 www/views/static_layer.js create mode 100644 www/views/waveform.js create mode 100644 www/workers/spectrogram.worker.js create mode 100644 www/workers/waveform.worker.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3ea0917 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..90e4d62 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1024 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alsa" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3" +dependencies = [ + "alsa-sys", + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "axum-macros", + "base64", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phoenix" +version = "1.0.0" +dependencies = [ + "alsa", + "anyhow", + "axum", + "futures-util", + "http-body-util", + "serde", + "serde_json", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9798cd8 --- /dev/null +++ b/Cargo.toml @@ -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" diff --git a/RASPBERRY_PI_SETUP.txt b/RASPBERRY_PI_SETUP.txt new file mode 100644 index 0000000..c27cb25 --- /dev/null +++ b/RASPBERRY_PI_SETUP.txt @@ -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://: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:/// +- Phoenix: http://: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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2f01478 --- /dev/null +++ b/README.md @@ -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//Phoenix/reference-config/phoenix-global-config..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 +``` diff --git a/etc/systemd/system/analyzer-kiosk-volumio.service b/etc/systemd/system/analyzer-kiosk-volumio.service new file mode 100644 index 0000000..9673497 --- /dev/null +++ b/etc/systemd/system/analyzer-kiosk-volumio.service @@ -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 diff --git a/etc/systemd/system/analyzer-kiosk.service b/etc/systemd/system/analyzer-kiosk.service new file mode 100644 index 0000000..1d250f7 --- /dev/null +++ b/etc/systemd/system/analyzer-kiosk.service @@ -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 diff --git a/etc/systemd/system/analyzer-web-volumio.service b/etc/systemd/system/analyzer-web-volumio.service new file mode 100644 index 0000000..6be0b1d --- /dev/null +++ b/etc/systemd/system/analyzer-web-volumio.service @@ -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 diff --git a/etc/systemd/system/analyzer-web.service b/etc/systemd/system/analyzer-web.service new file mode 100644 index 0000000..74c4839 --- /dev/null +++ b/etc/systemd/system/analyzer-web.service @@ -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 diff --git a/kiosk-start.sh b/kiosk-start.sh new file mode 100644 index 0000000..fbe6cb5 --- /dev/null +++ b/kiosk-start.sh @@ -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 </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 diff --git a/reference-config/phoenix-global-config.analyzer.json b/reference-config/phoenix-global-config.analyzer.json new file mode 100644 index 0000000..843fbd8 --- /dev/null +++ b/reference-config/phoenix-global-config.analyzer.json @@ -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" +} diff --git a/reference-config/phoenix-global-config.volumio.json b/reference-config/phoenix-global-config.volumio.json new file mode 100644 index 0000000..f80c2ac --- /dev/null +++ b/reference-config/phoenix-global-config.volumio.json @@ -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" +} diff --git a/scripts/restart_phoenix_services.sh b/scripts/restart_phoenix_services.sh new file mode 100644 index 0000000..6af857d --- /dev/null +++ b/scripts/restart_phoenix_services.sh @@ -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 ===" diff --git a/scripts/update_phoenix_from_zip.sh b/scripts/update_phoenix_from_zip.sh new file mode 100644 index 0000000..ecf5016 --- /dev/null +++ b/scripts/update_phoenix_from_zip.sh @@ -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 ===" diff --git a/session.sh b/session.sh new file mode 100644 index 0000000..f7e519c --- /dev/null +++ b/session.sh @@ -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 ===" diff --git a/session_volumio.sh b/session_volumio.sh new file mode 100644 index 0000000..9354edc --- /dev/null +++ b/session_volumio.sh @@ -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 ===" diff --git a/src/audio.rs b/src/audio.rs new file mode 100644 index 0000000..bac6ddb --- /dev/null +++ b/src/audio.rs @@ -0,0 +1,2104 @@ +use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + Mutex, + }, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +#[cfg(target_os = "linux")] +use std::collections::VecDeque; + +use tracing::warn; + +use crate::{ + config::PhoenixConfig, + model::{InputSource, MeterFrame, PhoenixRtaConfig}, +}; +#[cfg(target_os = "linux")] +use crate::model::{RtaFrame, SpectroFrame, WaveEnvFrame}; +use crate::state::NativeWavRecorder; + +#[cfg(target_os = "linux")] +const XY_SEND_INTERVAL_FRAMES: usize = 2; +#[cfg(target_os = "linux")] +const XY_HISTORY_SAMPLES: usize = 2048; +#[cfg(target_os = "linux")] +const RTA_PEAK_DECAY_DB_PER_S: f32 = 10.0; +#[cfg(target_os = "linux")] +const RTA_PEAK_FLOOR_DB: f32 = -150.0; +#[cfg(target_os = "linux")] +const WAVE_ENV_COLUMNS_PER_SEC: f32 = 9600.0; +#[cfg(target_os = "linux")] +const LUFS_BLOCK_MS: f32 = 400.0; +#[cfg(target_os = "linux")] +const LUFS_SHORT_WINDOW_MS: f32 = 3000.0; +#[cfg(target_os = "linux")] +const LUFS_INT_WINDOW_MIN: usize = 4; +#[cfg(target_os = "linux")] +const LUFS_LRA_HISTORY_MS: f32 = 15.0 * 60.0 * 1000.0; +#[cfg(target_os = "linux")] +const LUFS_ABS_GATE: f32 = -70.0; +#[cfg(target_os = "linux")] +const RTW_LOUDNESS_WINDOW_MS: f32 = 400.0; +#[cfg(target_os = "linux")] +const BOX_MIN_DB: f32 = -90.0; +#[cfg(target_os = "linux")] +const BOX_MAX_DB: f32 = 9.0; +#[cfg(target_os = "linux")] +const VU_WINDOW_MS: f32 = 300.0; +#[cfg(target_os = "linux")] +const VU_RECT_TO_RMS_GAIN: f32 = std::f32::consts::PI / (2.0 * std::f32::consts::SQRT_2); +#[cfg(target_os = "linux")] +const PPM_RECT_TO_PEAK_GAIN: f32 = std::f32::consts::PI / 2.0; +#[cfg(target_os = "linux")] +const TRUE_PEAK_OVERSAMPLE: usize = 4; +#[cfg(target_os = "linux")] +const TRUE_PEAK_INTERP_RADIUS: usize = 8; +#[cfg(target_os = "linux")] +const NATIVE_RECORDER_DISCONTINUITY_BLEND_FRAMES: usize = 256; +#[cfg(target_os = "linux")] +#[cfg(target_os = "linux")] +const RTW_CENTERS_1_3: &[f32] = &[ + 20.0, 25.0, 31.5, 40.0, 50.0, 63.0, 80.0, 100.0, 125.0, 160.0, + 200.0, 250.0, 315.0, 400.0, 500.0, 630.0, 800.0, 1000.0, 1250.0, 1600.0, + 2000.0, 2500.0, 3150.0, 4000.0, 5000.0, 6300.0, 8000.0, 10_000.0, 12_500.0, 16_000.0, 20_000.0, +]; +#[cfg(target_os = "linux")] +const RTW_CENTERS_1_6: &[f32] = &[ + 20.0, 22.4, 25.0, 28.0, 31.5, 35.5, 40.0, 45.0, 50.0, 56.0, + 63.0, 70.8, 80.0, 90.0, 100.0, 112.0, 125.0, 141.0, 160.0, 180.0, + 200.0, 224.0, 250.0, 282.0, 315.0, 355.0, 400.0, 450.0, 500.0, 560.0, + 630.0, 708.0, 800.0, 900.0, 1000.0, 1120.0, 1250.0, 1410.0, 1600.0, 1800.0, + 2000.0, 2240.0, 2500.0, 2820.0, 3150.0, 3550.0, 4000.0, 4500.0, 5000.0, 5600.0, + 6300.0, 7080.0, 8000.0, 9000.0, 10000.0, 11200.0, 12500.0, 14100.0, 16000.0, 18000.0, 20000.0, +]; +#[cfg(target_os = "linux")] +const RTW_CENTERS_1_12: &[f32] = &[ + 20.0, 21.2, 22.4, 23.8, 25.0, 26.5, 28.0, 29.8, 31.5, 33.4, + 35.5, 37.6, 40.0, 42.4, 45.0, 47.5, 50.0, 53.0, 56.0, 59.5, + 63.0, 67.0, 71.0, 75.0, 80.0, 85.0, 90.0, 95.0, 100.0, 106.0, + 112.0, 118.0, 125.0, 132.0, 140.0, 150.0, 160.0, 170.0, 180.0, 190.0, + 200.0, 212.0, 224.0, 238.0, 250.0, 265.0, 280.0, 298.0, 315.0, 334.0, + 355.0, 376.0, 400.0, 424.0, 450.0, 475.0, 500.0, 530.0, 560.0, 595.0, + 630.0, 670.0, 710.0, 750.0, 800.0, 850.0, 900.0, 950.0, 1000.0, 1060.0, + 1120.0, 1180.0, 1250.0, 1320.0, 1400.0, 1500.0, 1600.0, 1700.0, 1800.0, 1900.0, + 2000.0, 2120.0, 2240.0, 2380.0, 2500.0, 2650.0, 2800.0, 2980.0, 3150.0, 3340.0, + 3550.0, 3760.0, 4000.0, 4240.0, 4500.0, 4750.0, 5000.0, 5300.0, 5600.0, 5950.0, + 6300.0, 6700.0, 7100.0, 7500.0, 8000.0, 8500.0, 9000.0, 9500.0, 10_000.0, 10_600.0, + 11_200.0, 11_800.0, 12_500.0, 13_200.0, 14_000.0, 15_000.0, 16_000.0, 17_000.0, 18_000.0, 19_000.0, 20_000.0, +]; + +#[derive(Clone)] +pub struct AudioWorkerDeps { + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + pub config: PhoenixConfig, + pub input: Arc>, + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + pub rta_config: Arc>, + pub global_config_rev: Arc, + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + pub native_wav_recorders: Arc>>, + pub seq: Arc, + pub metrics_tx: tokio::sync::broadcast::Sender, + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + pub restart_token: Arc, +} + +#[cfg(target_os = "linux")] +#[derive(Clone, Copy)] +struct RtaBandDef { + center: f32, + f_lo: f32, + f_hi: f32, +} + +#[cfg(target_os = "linux")] +#[derive(Clone, Copy)] +struct BiquadCoeffs { + b0: f32, + b1: f32, + b2: f32, + a1: f32, + a2: f32, +} + +#[cfg(target_os = "linux")] +#[derive(Clone, Copy)] +struct RtaSectionState { + b0: f32, + b1: f32, + b2: f32, + a1: f32, + a2: f32, + z1_l: f32, + z2_l: f32, + z1_r: f32, + z2_r: f32, +} + +#[cfg(target_os = "linux")] +struct RtaBank { + layout: &'static str, + bpo_key: &'static str, + freq_min: f32, + freq_max: f32, + band_defs: Vec, + centers: Vec, + filters: Vec>, + acc_l: Vec, + acc_r: Vec, + energies: Vec, + levels: Vec, + peaks: Vec, +} + +#[cfg(target_os = "linux")] +#[derive(Clone, Copy)] +struct FftBandSeg { + index: usize, + weight: f32, +} + +#[cfg(target_os = "linux")] +struct FftBandMap { + center: f32, + f_lo: f32, + f_hi: f32, + bins: Vec, +} + +#[cfg(target_os = "linux")] +struct FftRtaState { + layout: &'static str, + bpo_key: &'static str, + freq_min: f32, + freq_max: f32, + centers: Vec, + mapping: Vec, + ring_l: Vec, + ring_r: Vec, + ring_pos: usize, + ring_fill: usize, + samples_since: usize, + fft_size: usize, + fft_step_samples: usize, + fft_re_l: Vec, + fft_im_l: Vec, + fft_re_r: Vec, + fft_im_r: Vec, + power_bins: Vec, + levels: Vec, + peaks: Vec, +} + +#[cfg(target_os = "linux")] +struct SpectroState { + fft_size: usize, + fft_step_samples: usize, + ring_l: Vec, + ring_r: Vec, + ring_pos: usize, + ring_fill: usize, + samples_since: usize, + fft_re_l: Vec, + fft_im_l: Vec, + fft_re_r: Vec, + fft_im_r: Vec, + bins: Vec, +} + +#[cfg(target_os = "linux")] +struct WaveEnvState { + column_samples: usize, + sample_rate: u32, + queue_min_l: Vec, + queue_max_l: Vec, + queue_min_r: Vec, + queue_max_r: Vec, + cur_min_l: f32, + cur_max_l: f32, + cur_min_r: f32, + cur_max_r: f32, + cur_count: usize, + last_channels: u8, +} + +#[cfg(target_os = "linux")] +#[derive(Clone, Copy)] +struct LoudnessBiquadState { + b0: f32, + b1: f32, + b2: f32, + a1: f32, + a2: f32, + x1: f32, + x2: f32, + y1: f32, + y2: f32, +} + +#[cfg(target_os = "linux")] +struct MovingAverageWindow { + window_len: usize, + ring_l: Vec, + ring_r: Vec, + pos: usize, + fill: usize, + sum_l: f64, + sum_r: f64, +} + +#[cfg(target_os = "linux")] +struct LufsState { + sample_rate: u32, + block_samples: usize, + block_acc: f64, + block_acc_l: f64, + block_acc_r: f64, + block_count: usize, + block_index: u64, + ring_s: VecDeque, + ring_sl: VecDeque, + ring_sr: VecDeque, + short_history: VecDeque, + int_powers: VecDeque, + int_block_ids: VecDeque, + hp_l: LoudnessBiquadState, + sh_l: LoudnessBiquadState, + hp_r: LoudnessBiquadState, + sh_r: LoudnessBiquadState, + box_window_len: usize, + box_ring_l: Vec, + box_ring_r: Vec, + box_ring_pos: usize, + box_ring_fill: usize, + box_sum_l: f64, + box_sum_r: f64, + last_norm_mode: bool, + lu_m: f32, + lu_s: f32, + lu_i: f32, + lra: f32, + lu_m_l: f32, + lu_m_r: f32, + lu_s_l: f32, + lu_s_r: f32, + ppm_box_l: f32, + ppm_box_r: f32, +} + +#[cfg(target_os = "linux")] +enum RtaEngineState { + Iir(RtaBank), + Fft(FftRtaState), +} + +#[cfg(target_os = "linux")] +pub fn spawn_audio_capture_worker(deps: AudioWorkerDeps) { + tokio::spawn(async move { + loop { + let generation = deps.restart_token.load(Ordering::SeqCst); + let deps_for_run = deps.clone(); + let join = tokio::task::spawn_blocking(move || capture_until_restart(deps_for_run, generation)).await; + + match join { + Ok(Ok(())) => { + tokio::time::sleep(Duration::from_millis(50)).await; + } + Ok(Err(err)) => { + warn!("Phoenix ALSA capture stopped: {}", err); + tokio::time::sleep(Duration::from_millis(250)).await; + } + Err(err) => { + warn!("Phoenix ALSA capture task join error: {}", err); + tokio::time::sleep(Duration::from_millis(250)).await; + } + } + } + }); +} + +#[cfg(not(target_os = "linux"))] +pub fn spawn_audio_capture_worker(deps: AudioWorkerDeps) { + tokio::spawn(async move { + warn!("Phoenix ALSA capture is only available on Linux; emitting placeholder frames on this host"); + let mut ticker = tokio::time::interval(Duration::from_millis(16)); + loop { + ticker.tick().await; + let input = *deps.input.read().await; + let frame = MeterFrame { + seq: deps.seq.fetch_add(1, Ordering::Relaxed) + 1, + timestamp_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + rms_l: -120.0, + rms_r: -120.0, + vu_l: -120.0, + vu_r: -120.0, + tp_l: -120.0, + tp_r: -120.0, + ppm_din_l: -120.0, + ppm_din_r: -120.0, + ppm_ebu_l: -120.0, + ppm_ebu_r: -120.0, + lufs_m: None, + lufs_s: None, + lufs_i: None, + lra: None, + lufs_ml: None, + lufs_mr: None, + lufs_sl: None, + lufs_sr: None, + ppm_box_l: None, + ppm_box_r: None, + wave_l: Vec::new(), + wave_r: Vec::new(), + wave_channels: 0, + xy_l: Vec::new(), + xy_r: Vec::new(), + rta: None, + spectro: None, + wave_env: None, + global_config_rev: deps.global_config_rev.load(Ordering::SeqCst), + input, + source: "non-linux-placeholder", + }; + let _ = deps.metrics_tx.send(frame); + } + }); +} + +#[cfg(target_os = "linux")] +fn capture_until_restart(deps: AudioWorkerDeps, generation: u64) -> anyhow::Result<()> { + use std::convert::TryInto; + use std::thread; + + use alsa::{ + pcm::{Access, Format, Frames, HwParams, PCM, State as PcmState}, + Direction, ValueOr, + }; + use tracing::info; + + let device = deps.config.alsa_device(); + info!("Phoenix opening ALSA capture on {}", device); + + let pcm = PCM::new(&device, Direction::Capture, false)?; + let hwp = HwParams::any(&pcm)?; + let period_size: Frames = deps + .config + .period_size + .try_into() + .map_err(|_| anyhow::anyhow!("invalid ALSA period size: {}", deps.config.period_size))?; + let buffer_size: Frames = deps + .config + .buffer_size + .try_into() + .map_err(|_| anyhow::anyhow!("invalid ALSA buffer size: {}", deps.config.buffer_size))?; + hwp.set_channels(2)?; + hwp.set_rate(deps.config.sample_rate, ValueOr::Nearest)?; + hwp.set_format(Format::s16())?; + hwp.set_access(Access::RWInterleaved)?; + hwp.set_period_size(period_size, ValueOr::Nearest)?; + hwp.set_buffer_size(buffer_size)?; + pcm.hw_params(&hwp)?; + pcm.prepare()?; + + let io = pcm.io_i16()?; + let frame_samples = deps.config.period_size as usize; + let mut buffer = vec![0i16; frame_samples * 2]; + let mut ppm = PpmState::default(); + + loop { + if deps.restart_token.load(Ordering::SeqCst) != generation { + info!("Phoenix ALSA capture restart requested"); + return Ok(()); + } + + match io.readi(&mut buffer) { + Ok(frames) => { + if frames == 0 { + thread::sleep(Duration::from_millis(1)); + continue; + } + let input = *deps.input.blocking_read(); + let rta_config = deps.rta_config.blocking_read().clone(); + append_native_wav_frames(&deps.native_wav_recorders, &buffer[..frames * 2], deps.config.sample_rate); + let frame = build_meter_frame( + &buffer[..frames * 2], + deps.config.sample_rate, + input, + deps.global_config_rev.load(Ordering::SeqCst), + &deps.seq, + &mut ppm, + &rta_config, + ); + let _ = deps.metrics_tx.send(frame); + } + Err(err) => match pcm.state() { + PcmState::XRun | PcmState::Suspended => { + warn!("Phoenix ALSA xrun/suspend, preparing capture device again"); + mark_native_wav_discontinuity(&deps.native_wav_recorders); + pcm.prepare()?; + continue; + } + _ => return Err(err.into()), + }, + } + } +} + +#[cfg(target_os = "linux")] +struct PpmState { + env_l: f32, + env_r: f32, + ebu_env_l: f32, + ebu_env_r: f32, + din_window: MovingAverageWindow, + ebu_window: MovingAverageWindow, + vu_window: MovingAverageWindow, + frame_counter: usize, + last_rta: Option, + last_spectro: Option, + rta_signature: String, + rta_state: Option, + spectro_signature: String, + spectro_state: Option, + wave_env: WaveEnvState, + lufs: LufsState, + lr_delay_signature: String, + lr_delay_enabled: bool, + lr_delay_samples: f32, + lr_delay_a: f32, + lr_delay_channel: u8, + lr_delay_x1_l: f32, + lr_delay_y1_l: f32, + lr_delay_x1_r: f32, + lr_delay_y1_r: f32, + tp_history_l: Vec, + tp_history_r: Vec, + xy_history_l: VecDeque, + xy_history_r: VecDeque, +} + +#[cfg(target_os = "linux")] +impl Default for PpmState { + fn default() -> Self { + Self { + env_l: 0.0, + env_r: 0.0, + ebu_env_l: 0.0, + ebu_env_r: 0.0, + din_window: create_moving_average_window(48_000, 5.0), + ebu_window: create_moving_average_window(48_000, 10.0), + vu_window: create_moving_average_window(48_000, VU_WINDOW_MS), + frame_counter: 0, + last_rta: None, + last_spectro: None, + rta_signature: String::new(), + rta_state: None, + spectro_signature: String::new(), + spectro_state: None, + wave_env: create_wave_env_state(48_000), + lufs: create_lufs_state(48_000), + lr_delay_signature: String::new(), + lr_delay_enabled: false, + lr_delay_samples: 0.0, + lr_delay_a: 0.0, + lr_delay_channel: 0, + lr_delay_x1_l: 0.0, + lr_delay_y1_l: 0.0, + lr_delay_x1_r: 0.0, + lr_delay_y1_r: 0.0, + tp_history_l: Vec::new(), + tp_history_r: Vec::new(), + xy_history_l: VecDeque::with_capacity(XY_HISTORY_SAMPLES), + xy_history_r: VecDeque::with_capacity(XY_HISTORY_SAMPLES), + } + } +} + +#[cfg(target_os = "linux")] +fn append_native_wav_frames( + sessions: &Arc>>, + interleaved: &[i16], + sample_rate: u32, +) { + let Ok(mut guard) = sessions.lock() else { + return; + }; + if guard.is_empty() { + return; + } + for capture in guard.values_mut() { + capture.sample_rate = sample_rate; + capture.channels = 2; + capture.pcm_bytes.reserve(interleaved.len() * 2); + let needs_blend = capture.pending_discontinuity && capture.total_frames > 0; + let blend_frames = if needs_blend { + NATIVE_RECORDER_DISCONTINUITY_BLEND_FRAMES.min(interleaved.len() / 2).max(1) + } else { + 0 + }; + for (frame_idx, frame) in interleaved.chunks_exact(2).enumerate() { + let mut l = frame[0]; + let mut r = frame[1]; + if blend_frames > 0 && frame_idx < blend_frames { + let t = (frame_idx as f32 + 1.0) / blend_frames as f32; + l = ((capture.last_l as f32) + ((l as f32) - (capture.last_l as f32)) * t) + .round() + .clamp(i16::MIN as f32, i16::MAX as f32) as i16; + r = ((capture.last_r as f32) + ((r as f32) - (capture.last_r as f32)) * t) + .round() + .clamp(i16::MIN as f32, i16::MAX as f32) as i16; + } + capture.pcm_bytes.extend_from_slice(&l.to_le_bytes()); + capture.pcm_bytes.extend_from_slice(&r.to_le_bytes()); + capture.last_l = l; + capture.last_r = r; + capture.total_frames += 1; + } + capture.pending_discontinuity = false; + } +} + +#[cfg(target_os = "linux")] +fn mark_native_wav_discontinuity( + sessions: &Arc>>, +) { + let Ok(mut guard) = sessions.lock() else { + return; + }; + for capture in guard.values_mut() { + if capture.total_frames > 0 { + capture.pending_discontinuity = true; + } + } +} + +#[cfg(target_os = "linux")] +fn create_wave_env_state(sample_rate: u32) -> WaveEnvState { + let sr = sample_rate.max(8_000); + let column_samples = ((sr as f32) / WAVE_ENV_COLUMNS_PER_SEC).round().max(8.0) as usize; + WaveEnvState { + column_samples, + sample_rate: sr, + queue_min_l: Vec::new(), + queue_max_l: Vec::new(), + queue_min_r: Vec::new(), + queue_max_r: Vec::new(), + cur_min_l: f32::INFINITY, + cur_max_l: f32::NEG_INFINITY, + cur_min_r: f32::INFINITY, + cur_max_r: f32::NEG_INFINITY, + cur_count: 0, + last_channels: 1, + } +} + +#[cfg(target_os = "linux")] +fn ensure_wave_env_state(state: &mut WaveEnvState, sample_rate: u32) { + if state.sample_rate == sample_rate.max(8_000) { + return; + } + *state = create_wave_env_state(sample_rate); +} + +#[cfg(target_os = "linux")] +fn wave_env_accumulate(state: &mut WaveEnvState, l: f32, r: f32, channels: u8) { + let chan = if channels > 1 { 2 } else { 1 }; + state.last_channels = chan; + state.cur_min_l = state.cur_min_l.min(l); + state.cur_max_l = state.cur_max_l.max(l); + if chan > 1 { + state.cur_min_r = state.cur_min_r.min(r); + state.cur_max_r = state.cur_max_r.max(r); + } + state.cur_count += 1; + if state.cur_count >= state.column_samples { + state.queue_min_l.push(state.cur_min_l); + state.queue_max_l.push(state.cur_max_l); + if chan > 1 { + state.queue_min_r.push(state.cur_min_r); + state.queue_max_r.push(state.cur_max_r); + } + state.cur_min_l = f32::INFINITY; + state.cur_max_l = f32::NEG_INFINITY; + state.cur_min_r = f32::INFINITY; + state.cur_max_r = f32::NEG_INFINITY; + state.cur_count = 0; + } +} + +#[cfg(target_os = "linux")] +fn wave_env_flush(state: &mut WaveEnvState) -> Option { + if state.queue_min_l.is_empty() { + return None; + } + let count = state.queue_min_l.len(); + let has_r = state.last_channels > 1 && state.queue_min_r.len() == count; + let stride = if has_r { 4 } else { 2 }; + let mut data = Vec::with_capacity(count * stride); + for i in 0..count { + data.push(state.queue_min_l[i]); + data.push(state.queue_max_l[i]); + if has_r { + data.push(state.queue_min_r[i]); + data.push(state.queue_max_r[i]); + } + } + state.queue_min_l.clear(); + state.queue_max_l.clear(); + state.queue_min_r.clear(); + state.queue_max_r.clear(); + Some(WaveEnvFrame { + data, + columns: count, + channels: if has_r { 2 } else { 1 }, + column_samples: state.column_samples, + sample_rate: state.sample_rate, + }) +} + +#[cfg(target_os = "linux")] +fn create_moving_average_window(sample_rate: u32, window_ms: f32) -> MovingAverageWindow { + let sr = sample_rate.max(8_000) as f32; + let window_len = ((window_ms.max(0.1) / 1000.0) * sr).round().max(1.0) as usize; + MovingAverageWindow { + window_len, + ring_l: vec![0.0; window_len], + ring_r: vec![0.0; window_len], + pos: 0, + fill: 0, + sum_l: 0.0, + sum_r: 0.0, + } +} + +#[cfg(target_os = "linux")] +fn ensure_moving_average_window(state: &mut MovingAverageWindow, sample_rate: u32, window_ms: f32) { + let desired_len = ((window_ms.max(0.1) / 1000.0) * sample_rate.max(8_000) as f32).round().max(1.0) as usize; + if state.window_len == desired_len { + return; + } + *state = create_moving_average_window(sample_rate, window_ms); +} + +#[cfg(target_os = "linux")] +fn moving_average_push(state: &mut MovingAverageWindow, l: f32, r: f32) -> (f32, f32) { + if state.fill < state.window_len { + state.fill += 1; + } else { + state.sum_l -= f64::from(state.ring_l[state.pos]); + state.sum_r -= f64::from(state.ring_r[state.pos]); + } + state.ring_l[state.pos] = l; + state.ring_r[state.pos] = r; + state.sum_l += f64::from(l); + state.sum_r += f64::from(r); + state.pos = (state.pos + 1) % state.window_len; + let denom = state.fill.max(1) as f32; + ((state.sum_l / denom as f64) as f32, (state.sum_r / denom as f64) as f32) +} + +#[cfg(target_os = "linux")] +fn create_biquad_from_coeffs(b0: f32, b1: f32, b2: f32, a1: f32, a2: f32) -> LoudnessBiquadState { + LoudnessBiquadState { + b0, + b1, + b2, + a1, + a2, + x1: 0.0, + x2: 0.0, + y1: 0.0, + y2: 0.0, + } +} + +#[cfg(target_os = "linux")] +fn biquad_highpass(sample_rate: u32, freq_hz: f32, q: f32) -> LoudnessBiquadState { + let sr = sample_rate.max(8_000) as f32; + let w0 = 2.0 * std::f32::consts::PI * freq_hz / sr; + let cos_w0 = w0.cos(); + let sin_w0 = w0.sin(); + let alpha = sin_w0 / (2.0 * q.max(1e-4)); + let a0 = 1.0 + alpha; + LoudnessBiquadState { + b0: ((1.0 + cos_w0) * 0.5) / a0, + b1: (-(1.0 + cos_w0)) / a0, + b2: ((1.0 + cos_w0) * 0.5) / a0, + a1: (-2.0 * cos_w0) / a0, + a2: (1.0 - alpha) / a0, + x1: 0.0, + x2: 0.0, + y1: 0.0, + y2: 0.0, + } +} + +#[cfg(target_os = "linux")] +fn biquad_highshelf(sample_rate: u32, freq_hz: f32, gain_db: f32) -> LoudnessBiquadState { + let sr = sample_rate.max(8_000) as f32; + let a = 10f32.powf(gain_db / 40.0); + let w0 = 2.0 * std::f32::consts::PI * freq_hz / sr; + let cos_w0 = w0.cos(); + let sin_w0 = w0.sin(); + let sqrt_a = a.sqrt(); + let alpha = 0.5 * sin_w0 * (2.0f32).sqrt(); + let two_sqrt_a_alpha = 2.0 * sqrt_a * alpha; + let a0 = (a + 1.0) - (a - 1.0) * cos_w0 + two_sqrt_a_alpha; + LoudnessBiquadState { + b0: (a * ((a + 1.0) + (a - 1.0) * cos_w0 + two_sqrt_a_alpha)) / a0, + b1: (-2.0 * a * ((a - 1.0) + (a + 1.0) * cos_w0)) / a0, + b2: (a * ((a + 1.0) + (a - 1.0) * cos_w0 - two_sqrt_a_alpha)) / a0, + a1: (2.0 * ((a - 1.0) - (a + 1.0) * cos_w0)) / a0, + a2: ((a + 1.0) - (a - 1.0) * cos_w0 - two_sqrt_a_alpha) / a0, + x1: 0.0, + x2: 0.0, + y1: 0.0, + y2: 0.0, + } +} + +#[cfg(target_os = "linux")] +fn create_bs1770_prefilter(sample_rate: u32) -> LoudnessBiquadState { + if sample_rate == 48_000 { + return create_biquad_from_coeffs( + 1.53512485958697, + -2.69169618940638, + 1.19839281085285, + -1.69065929318241, + 0.73248077421585, + ); + } + biquad_highshelf(sample_rate, 1681.974450955533, 3.99984385397) +} + +#[cfg(target_os = "linux")] +fn create_bs1770_rlb_filter(sample_rate: u32) -> LoudnessBiquadState { + if sample_rate == 48_000 { + return create_biquad_from_coeffs( + 1.0, + -2.0, + 1.0, + -1.99004745483398, + 0.99007225036621, + ); + } + biquad_highpass(sample_rate, 38.13547087602444, 0.5003270373238773) +} + +#[cfg(target_os = "linux")] +fn process_loudness_biquad(state: &mut LoudnessBiquadState, x: f32) -> f32 { + let y = state.b0 * x + + state.b1 * state.x1 + + state.b2 * state.x2 + - state.a1 * state.y1 + - state.a2 * state.y2; + state.x2 = state.x1; + state.x1 = x; + state.y2 = state.y1; + state.y1 = y; + y +} + +#[cfg(target_os = "linux")] +fn create_lufs_state(sample_rate: u32) -> LufsState { + let sr = sample_rate.max(8_000); + let block_samples = ((LUFS_BLOCK_MS / 1000.0) * sr as f32).round().max(1.0) as usize; + let short_blocks = ((LUFS_SHORT_WINDOW_MS / LUFS_BLOCK_MS).round().max(1.0)) as usize; + let lra_blocks = ((LUFS_LRA_HISTORY_MS / LUFS_BLOCK_MS).round().max(2.0)) as usize; + let int_blocks = ((LUFS_INT_WINDOW_MIN as f32 * 60_000.0) / LUFS_BLOCK_MS).round().max(1.0) as usize; + let box_window_len = ((RTW_LOUDNESS_WINDOW_MS / 1000.0) * sr as f32).round().max(1.0) as usize; + let mut state = LufsState { + sample_rate: sr, + block_samples, + block_acc: 0.0, + block_acc_l: 0.0, + block_acc_r: 0.0, + block_count: 0, + block_index: 0, + ring_s: VecDeque::with_capacity(short_blocks + 1), + ring_sl: VecDeque::with_capacity(short_blocks + 1), + ring_sr: VecDeque::with_capacity(short_blocks + 1), + short_history: VecDeque::with_capacity(lra_blocks + 1), + int_powers: VecDeque::with_capacity(int_blocks + 1), + int_block_ids: VecDeque::with_capacity(int_blocks + 1), + hp_l: create_bs1770_rlb_filter(sr), + sh_l: create_bs1770_prefilter(sr), + hp_r: create_bs1770_rlb_filter(sr), + sh_r: create_bs1770_prefilter(sr), + box_window_len, + box_ring_l: vec![0.0; box_window_len], + box_ring_r: vec![0.0; box_window_len], + box_ring_pos: 0, + box_ring_fill: 0, + box_sum_l: 0.0, + box_sum_r: 0.0, + last_norm_mode: false, + lu_m: -60.0, + lu_s: -60.0, + lu_i: -60.0, + lra: 0.0, + lu_m_l: -60.0, + lu_m_r: -60.0, + lu_s_l: -60.0, + lu_s_r: -60.0, + ppm_box_l: BOX_MIN_DB, + ppm_box_r: BOX_MIN_DB, + }; + state.ring_s.reserve(short_blocks + 1); + state.ring_sl.reserve(short_blocks + 1); + state.ring_sr.reserve(short_blocks + 1); + state.short_history.reserve(lra_blocks + 1); + state.int_powers.reserve(int_blocks + 1); + state.int_block_ids.reserve(int_blocks + 1); + state +} + +#[cfg(target_os = "linux")] +fn ensure_lufs_state(state: &mut LufsState, sample_rate: u32) { + if state.sample_rate == sample_rate.max(8_000) { + return; + } + *state = create_lufs_state(sample_rate); +} + +#[cfg(target_os = "linux")] +fn lufs_from_stereo_power(p: f32) -> f32 { + -0.691 + 10.0 * p.max(1e-20).log10() +} + +#[cfg(target_os = "linux")] +fn loudness_from_power(p: f32) -> f32 { + -0.691 + 10.0 * p.max(1e-20).log10() +} + +#[cfg(target_os = "linux")] +fn db_per_s_to_tau(db_per_s: f32) -> f32 { + if db_per_s > 0.0 { + (20.0 / std::f32::consts::LN_10) / db_per_s + } else { + 0.75 + } +} + +#[cfg(target_os = "linux")] +fn make_release_coeff(sample_rate: u32, tau: f32) -> f32 { + let tt = tau.max(1e-5); + (-1.0 / (sample_rate.max(8_000) as f32 * tt)).exp() +} + +#[cfg(target_os = "linux")] +fn clamp_box_db(v: f32) -> f32 { + v.clamp(BOX_MIN_DB, BOX_MAX_DB) +} + +#[cfg(target_os = "linux")] +fn percentile_sorted(sorted: &[f32], p: f32) -> f32 { + if sorted.is_empty() { + return f32::NEG_INFINITY; + } + if sorted.len() == 1 { + return sorted[0]; + } + let idx = (p / 100.0) * (sorted.len() - 1) as f32; + let lower = idx.floor() as usize; + let upper = idx.ceil() as usize; + if lower == upper { + return sorted[lower]; + } + let frac = idx - lower as f32; + sorted[lower] + (sorted[upper] - sorted[lower]) * frac +} + +#[cfg(target_os = "linux")] +fn process_lufs_sample(state: &mut LufsState, l: f32, r: f32, config: &PhoenixRtaConfig) { + let wl_pre = process_loudness_biquad(&mut state.sh_l, l); + let wr_pre = process_loudness_biquad(&mut state.sh_r, r); + let wl = process_loudness_biquad(&mut state.hp_l, wl_pre); + let wr = process_loudness_biquad(&mut state.hp_r, wr_pre); + state.block_acc += f64::from(wl * wl + wr * wr); + state.block_acc_l += f64::from(wl * wl); + state.block_acc_r += f64::from(wr * wr); + state.block_count += 1; + let box_sq_l = wl * wl; + let box_sq_r = wr * wr; + if state.box_ring_fill < state.box_window_len { + state.box_ring_fill += 1; + } else { + state.box_sum_l -= f64::from(state.box_ring_l[state.box_ring_pos]); + state.box_sum_r -= f64::from(state.box_ring_r[state.box_ring_pos]); + } + state.box_ring_l[state.box_ring_pos] = box_sq_l; + state.box_ring_r[state.box_ring_pos] = box_sq_r; + state.box_sum_l += f64::from(box_sq_l); + state.box_sum_r += f64::from(box_sq_r); + state.box_ring_pos = (state.box_ring_pos + 1) % state.box_window_len; + + if state.block_count < state.block_samples { + return; + } + + state.block_index = state.block_index.wrapping_add(1); + let p_block = (state.block_acc / state.block_count as f64) as f32; + let p_block_l = (state.block_acc_l / state.block_count as f64) as f32; + let p_block_r = (state.block_acc_r / state.block_count as f64) as f32; + state.lu_m = lufs_from_stereo_power(p_block); + state.lu_m_l = lufs_from_stereo_power(p_block_l); + state.lu_m_r = lufs_from_stereo_power(p_block_r); + + let short_cap = ((LUFS_SHORT_WINDOW_MS / LUFS_BLOCK_MS).round().max(1.0)) as usize; + state.ring_s.push_back(p_block); + state.ring_sl.push_back(p_block_l); + state.ring_sr.push_back(p_block_r); + while state.ring_s.len() > short_cap { + state.ring_s.pop_front(); + } + while state.ring_sl.len() > short_cap { + state.ring_sl.pop_front(); + } + while state.ring_sr.len() > short_cap { + state.ring_sr.pop_front(); + } + let avg_short = state.ring_s.iter().copied().sum::() / state.ring_s.len().max(1) as f32; + let avg_short_l = state.ring_sl.iter().copied().sum::() / state.ring_sl.len().max(1) as f32; + let avg_short_r = state.ring_sr.iter().copied().sum::() / state.ring_sr.len().max(1) as f32; + state.lu_s = lufs_from_stereo_power(avg_short); + state.lu_s_l = lufs_from_stereo_power(avg_short_l); + state.lu_s_r = lufs_from_stereo_power(avg_short_r); + + let lra_cap = ((LUFS_LRA_HISTORY_MS / LUFS_BLOCK_MS).round().max(2.0)) as usize; + state.short_history.push_back(state.lu_s); + while state.short_history.len() > lra_cap { + state.short_history.pop_front(); + } + + let norm_mode = !!config.lufs_i_norm_enabled; + if norm_mode != state.last_norm_mode { + state.int_powers.clear(); + state.int_block_ids.clear(); + state.lu_i = -60.0; + state.last_norm_mode = norm_mode; + } + + let prev_lu_i = state.lu_i; + if state.lu_m > LUFS_ABS_GATE { + state.int_powers.push_back(p_block); + state.int_block_ids.push_back(state.block_index); + if !norm_mode { + let int_cap = (((config.lufs_i_window_min.max(1).min(10)) as f32 * 60_000.0) / LUFS_BLOCK_MS).round().max(1.0) as usize; + while state.int_powers.len() > int_cap { + state.int_powers.pop_front(); + state.int_block_ids.pop_front(); + } + } + let mut thr = f32::NEG_INFINITY; + let mut prev_count = usize::MAX; + for _ in 0..3 { + let mut sum = 0.0f32; + let mut count = 0usize; + for &p in &state.int_powers { + if p >= thr { + sum += p; + count += 1; + } + } + if count == 0 || count == prev_count { + break; + } + prev_count = count; + let avg = sum / count as f32; + state.lu_i = lufs_from_stereo_power(avg); + thr = 10f32.powf(((state.lu_i - 10.0) + 0.691) / 10.0); + } + } else { + state.lu_i = prev_lu_i; + } + + let mut abs_values: Vec = state + .short_history + .iter() + .copied() + .filter(|v| *v > LUFS_ABS_GATE) + .collect(); + if abs_values.len() >= 2 { + let mean_abs = abs_values.iter().copied().sum::() / abs_values.len() as f32; + let rel_gate = mean_abs - 20.0; + abs_values.retain(|v| *v >= rel_gate); + if abs_values.len() >= 2 { + abs_values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let p10 = percentile_sorted(&abs_values, 10.0); + let p95 = percentile_sorted(&abs_values, 95.0); + state.lra = (p95 - p10).max(0.0); + } else { + state.lra = 0.0; + } + } else { + state.lra = 0.0; + } + + state.block_acc = 0.0; + state.block_acc_l = 0.0; + state.block_acc_r = 0.0; + state.block_count = 0; +} + +#[cfg(target_os = "linux")] +fn update_box_meter(state: &mut LufsState) { + let denom = state.box_ring_fill.max(1) as f32; + let p_box_l = (state.box_sum_l / denom as f64) as f32; + let p_box_r = (state.box_sum_r / denom as f64) as f32; + state.ppm_box_l = clamp_box_db(loudness_from_power(p_box_l)); + state.ppm_box_r = clamp_box_db(loudness_from_power(p_box_r)); +} + +#[cfg(target_os = "linux")] +fn sinc(x: f32) -> f32 { + if x.abs() < 1.0e-6 { + 1.0 + } else { + let pix = std::f32::consts::PI * x; + pix.sin() / pix + } +} + +#[cfg(target_os = "linux")] +fn blackman_window(x: f32, radius: usize) -> f32 { + let span = (radius * 2) as f32; + if span <= 0.0 { + return 1.0; + } + let n = x + radius as f32; + let phase = 2.0 * std::f32::consts::PI * n / span; + 0.42 - 0.5 * phase.cos() + 0.08 * (2.0 * phase).cos() +} + +#[cfg(target_os = "linux")] +fn interpolate_true_peak_sample(samples: &[f32], pos: f32) -> f32 { + let radius = TRUE_PEAK_INTERP_RADIUS as isize; + let base = pos.floor() as isize; + let mut sum = 0.0f32; + let mut norm = 0.0f32; + for n in (base - radius + 1)..=(base + radius) { + if n < 0 || n >= samples.len() as isize { + continue; + } + let x = pos - n as f32; + let w = sinc(x) * blackman_window(x, TRUE_PEAK_INTERP_RADIUS); + sum += samples[n as usize] * w; + norm += w; + } + if norm.abs() > 1.0e-6 { + sum / norm + } else { + 0.0 + } +} + +#[cfg(target_os = "linux")] +fn estimate_true_peak(history: &mut Vec, block: &[f32]) -> f32 { + let radius = TRUE_PEAK_INTERP_RADIUS; + let hist_len = history.len(); + let mut peak = block.iter().copied().fold(0.0f32, |acc, v| acc.max(v.abs())); + if block.is_empty() { + return peak; + } + let mut samples = Vec::with_capacity(hist_len + block.len()); + samples.extend_from_slice(history); + samples.extend_from_slice(block); + if hist_len >= radius && samples.len() > radius + 1 { + let start_interval = hist_len.saturating_sub(1); + let end_interval = samples.len().saturating_sub(radius + 1); + for i in start_interval..end_interval { + for phase in 1..TRUE_PEAK_OVERSAMPLE { + let pos = i as f32 + (phase as f32 / TRUE_PEAK_OVERSAMPLE as f32); + peak = peak.max(interpolate_true_peak_sample(&samples, pos).abs()); + } + } + } + if samples.len() > radius { + let keep_from = samples.len() - radius; + history.clear(); + history.extend_from_slice(&samples[keep_from..]); + } else { + history.clear(); + history.extend_from_slice(&samples); + } + peak +} + +#[cfg(target_os = "linux")] +fn push_xy_history(history: &mut VecDeque, sample: f32) { + if history.len() >= XY_HISTORY_SAMPLES { + let _ = history.pop_front(); + } + history.push_back(sample); +} + +#[cfg(target_os = "linux")] +fn build_meter_frame( + interleaved: &[i16], + sample_rate: u32, + input: InputSource, + global_config_rev: u64, + seq: &Arc, + ppm_state: &mut PpmState, + rta_config: &PhoenixRtaConfig, +) -> MeterFrame { + let mut sum_sq_l = 0.0f64; + let mut sum_sq_r = 0.0f64; + let mut peak_l = 0.0f32; + let mut peak_r = 0.0f32; + let frames = interleaved.len() / 2; + let mut wave_l = Vec::with_capacity(frames); + let mut wave_r = Vec::with_capacity(frames); + ensure_wave_env_state(&mut ppm_state.wave_env, sample_rate); + ensure_lufs_state(&mut ppm_state.lufs, sample_rate); + + let din_release_coeff = make_release_coeff(sample_rate, db_per_s_to_tau(rta_config.ppm_din_decay_db_per_s)); + let ebu_release_coeff = make_release_coeff(sample_rate, db_per_s_to_tau(rta_config.ppm_ebu_decay_db_per_s)); + ensure_moving_average_window( + &mut ppm_state.din_window, + sample_rate, + if rta_config.ppm_din_fast_attack { 0.1 } else { rta_config.ppm_din_attack_ms.max(0.1) }, + ); + ensure_moving_average_window(&mut ppm_state.ebu_window, sample_rate, rta_config.ppm_ebu_attack_ms.max(0.1)); + ensure_moving_average_window(&mut ppm_state.vu_window, sample_rate, VU_WINDOW_MS); + ppm_state.frame_counter = ppm_state.frame_counter.wrapping_add(1); + let emit_xy = (ppm_state.frame_counter % XY_SEND_INTERVAL_FRAMES) == 0; + let gain_l = db_gain(configured_input_offset_db(rta_config.input_offset_db_l)); + let gain_r = db_gain(configured_input_offset_db(rta_config.input_offset_db_r)); + + ensure_rta_state(ppm_state, sample_rate, rta_config); + ensure_spectro_state(ppm_state, sample_rate, rta_config); + ensure_lr_delay_state(ppm_state, rta_config); + + for frame in interleaved.chunks_exact(2) { + let mut l = ((frame[0] as f32) / 32768.0) * gain_l; + let mut r = ((frame[1] as f32) / 32768.0) * gain_r; + apply_lr_fractional_delay(ppm_state, &mut l, &mut r); + if rta_config.mono_input { + let mono = 0.5 * (l + r); + l = mono; + r = mono; + } + wave_l.push(l); + wave_r.push(r); + wave_env_accumulate(&mut ppm_state.wave_env, l, r, 2); + process_lufs_sample(&mut ppm_state.lufs, l, r, rta_config); + + sum_sq_l += f64::from(l * l); + sum_sq_r += f64::from(r * r); + let abs_l = l.abs(); + let abs_r = r.abs(); + let (din_avg_l, din_avg_r) = moving_average_push(&mut ppm_state.din_window, abs_l, abs_r); + let (ebu_avg_l, ebu_avg_r) = moving_average_push(&mut ppm_state.ebu_window, abs_l, abs_r); + let _ = moving_average_push(&mut ppm_state.vu_window, abs_l, abs_r); + peak_l = peak_l.max(abs_l); + peak_r = peak_r.max(abs_r); + + ppm_state.env_l = if din_avg_l > ppm_state.env_l { + din_avg_l + } else { + ppm_state.env_l * din_release_coeff + }; + ppm_state.env_r = if din_avg_r > ppm_state.env_r { + din_avg_r + } else { + ppm_state.env_r * din_release_coeff + }; + + ppm_state.ebu_env_l = if ebu_avg_l > ppm_state.ebu_env_l { + ebu_avg_l + } else { + ppm_state.ebu_env_l * ebu_release_coeff + }; + ppm_state.ebu_env_r = if ebu_avg_r > ppm_state.ebu_env_r { + ebu_avg_r + } else { + ppm_state.ebu_env_r * ebu_release_coeff + }; + + push_xy_history(&mut ppm_state.xy_history_l, l); + push_xy_history(&mut ppm_state.xy_history_r, r); + + if let Some(state) = ppm_state.rta_state.as_mut() { + match state { + RtaEngineState::Iir(bank) => process_rta_sample(bank, l, r), + RtaEngineState::Fft(fft) => push_fft_sample(fft, l, r), + } + } + if let Some(spectro) = ppm_state.spectro_state.as_mut() { + push_spectro_sample(spectro, l, r); + } + } + + if let Some(state) = ppm_state.rta_state.as_mut() { + match state { + RtaEngineState::Iir(bank) => { + finalize_rta_bank(bank, sample_rate, frames, 2, rta_config); + ppm_state.last_rta = Some(build_iir_rta_frame(bank, sample_rate, rta_config)); + } + RtaEngineState::Fft(fft) => { + if finalize_fft_state(fft, sample_rate, rta_config) { + ppm_state.last_rta = Some(build_fft_rta_frame(fft, sample_rate, rta_config)); + } + } + } + } + let mut spectro_frame = None; + if let Some(state) = ppm_state.spectro_state.as_mut() { + if finalize_spectro_state(state, sample_rate) { + let frame = build_spectro_frame(state, sample_rate); + ppm_state.last_spectro = Some(frame.clone()); + spectro_frame = Some(frame); + } + } + + let rms_l = dbfs((sum_sq_l / frames.max(1) as f64).sqrt() as f32); + let rms_r = dbfs((sum_sq_r / frames.max(1) as f64).sqrt() as f32); + let vu_rect_l = (ppm_state.vu_window.sum_l / ppm_state.vu_window.fill.max(1) as f64) as f32; + let vu_rect_r = (ppm_state.vu_window.sum_r / ppm_state.vu_window.fill.max(1) as f64) as f32; + let vu_l = dbfs(vu_rect_l * VU_RECT_TO_RMS_GAIN); + let vu_r = dbfs(vu_rect_r * VU_RECT_TO_RMS_GAIN); + update_box_meter(&mut ppm_state.lufs); + let tp_l = dbfs(estimate_true_peak(&mut ppm_state.tp_history_l, &wave_l).max(peak_l)); + let tp_r = dbfs(estimate_true_peak(&mut ppm_state.tp_history_r, &wave_r).max(peak_r)); + let ppm_din_l = dbfs(ppm_state.env_l * PPM_RECT_TO_PEAK_GAIN); + let ppm_din_r = dbfs(ppm_state.env_r * PPM_RECT_TO_PEAK_GAIN); + let ppm_ebu_l = dbfs(ppm_state.ebu_env_l * PPM_RECT_TO_PEAK_GAIN); + let ppm_ebu_r = dbfs(ppm_state.ebu_env_r * PPM_RECT_TO_PEAK_GAIN); + let wave_env = wave_env_flush(&mut ppm_state.wave_env); + let (xy_l, xy_r) = if emit_xy { + ( + ppm_state.xy_history_l.iter().copied().collect::>(), + ppm_state.xy_history_r.iter().copied().collect::>(), + ) + } else { + (Vec::new(), Vec::new()) + }; + + MeterFrame { + seq: seq.fetch_add(1, Ordering::Relaxed) + 1, + timestamp_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + rms_l, + rms_r, + vu_l, + vu_r, + tp_l, + tp_r, + ppm_din_l, + ppm_din_r, + ppm_ebu_l, + ppm_ebu_r, + lufs_m: Some(ppm_state.lufs.lu_m), + lufs_s: Some(ppm_state.lufs.lu_s), + lufs_i: Some(ppm_state.lufs.lu_i), + lra: Some(ppm_state.lufs.lra), + lufs_ml: Some(ppm_state.lufs.lu_m_l), + lufs_mr: Some(ppm_state.lufs.lu_m_r), + lufs_sl: Some(ppm_state.lufs.lu_s_l), + lufs_sr: Some(ppm_state.lufs.lu_s_r), + ppm_box_l: Some(ppm_state.lufs.ppm_box_l), + ppm_box_r: Some(ppm_state.lufs.ppm_box_r), + wave_l, + wave_r, + wave_channels: 2, + xy_l, + xy_r, + rta: ppm_state.last_rta.clone(), + spectro: spectro_frame, + wave_env, + global_config_rev, + input, + source: "alsa-capture", + } +} + +#[cfg(target_os = "linux")] +fn ensure_rta_state(state: &mut PpmState, sample_rate: u32, config: &PhoenixRtaConfig) { + let signature = format!( + "{}|{}|{}|{}|{}|{}|{}", + normalize_engine(&config.engine), + normalize_layout(&config.layout), + normalize_bpo_key(&config.bpo), + normalize_freq_range(&config.freq_range), + config.order.clamp(2, 8), + normalize_fft_size(config.fft_size), + sample_rate + ); + if state.rta_state.is_some() && state.rta_signature == signature { + return; + } + state.rta_signature = signature; + state.rta_state = Some(create_rta_state(sample_rate, config)); +} + +#[cfg(target_os = "linux")] +fn create_rta_state(sample_rate: u32, config: &PhoenixRtaConfig) -> RtaEngineState { + match normalize_engine(&config.engine) { + "fft" => RtaEngineState::Fft(create_fft_state(sample_rate, config)), + _ => RtaEngineState::Iir(create_rta_bank(sample_rate, config)), + } +} + +#[cfg(target_os = "linux")] +fn ensure_lr_delay_state(state: &mut PpmState, config: &PhoenixRtaConfig) { + let signature = format!( + "{}|{:.6}", + if config.lr_fractional_delay_enabled { 1 } else { 0 }, + config.lr_fractional_delay_samples + ); + if state.lr_delay_signature == signature { + return; + } + state.lr_delay_signature = signature; + let enabled = config.lr_fractional_delay_enabled + && config.lr_fractional_delay_samples.is_finite() + && config.lr_fractional_delay_samples != 0.0; + if !enabled { + state.lr_delay_enabled = false; + state.lr_delay_samples = 0.0; + state.lr_delay_a = 0.0; + state.lr_delay_channel = 0; + } else { + let d = config.lr_fractional_delay_samples.abs().clamp(0.0, 1.5); + if d == 0.0 { + state.lr_delay_enabled = false; + state.lr_delay_samples = 0.0; + state.lr_delay_a = 0.0; + state.lr_delay_channel = 0; + } else { + let sign = if config.lr_fractional_delay_samples < 0.0 { -1.0 } else { 1.0 }; + let denom = 1.0 + d; + let a = if denom != 0.0 { (d - 1.0) / denom } else { 0.0 }; + state.lr_delay_enabled = true; + state.lr_delay_samples = sign * d; + state.lr_delay_a = a; + state.lr_delay_channel = if sign < 0.0 { 1 } else { 0 }; + } + } + state.lr_delay_x1_l = 0.0; + state.lr_delay_y1_l = 0.0; + state.lr_delay_x1_r = 0.0; + state.lr_delay_y1_r = 0.0; +} + +#[cfg(target_os = "linux")] +fn ensure_spectro_state(state: &mut PpmState, sample_rate: u32, config: &PhoenixRtaConfig) { + let signature = format!( + "{}|{}", + normalize_fft_size(config.fft_size), + sample_rate + ); + if state.spectro_state.is_some() && state.spectro_signature == signature { + return; + } + state.spectro_signature = signature; + state.spectro_state = Some(create_spectro_state(sample_rate, config)); + state.last_spectro = None; +} + +#[cfg(target_os = "linux")] +fn build_active_rta_bands( + sample_rate: u32, + config: &PhoenixRtaConfig, +) -> (&'static str, &'static str, Vec) { + let requested_layout = normalize_layout(&config.layout); + let bpo_key = normalize_bpo_key(&config.bpo); + let bands = build_rta_bands(sample_rate, requested_layout, bpo_key, &config.freq_range); + let active_layout = if requested_layout == "rtw" && !bands.is_empty() { "rtw" } else { "iec" }; + let active_bands = if bands.is_empty() { + build_rta_bands(sample_rate, "iec", bpo_key, &config.freq_range) + } else { + bands + }; + (active_layout, bpo_key, active_bands) +} + +#[cfg(target_os = "linux")] +fn create_rta_bank(sample_rate: u32, config: &PhoenixRtaConfig) -> RtaBank { + let (active_layout, bpo_key, active_bands) = build_active_rta_bands(sample_rate, config); + let q = q_for_bpo(bpo_value(bpo_key) as f32); + let order = config.order.clamp(2, 8); + let section_count = ((order as usize) / 2).max(1); + let centers: Vec = active_bands.iter().map(|band| band.center).collect(); + let freq_min = active_bands.first().map(|band| band.f_lo).unwrap_or(20.0); + let freq_max = active_bands.last().map(|band| band.f_hi).unwrap_or(20_000.0); + let mut filters = Vec::with_capacity(active_bands.len()); + for band in &active_bands { + let coeffs = compute_bandpass_coeffs(band.center, q, sample_rate as f32); + let mut sections = Vec::with_capacity(section_count); + for _ in 0..section_count { + sections.push(create_section(coeffs)); + } + filters.push(sections); + } + let len = active_bands.len(); + RtaBank { + layout: active_layout, + bpo_key, + freq_min, + freq_max, + band_defs: active_bands, + centers, + filters, + acc_l: vec![0.0; len], + acc_r: vec![0.0; len], + energies: vec![0.0; len], + levels: vec![-120.0; len], + peaks: vec![-120.0; len], + } +} + +#[cfg(target_os = "linux")] +fn create_fft_state(sample_rate: u32, config: &PhoenixRtaConfig) -> FftRtaState { + let (active_layout, bpo_key, active_bands) = build_active_rta_bands(sample_rate, config); + let fft_size = normalize_fft_size(config.fft_size); + let fft_step_samples = (fft_size / 4).max(1); + let centers: Vec = active_bands.iter().map(|band| band.center).collect(); + let freq_min = active_bands.first().map(|band| band.f_lo).unwrap_or(20.0); + let freq_max = active_bands.last().map(|band| band.f_hi).unwrap_or(20_000.0); + let mapping = build_fft_band_mapping(&active_bands, sample_rate as f32 * 0.5, fft_size / 2); + let len = active_bands.len(); + FftRtaState { + layout: active_layout, + bpo_key, + freq_min, + freq_max, + centers, + mapping, + ring_l: vec![0.0; fft_size], + ring_r: vec![0.0; fft_size], + ring_pos: 0, + ring_fill: 0, + samples_since: 0, + fft_size, + fft_step_samples, + fft_re_l: vec![0.0; fft_size], + fft_im_l: vec![0.0; fft_size], + fft_re_r: vec![0.0; fft_size], + fft_im_r: vec![0.0; fft_size], + power_bins: vec![0.0; fft_size / 2], + levels: vec![-120.0; len], + peaks: vec![-120.0; len], + } +} + +#[cfg(target_os = "linux")] +fn create_spectro_state(_sample_rate: u32, config: &PhoenixRtaConfig) -> SpectroState { + let fft_size = normalize_fft_size(config.fft_size); + let fft_step_samples = (fft_size / 8).max(128); + SpectroState { + fft_size, + fft_step_samples, + ring_l: vec![0.0; fft_size], + ring_r: vec![0.0; fft_size], + ring_pos: 0, + ring_fill: 0, + samples_since: 0, + fft_re_l: vec![0.0; fft_size], + fft_im_l: vec![0.0; fft_size], + fft_re_r: vec![0.0; fft_size], + fft_im_r: vec![0.0; fft_size], + bins: vec![-160.0; fft_size / 2], + } +} + +#[cfg(target_os = "linux")] +fn apply_lr_fractional_delay(state: &mut PpmState, l: &mut f32, r: &mut f32) { + if !state.lr_delay_enabled { + return; + } + let a = state.lr_delay_a; + if state.lr_delay_channel == 1 { + let prev_x = state.lr_delay_x1_r; + let prev_y = state.lr_delay_y1_r; + let y = -a * *r + prev_x + a * prev_y; + state.lr_delay_x1_r = *r; + state.lr_delay_y1_r = y; + *r = y; + } else { + let prev_x = state.lr_delay_x1_l; + let prev_y = state.lr_delay_y1_l; + let y = -a * *l + prev_x + a * prev_y; + state.lr_delay_x1_l = *l; + state.lr_delay_y1_l = y; + *l = y; + } +} + +#[cfg(target_os = "linux")] +fn process_rta_sample(bank: &mut RtaBank, in_l: f32, in_r: f32) { + for (band_index, sections) in bank.filters.iter_mut().enumerate() { + let mut out_l = in_l; + let mut out_r = in_r; + for section in sections.iter_mut() { + out_l = process_section(section, out_l, 0); + out_r = process_section(section, out_r, 1); + } + bank.acc_l[band_index] += out_l * out_l; + bank.acc_r[band_index] += out_r * out_r; + } +} + +#[cfg(target_os = "linux")] +fn push_fft_sample(state: &mut FftRtaState, l: f32, r: f32) { + state.ring_l[state.ring_pos] = l; + state.ring_r[state.ring_pos] = r; + state.ring_pos = (state.ring_pos + 1) % state.fft_size; + state.ring_fill = (state.ring_fill + 1).min(state.fft_size); + state.samples_since = state.samples_since.saturating_add(1); +} + +#[cfg(target_os = "linux")] +fn push_spectro_sample(state: &mut SpectroState, l: f32, r: f32) { + state.ring_l[state.ring_pos] = l; + state.ring_r[state.ring_pos] = r; + state.ring_pos = (state.ring_pos + 1) % state.fft_size; + state.ring_fill = (state.ring_fill + 1).min(state.fft_size); + state.samples_since = state.samples_since.saturating_add(1); +} + +#[cfg(target_os = "linux")] +fn finalize_rta_bank( + bank: &mut RtaBank, + sample_rate: u32, + block_size: usize, + channel_count: usize, + config: &PhoenixRtaConfig, +) { + let dt = block_size as f32 / sample_rate as f32; + let tau = integration_tau(config); + let k = 1.0 - (-dt / tau.max(0.001)).exp(); + let release_step = RTA_PEAK_DECAY_DB_PER_S * dt; + let weighting = normalize_weighting(&config.weighting); + let denom = (channel_count.max(1) * block_size.max(1)) as f32; + + for i in 0..bank.energies.len() { + let power = (bank.acc_l[i] + if channel_count > 1 { bank.acc_r[i] } else { 0.0 }) / denom; + let new_energy = bank.energies[i] + k * (power - bank.energies[i]); + bank.energies[i] = new_energy.max(1.0e-12); + let band = &bank.band_defs[i]; + let weighted = bank.energies[i] * band_weighting_gain(band.f_lo, band.center, band.f_hi, weighting); + let level_db = 10.0 * weighted.max(1.0e-12).log10(); + bank.levels[i] = level_db; + + let prev_peak = bank.peaks[i]; + if !prev_peak.is_finite() || level_db >= prev_peak { + bank.peaks[i] = level_db; + } else { + bank.peaks[i] = (prev_peak - release_step).max(level_db).max(RTA_PEAK_FLOOR_DB); + } + + bank.acc_l[i] = 0.0; + bank.acc_r[i] = 0.0; + } +} + +#[cfg(target_os = "linux")] +fn finalize_fft_state(state: &mut FftRtaState, sample_rate: u32, config: &PhoenixRtaConfig) -> bool { + if state.ring_fill < state.fft_size || state.samples_since < state.fft_step_samples { + return false; + } + state.samples_since -= state.fft_step_samples; + + compute_fft_power_bins( + &state.ring_l, + &state.ring_r, + state.ring_pos, + &mut state.fft_re_l, + &mut state.fft_im_l, + &mut state.fft_re_r, + &mut state.fft_im_r, + &mut state.power_bins, + ); + + let dt = state.fft_step_samples as f32 / sample_rate as f32; + let tau = integration_tau(config); + let k = 1.0 - (-dt / tau.max(0.001)).exp(); + let release_step = RTA_PEAK_DECAY_DB_PER_S * dt; + let weighting = normalize_weighting(&config.weighting); + for (i, band) in state.mapping.iter().enumerate() { + let mut band_power = 0.0f32; + for seg in &band.bins { + if let Some(power) = state.power_bins.get(seg.index) { + band_power += *power * seg.weight; + } + } + let weighted = band_power.max(1.0e-12) * band_weighting_gain(band.f_lo, band.center, band.f_hi, weighting); + let db = 10.0 * weighted.max(1.0e-12).log10(); + let prev = state.levels[i]; + state.levels[i] = prev + k * (db - prev); + let prev_peak = state.peaks[i]; + if !prev_peak.is_finite() || state.levels[i] >= prev_peak { + state.peaks[i] = state.levels[i]; + } else { + state.peaks[i] = (prev_peak - release_step).max(state.levels[i]).max(RTA_PEAK_FLOOR_DB); + } + } + true +} + +#[cfg(target_os = "linux")] +fn finalize_spectro_state(state: &mut SpectroState, sample_rate: u32) -> bool { + if state.ring_fill < state.fft_size || state.samples_since < state.fft_step_samples { + return false; + } + state.samples_since -= state.fft_step_samples; + + compute_fft_power_bins( + &state.ring_l, + &state.ring_r, + state.ring_pos, + &mut state.fft_re_l, + &mut state.fft_im_l, + &mut state.fft_re_r, + &mut state.fft_im_r, + &mut state.bins, + ); + + let floor = -160.0f32; + for bin in &mut state.bins { + *bin = 10.0 * bin.max(1.0e-16).log10(); + if !bin.is_finite() || *bin < floor { + *bin = floor; + } + } + let _ = sample_rate; + true +} + +#[cfg(target_os = "linux")] +fn build_iir_rta_frame(bank: &RtaBank, sample_rate: u32, config: &PhoenixRtaConfig) -> RtaFrame { + RtaFrame { + engine: "iir".to_string(), + bands_avg: bank.levels.clone(), + bands_peak: bank.peaks.clone(), + bands: bank.levels.clone(), + centers: bank.centers.clone(), + freq_min: bank.freq_min, + freq_max: bank.freq_max, + bpo: bank.bpo_key.to_string(), + weighting: normalize_weighting(&config.weighting).to_string(), + layout: bank.layout.to_string(), + sample_rate, + } +} + +#[cfg(target_os = "linux")] +fn build_fft_rta_frame(state: &FftRtaState, sample_rate: u32, config: &PhoenixRtaConfig) -> RtaFrame { + RtaFrame { + engine: "fft".to_string(), + bands_avg: state.levels.clone(), + bands_peak: state.peaks.clone(), + bands: state.levels.clone(), + centers: state.centers.clone(), + freq_min: state.freq_min, + freq_max: state.freq_max, + bpo: state.bpo_key.to_string(), + weighting: normalize_weighting(&config.weighting).to_string(), + layout: state.layout.to_string(), + sample_rate, + } +} + +#[cfg(target_os = "linux")] +fn build_spectro_frame(state: &SpectroState, sample_rate: u32) -> SpectroFrame { + SpectroFrame { + bins: state.bins.clone(), + sample_rate, + fft_size: state.fft_size as u32, + } +} + +#[cfg(target_os = "linux")] +fn dbfs(value: f32) -> f32 { + 20.0 * value.max(1.0e-9).log10() +} + +#[cfg(target_os = "linux")] +fn normalize_bpo_key(value: &str) -> &'static str { + match value.trim().replace('/', "_").as_str() { + "1_3" => "1_3", + "1_12" => "1_12", + _ => "1_6", + } +} + +#[cfg(target_os = "linux")] +fn normalize_engine(value: &str) -> &'static str { + match value.trim().to_ascii_lowercase().as_str() { + "fft" => "fft", + _ => "iir", + } +} + +#[cfg(target_os = "linux")] +fn normalize_fft_size(value: u32) -> usize { + match value { + 2048 => 2048, + 4096 => 4096, + 8192 => 8192, + 16384 => 16384, + n if n < 3072 => 2048, + n if n < 6144 => 4096, + n if n < 12288 => 8192, + _ => 16384, + } +} + +#[cfg(target_os = "linux")] +fn normalize_layout(value: &str) -> &'static str { + match value.trim().to_ascii_lowercase().as_str() { + "iec" => "iec", + _ => "rtw", + } +} + +#[cfg(target_os = "linux")] +fn normalize_weighting(value: &str) -> &'static str { + match value.trim().to_ascii_lowercase().as_str() { + "a" => "a", + "c" => "c", + _ => "z", + } +} + +#[cfg(target_os = "linux")] +fn normalize_freq_range(value: &str) -> &'static str { + match value.trim().to_ascii_lowercase().as_str() { + "lf" => "lf", + _ => "norm", + } +} + +#[cfg(target_os = "linux")] +fn bpo_value(bpo_key: &str) -> usize { + match bpo_key { + "1_3" => 3, + "1_12" => 12, + _ => 6, + } +} + +#[cfg(target_os = "linux")] +fn freq_range_bounds(sample_rate: u32, freq_range: &str) -> (f32, f32) { + let max: f32 = if normalize_freq_range(freq_range) == "lf" { 5000.0 } else { 20_000.0 }; + let min: f32 = if normalize_freq_range(freq_range) == "lf" { 5.0 } else { 20.0 }; + (min, max.min(sample_rate as f32 * 0.5)) +} + +#[cfg(target_os = "linux")] +fn integration_tau(config: &PhoenixRtaConfig) -> f32 { + if config.integration.trim().eq_ignore_ascii_case("slow") { + config.tau_slow.max(0.05) + } else { + config.tau_fast.max(0.01) + } +} + +#[cfg(target_os = "linux")] +fn q_for_bpo(n: f32) -> f32 { + let up = 2.0f32.powf(1.0 / (2.0 * n)); + let down = 2.0f32.powf(-1.0 / (2.0 * n)); + 1.0 / (up - down) +} + +#[cfg(target_os = "linux")] +fn build_rta_bands(sample_rate: u32, layout: &'static str, bpo_key: &'static str, freq_range: &str) -> Vec { + let (freq_min, freq_max) = freq_range_bounds(sample_rate, freq_range); + if layout == "rtw" { + return build_rtw_bands(freq_min, freq_max, bpo_key); + } + make_fractional_bands(freq_min, freq_max, bpo_value(bpo_key) as f32) +} + +#[cfg(target_os = "linux")] +fn build_rtw_bands(freq_min: f32, freq_max: f32, bpo_key: &'static str) -> Vec { + let centers = match bpo_key { + "1_3" => RTW_CENTERS_1_3, + "1_12" => RTW_CENTERS_1_12, + _ => RTW_CENTERS_1_6, + }; + let factor = 2.0f32.powf(1.0 / (2.0 * bpo_value(bpo_key) as f32)); + let mut bands = Vec::new(); + for &fc in centers { + if !fc.is_finite() || fc < freq_min || fc > freq_max { + continue; + } + let f_lo = (fc / factor).max(freq_min); + let f_hi = (fc * factor).min(freq_max); + if f_hi <= f_lo { + continue; + } + bands.push(RtaBandDef { center: fc, f_lo, f_hi }); + } + bands +} + +#[cfg(target_os = "linux")] +fn make_fractional_bands(freq_min: f32, freq_max: f32, bpo: f32) -> Vec { + let center_ref = 1000.0f32; + let factor = 2.0f32.powf(1.0 / (2.0 * bpo)); + let k_min = (bpo * (freq_min / center_ref).log2()).ceil() as i32; + let k_max = (bpo * (freq_max / center_ref).log2()).floor() as i32; + let mut bands = Vec::new(); + for k in k_min..=k_max { + let center = center_ref * 2.0f32.powf(k as f32 / bpo); + let f_lo = (center / factor).max(freq_min); + let f_hi = (center * factor).min(freq_max); + if f_hi < freq_min || f_lo > freq_max || f_hi <= f_lo { + continue; + } + bands.push(RtaBandDef { center, f_lo, f_hi }); + } + bands +} + +#[cfg(target_os = "linux")] +fn compute_bandpass_coeffs(fc: f32, q: f32, sr: f32) -> BiquadCoeffs { + let w0 = 2.0 * std::f32::consts::PI * fc / sr; + let cos_w0 = w0.cos(); + let sin_w0 = w0.sin(); + let alpha = sin_w0 / (2.0 * q); + let b0 = alpha; + let b1 = 0.0; + let b2 = -alpha; + let a0 = 1.0 + alpha; + let a1 = -2.0 * cos_w0; + let a2 = 1.0 - alpha; + BiquadCoeffs { + b0: b0 / a0, + b1: b1 / a0, + b2: b2 / a0, + a1: a1 / a0, + a2: a2 / a0, + } +} + +#[cfg(target_os = "linux")] +fn create_section(coeffs: BiquadCoeffs) -> RtaSectionState { + RtaSectionState { + b0: coeffs.b0, + b1: coeffs.b1, + b2: coeffs.b2, + a1: coeffs.a1, + a2: coeffs.a2, + z1_l: 0.0, + z2_l: 0.0, + z1_r: 0.0, + z2_r: 0.0, + } +} + +#[cfg(target_os = "linux")] +fn process_section(section: &mut RtaSectionState, x: f32, channel: usize) -> f32 { + if channel == 1 { + let y = section.b0 * x + section.z1_r; + section.z1_r = section.b1 * x - section.a1 * y + section.z2_r; + section.z2_r = section.b2 * x - section.a2 * y; + return y; + } + let y = section.b0 * x + section.z1_l; + section.z1_l = section.b1 * x - section.a1 * y + section.z2_l; + section.z2_l = section.b2 * x - section.a2 * y; + y +} + +#[cfg(target_os = "linux")] +fn build_fft_band_mapping(bands: &[RtaBandDef], nyq: f32, bin_count: usize) -> Vec { + let mut result = Vec::with_capacity(bands.len()); + if !nyq.is_finite() || nyq <= 0.0 || bin_count == 0 { + return result; + } + let bin_width = nyq / bin_count as f32; + for band in bands { + let start = ((band.f_lo / nyq) * bin_count as f32).floor().max(0.0) as usize; + let end = ((band.f_hi / nyq) * bin_count as f32) + .ceil() + .min((bin_count.saturating_sub(1)) as f32) as usize; + let mut bins = Vec::new(); + let mut weight_sum = 0.0f32; + for index in start..=end { + let bin_start = index as f32 * bin_width; + let bin_end = bin_start + bin_width; + let overlap = (bin_end.min(band.f_hi) - bin_start.max(band.f_lo)).max(0.0); + if overlap > 0.0 { + bins.push(FftBandSeg { index, weight: overlap }); + weight_sum += overlap; + } + } + if bins.is_empty() { + let idx = ((band.center / nyq) * bin_count as f32) + .round() + .clamp(0.0, (bin_count.saturating_sub(1)) as f32) as usize; + bins.push(FftBandSeg { index: idx, weight: 1.0 }); + weight_sum = 1.0; + } + for seg in &mut bins { + seg.weight /= weight_sum.max(1.0e-12); + } + result.push(FftBandMap { + center: band.center, + f_lo: band.f_lo, + f_hi: band.f_hi, + bins, + }); + } + result +} + +#[cfg(target_os = "linux")] +fn compute_fft_power_bins( + ring_l: &[f32], + ring_r: &[f32], + ring_pos: usize, + fft_re_l: &mut [f32], + fft_im_l: &mut [f32], + fft_re_r: &mut [f32], + fft_im_r: &mut [f32], + out_power_bins: &mut [f32], +) { + let fft_size = fft_re_l.len(); + let mut window_sum = 0.0f32; + for i in 0..fft_size { + let src_idx = (ring_pos + i) % fft_size; + let phase = (2.0 * std::f32::consts::PI * i as f32) / (fft_size.saturating_sub(1) as f32); + let win = 0.5 - 0.5 * phase.cos(); + window_sum += win; + fft_re_l[i] = ring_l[src_idx] * win; + fft_im_l[i] = 0.0; + fft_re_r[i] = ring_r[src_idx] * win; + fft_im_r[i] = 0.0; + } + + fft_in_place(fft_re_l, fft_im_l); + fft_in_place(fft_re_r, fft_im_r); + + let fft_norm = ((window_sum * 0.5).max(1.0)).powi(2); + for bin in 0..out_power_bins.len() { + let power_l = fft_re_l[bin] * fft_re_l[bin] + fft_im_l[bin] * fft_im_l[bin]; + let power_r = fft_re_r[bin] * fft_re_r[bin] + fft_im_r[bin] * fft_im_r[bin]; + out_power_bins[bin] = 0.5 * (power_l + power_r) / fft_norm; + } +} + +#[cfg(target_os = "linux")] +fn configured_input_offset_db(value: f32) -> f32 { + if value.is_finite() { value.clamp(-60.0, 20.0) } else { -5.0 } +} + +#[cfg(target_os = "linux")] +fn db_gain(db: f32) -> f32 { + 10.0f32.powf(db / 20.0) +} + +#[cfg(target_os = "linux")] +fn fft_in_place(re: &mut [f32], im: &mut [f32]) { + let n = re.len(); + if n == 0 || n != im.len() || !n.is_power_of_two() { + return; + } + + let mut j = 0usize; + for i in 1..n { + let mut bit = n >> 1; + while j & bit != 0 { + j &= !bit; + bit >>= 1; + } + j |= bit; + if i < j { + re.swap(i, j); + im.swap(i, j); + } + } + + let mut len = 2usize; + while len <= n { + let ang = -2.0 * std::f32::consts::PI / len as f32; + let wlen_re = ang.cos(); + let wlen_im = ang.sin(); + let half = len / 2; + let mut start = 0usize; + while start < n { + let mut w_re = 1.0f32; + let mut w_im = 0.0f32; + for offset in 0..half { + let u_re = re[start + offset]; + let u_im = im[start + offset]; + let v_idx = start + offset + half; + let v_re = re[v_idx] * w_re - im[v_idx] * w_im; + let v_im = re[v_idx] * w_im + im[v_idx] * w_re; + re[start + offset] = u_re + v_re; + im[start + offset] = u_im + v_im; + re[v_idx] = u_re - v_re; + im[v_idx] = u_im - v_im; + let next_w_re = w_re * wlen_re - w_im * wlen_im; + let next_w_im = w_re * wlen_im + w_im * wlen_re; + w_re = next_w_re; + w_im = next_w_im; + } + start += len; + } + len <<= 1; + } +} + +#[cfg(target_os = "linux")] +fn weighting_db(freq: f32, mode: &str) -> f32 { + if !freq.is_finite() || freq <= 0.0 { + return 0.0; + } + let f2 = freq * freq; + match normalize_weighting(mode) { + "a" => { + let ra_num = (12_194.0f32 * 12_194.0f32) * f2 * f2; + let ra_den = + (f2 + 20.6f32 * 20.6f32) + * ((f2 + 107.7f32 * 107.7f32) * (f2 + 737.9f32 * 737.9f32)).sqrt() + * (f2 + 12_194.0f32 * 12_194.0f32); + 20.0 * (ra_num / ra_den).max(1.0e-12).log10() + 2.0 + } + "c" => { + let rc_num = (12_194.0f32 * 12_194.0f32) * f2; + let rc_den = (f2 + 20.6f32 * 20.6f32) * (f2 + 12_194.0f32 * 12_194.0f32); + 20.0 * (rc_num / rc_den).max(1.0e-12).log10() + 0.06 + } + _ => 0.0, + } +} + +#[cfg(target_os = "linux")] +fn weighting_gain(freq: f32, mode: &str) -> f32 { + let db = weighting_db(freq, mode); + 10.0f32.powf(db / 10.0) +} + +#[cfg(target_os = "linux")] +fn band_weighting_gain(f_lo: f32, center: f32, f_hi: f32, mode: &str) -> f32 { + if normalize_weighting(mode) == "z" { + return 1.0; + } + let lo = f_lo.max(1.0); + let hi = f_hi.max(lo); + let sample_points = [0.0f32, 0.25, 0.5, 0.75, 1.0]; + let mut sum = 0.0f32; + for t in sample_points { + let freq = if hi > lo { + lo * (hi / lo).powf(t) + } else { + center.max(1.0) + }; + sum += weighting_gain(freq, mode); + } + sum / sample_points.len() as f32 +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..b873d6e --- /dev/null +++ b/src/config.rs @@ -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, + #[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::().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::().ok()) + .unwrap_or(48_000), + period_size: std::env::var("PHOENIX_PERIOD_SIZE") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(128), + buffer_size: std::env::var("PHOENIX_BUFFER_SIZE") + .ok() + .and_then(|v| v.parse::().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::().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::().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") +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..11e4a0c --- /dev/null +++ b/src/main.rs @@ -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(()) +} diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..dd61d7a --- /dev/null +++ b/src/model.rs @@ -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, + pub bands_peak: Vec, + pub bands: Vec, + pub centers: Vec, + 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, + pub sample_rate: u32, + pub fft_size: u32, +} + +#[derive(Clone, Debug, Serialize)] +pub struct WaveEnvFrame { + pub data: Vec, + 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub lufs_s: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub lufs_i: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub lra: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub lufs_ml: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub lufs_mr: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub lufs_sl: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub lufs_sr: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ppm_box_l: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ppm_box_r: Option, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub wave_l: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub wave_r: Vec, + pub wave_channels: u8, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub xy_l: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub xy_r: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub rta: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub spectro: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub wave_env: Option, + pub global_config_rev: u64, + pub input: InputSource, + pub source: &'static str, +} diff --git a/src/routes.rs b/src/routes.rs new file mode 100644 index 0000000..858238f --- /dev/null +++ b/src/routes.rs @@ -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, +} + +#[derive(Debug, Deserialize)] +pub struct FrontendPresetSavePayload { + pub preset_id: String, + pub config: serde_json::Value, +} + +pub async fn health() -> Json { + Json(serde_json::json!({ + "ok": true, + "service": "phoenix" + })) +} + +pub async fn status(State(state): State) -> Json { + Json(state.status().await) +} + +pub async fn get_rta_config(State(state): State) -> Json { + Json(state.rta_config().await) +} + +pub async fn get_global_config(State(state): State) -> Json { + Json(state.global_config_envelope().await) +} + +pub async fn get_frontend_presets(State(state): State) -> 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, + Json(payload): Json, +) -> 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) -> 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, + Json(payload): Json, +) -> 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, + Json(payload): Json, +) -> Json { + 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, + Json(payload): Json, +) -> 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) -> Response { + ws.on_upgrade(move |socket| metrics_ws_inner(socket, state)) +} + +pub async fn start_wav_recording( + State(state): State, + Path(session_id): Path, +) -> 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, + Path(session_id): Path, +) -> Response { + stop_recording_inner(state, session_id, "wav", None).await +} + +pub async fn stop_recording_with_format( + State(state): State, + Path((session_id, format)): Path<(u64, String)>, + Query(query): Query, +) -> 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) -> 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, +) -> anyhow::Result<(&'static str, Vec)> { + 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> { + 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> { + 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 { + 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, + 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> { + 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::(&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> { + 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::(&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, +) -> 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, +) -> 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 { + 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 { + 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 { + 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 { + 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::() +} + +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 { + 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()) +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..07d6cac --- /dev/null +++ b/src/state.rs @@ -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>, + seq: Arc, + metrics_tx: broadcast::Sender, + restart_token: Arc, + rta_config: Arc>, + global_config: Arc>, + global_config_rev: Arc, + native_wav_recorders: Arc>>, +} + +#[derive(Clone)] +pub struct NativeWavCapture { + pub sample_rate: u32, + pub channels: u16, + pub pcm_bytes: Vec, +} + +pub(crate) struct NativeWavRecorder { + pub sample_rate: u32, + pub channels: u16, + pub pcm_bytes: Vec, + 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 { + 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 { + 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 { + 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::(&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(()) +} diff --git a/toggle-ext/bg.js b/toggle-ext/bg.js new file mode 100644 index 0000000..e9cc7c3 --- /dev/null +++ b/toggle-ext/bg.js @@ -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}); + } +}); diff --git a/toggle-ext/content.js b/toggle-ext/content.js new file mode 100644 index 0000000..88aeac6 --- /dev/null +++ b/toggle-ext/content.js @@ -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){}})(); diff --git a/toggle-ext/manifest.json b/toggle-ext/manifest.json new file mode 100644 index 0000000..cf609d1 --- /dev/null +++ b/toggle-ext/manifest.json @@ -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"} } diff --git a/www/assets/dvdlogo.svg b/www/assets/dvdlogo.svg new file mode 100644 index 0000000..83f6b95 --- /dev/null +++ b/www/assets/dvdlogo.svg @@ -0,0 +1 @@ +08dvdlogo- \ No newline at end of file diff --git a/www/core/audio.js b/www/core/audio.js new file mode 100644 index 0000000..643f443 --- /dev/null +++ b/www/core/audio.js @@ -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); +} diff --git a/www/core/config.js b/www/core/config.js new file mode 100644 index 0000000..3fbfd09 --- /dev/null +++ b/www/core/config.js @@ -0,0 +1,1252 @@ +// core/config.js — zentrale CONFIG + Presets + Persistenz + +function isLoopbackHost(host) { + const h = String(host || '').trim().toLowerCase(); + return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '[::1]'; +} + +function normalizePhoenixBaseUrlForCurrentClient(rawUrl) { + const fallback = resolvePhoenixDefaultBaseUrl(); + const input = String(rawUrl || '').trim(); + if (!input) return fallback; + try { + const currentHost = String(globalThis?.location?.hostname || '').trim(); + const parsed = new URL(input, fallback); + if (!currentHost) return parsed.toString(); + if (!isLoopbackHost(currentHost)) { + const parsedHost = String(parsed.hostname || '').trim(); + if (isLoopbackHost(parsedHost)) { + parsed.hostname = currentHost; + } + } + return parsed.toString(); + } catch (_) { + return fallback; + } +} + +function resolvePhoenixDefaultBaseUrl() { + try { + const host = String(globalThis?.location?.hostname || '').trim(); + if (host) return `http://${host}:8789`; + } catch (_) {} + return 'http://127.0.0.1:8789'; +} + +const DEFAULT_PHOENIX_BASE_URL = resolvePhoenixDefaultBaseUrl(); +const SOFTWARE_PRESET_ENDPOINT = '/api/v1/frontend-presets'; +const LAYOUT_PRESET_ENDPOINT = '/api/v1/frontend-layouts'; + +// Define CONFIG first, then export it +const CONFIG = { + FFT_SIZE: 8192, + PHOENIX_BASE_URL: DEFAULT_PHOENIX_BASE_URL, + DBFS_TOP: 9, + DBFS_BOTTOM: -63, + AXIS_GUTTER_LEFT: 14, + Y_TICKS: [], + AL_MARKERS_ENABLED: false, + REALTIME_RENDER_STYLE: 'bars', + REALTIME_BAR_HOLD_MS: 800, + REALTIME_BAR_DECAY_DB_PER_S: 20, + RTA_ENGINE: 'iir', // 'fft' | 'iir' + RTA_FREQ_RANGE: 'norm', // 'norm' | 'lf' + RTA_BPO_MODE: '1_6', + RTA_WEIGHTING: 'z', // 'z' | 'a' | 'c' + RTA_DISPLAY_GAIN_FFT_DB: 20, + RTA_DISPLAY_GAIN_IIR_DB: 20, + RTA_BAR_LAYOUT: 'rtw', // 'iec' | 'rtw' + RTA_BAR_BASE_COLOR: '#ffe066', + HEADER_TEXT_COLOR: '#ffe066', + RTA_INTEGRATION: 'fast', // 'impulse' | 'fast' | 'slow' | 'peak' + RTA_BALLISTICS_MODE: 'average', // 'average' | 'peak' | 'both' + RTA_PEAK_HOLD_MODE: 'auto', // 'off' | 'auto' | 'manual' + RTA_PEAK_HOLD_SEC: 2, + RTA_PEAK_DECAY_DB_PER_S: 20, + RTA_DISPLAY_HOLD_SEC: 0, + RTA_IIR_ORDER: 4, + RTA_IIR_TAU_FAST: 0.12, + RTA_IIR_TAU_SLOW: 1.0, + SPECTRO_GAMMA: 0.4, + SPECTRO_SCROLL_MODE: 1, // 0.5=Langsam, 1=Normal, 2=Licht, 4=Lächerlich, 6=Wahnsinnig + PEAK_HISTORY_SCROLL_MODE: 1, // 0.5=Langsam, 1=Normal, 2=Schnell, 4=Turbo, 6=Max + PEAK_HISTORY_FILL_ENABLED: false, + PEAK_HISTORY_FILL_INVERT: false, + PEAK_HISTORY_SLOT: 'ppm-din', + SPECTRO_USE_WORKER: true, // Falls true: Offscreen-Worker; false: Fallback im Main-Thread + SPECTRO_DEBUG: false, + WAVEFORM_WINDOW_SEC: 1, + WAVEFORM_MODE: 'stacked', + WAVEFORM_COLOR_LEFT: '#00e7ff', + WAVEFORM_COLOR_RIGHT: '#ff6b81', + WAVEFORM_COLOR_DIFF: '#00e7ff', + INPUT_OFFSET_DB_L: -5.0, + INPUT_OFFSET_DB_R: -5.0, + INPUT_OFFSET_GAIN_L: Math.pow(10, -5.0 / 20), + INPUT_OFFSET_GAIN_R: Math.pow(10, -5.0 / 20), + MONO_INPUT: false, + LR_FRACTIONAL_DELAY_ENABLED: true, + LR_FRACTIONAL_DELAY_SAMPLES: 0.996, + SCREENSAVER_ENABLED: true, + SCREENSAVER_IDLE_MIN: 5, + SCREENSAVER_ACTIVITY_DB: -50, + SCREENSAVER_MODE: 'clock', // 'dvd' | 'starfield' | 'clock' | 'black' + SCREENSAVER_LED_GLOW: true, + SCREENSAVER_LED_COLOR: '#ff0000', + CLOCK_LED_GLOW: true, + CLOCK_LED_COLOR: '#ff0000', + CLOCK_STYLE: 'analog', // 'analog' | 'digital' | 'analog-ppm' | 'digital-ppm' + CLOCK_PPM_LED_MODE: false, + // Alignment-Profile (EBU default) definieren die digitale Referenz für 0 dBu. + ALIGNMENT_MODE: 'ebu', + DIGITAL_REF_DBFS_RMS: -18.0, // 0 dBu ≙ −18 dBFS RMS (EBU) + VU_REF_IS_PLUS4: true, + // 0 VU soll +4 dBu entsprechen → DIGITAL_REF + 4 dB. + VU_DBFS_REF: -14.0, + VU_OFFSET_DB: 1.92, + VU_HEADER_SHOW_VALUE: false, + TP_OFFSET_DB: 0.0, + TP_HEADER_SHOW_VALUE: false, + RMS_OFFSET_DB: 0.0, + RMS_HEADER_SHOW_VALUE: false, + VU_RED_START: 0.0, + TP_RED_START: -1.0, + RMS_RED_START: -3.0, + VU_RED_BAR_ONLY: true, + PPM_RED_BAR_ONLY: true, + TP_RED_BAR_ONLY: true, + RMS_RED_BAR_ONLY: true, + RMS_TC_MODE: 'fast', // 'impulse' (35 ms), 'fast' (125 ms), 'slow' (1000 ms), 'none' + RMS_TC_MS: null, // optional override in ms (falls gesetzt, überschreibt den Modus) + VU_COLOR_NORMAL: '#ffe066', + VU_COLOR_WARN: '#ff3b3b', + PPM_DIN_COLOR_NORMAL: '#ffe066', + PPM_DIN_COLOR_WARN: '#ff3b3b', + PPM_DIN_HEADER_SHOW_VALUE: false, + PPM_EBU_COLOR_NORMAL: '#ffe066', + PPM_EBU_COLOR_WARN: '#ff3b3b', + PPM_EBU_HEADER_SHOW_VALUE: false, + TP_COLOR_NORMAL: '#ffe066', + TP_COLOR_WARN: '#ff3b3b', + HIFI_PEAK_ALIGNMENT: 'ppm_din_minus5', + PPM_DIN_LOUDNESS_BOXES: true, + PPM_DIN_LOUDNESS_OFFSET_DB: 0.0, + // RMS colours: use a green bar for normal RMS to distinguish from PPM/VU. + RMS_COLOR_NORMAL: '#34d399', + RMS_COLOR_WARN: '#ff3b3b', + // RMS meter defaults to dBFS mode. In German Rundfunk entspricht der + // Alignment‑Level (0 dBu) −18 dBFS RMS【212988610891871†L41-L43】; 0 VU (+4 dBu) + // liegt 4 dB darüber bei −14 dBFS. Viele Anwender möchten die digitale + // Anzeige (dBFS) sehen. Setze daher RMS_MODE auf 'dbfs'. Wenn RMS_MODE + // auf 'dbu' umgeschaltet würde, nutzt die Umrechnung die folgenden + // Referenzen: 0 dBu entspricht −18 dBFS RMS (RMS_REF_DBFS_FOR_REF_DBU) und + // RMS_REF_DBU definiert den dBu‑Wert bei diesem Pegel. Wir setzen + // RMS_REF_DBU auf 0, sodass −18 dBFS → 0 dBu und −14 dBFS → +4 dBu. + RMS_MODE: 'dbfs', + RMS_REF_DBFS_FOR_REF_DBU: -18, + RMS_REF_DBU: 0, + // EBU (Type IIb) PPM scale settings. The scale runs from −12 dB to + // +12 dB with the red zone starting at +9 dB. Attack and decay + // parameters are defined below. + PPM_EBU_TOP: +12, + PPM_EBU_BOTTOM: -12, + PPM_EBU_RED_START: +9, + // DIN (Type I) PPM scale settings. The DIN scale covers −50 dB to +5 dB + // and the red zone begins at 0 dB. Attack and decay parameters follow. + PPM_DIN_TOP: +5, + PPM_DIN_BOTTOM: -50, + PPM_DIN_RED_START: 0, + // DIN PPM attack and decay times: 5 ms integration and 20 dB in 1.7 s + // (≈11.8 dB/s) return, per IEC 60268‑10 Type I. + PPM_DIN_ATTACK_MS: 5, + // Non-normative: when enabled, PPM DIN attack becomes instant (no integration). + // Useful to compensate perceived meter lag caused by device / pipeline latency. + PPM_DIN_FAST_ATTACK: false, + PPM_DIN_DECAY_DB_PER_S: 11.8, + // Hold time for DIN PPM: verlängert auf 1000 ms (1 s), damit der Peak‑Hold + // auch bei kurzen Transienten deutlich sichtbar bleibt. Gemäß + // Rundfunkpraxis werden Peak‑Hold‑Striche häufig 1–2 Sekunden gehalten. + PPM_DIN_HOLD_MS: 1000, + // Decay rate for the DIN PPM hold. When the held peak decays, it uses + // the same slope as the main meter (≈11.8 dB/s). + PPM_DIN_HOLD_DECAY_DB_PER_S: 11.8, + + // Offsets für die PPM-Meter (DIN/EBU-spezifisch). Die EBU/DIN Offsets + // ersetzen den früheren globalen PPM_OFFSET. + PPM_DIN_OFFSET: -8.90, + PPM_EBU_OFFSET: 0.0, + PPM_REF_DBFS_PEAK_FOR_0_DBU: -15.0, + + // Ballistic parameters for the EBU PPM (Type IIb). Attack ~10 ms and + // decay 24 dB in 2.8 s (≈8.6 dB/s), per IEC 60268‑10 Type II. A short + // peak-hold (~750 ms) helps visualise transients. Hold decay matches + // the main decay rate. + PPM_EBU_ATTACK_MS: 10, + PPM_EBU_DECAY_DB_PER_S: 8.6, + PPM_EBU_HOLD_MS: 750, + PPM_EBU_HOLD_DECAY_DB_PER_S: 8.6, + CORR_SMOOTH: 0.85, + CORR_ZERO_ON_SILENCE: false, + CORR_SILENCE_THRESHOLD_RMS_DBFS: -75, + XY_POINTS: 1024, + XY_STYLE: 'lines', + XY_SILENCE_GATE_ENABLED: true, + XY_SILENCE_THRESHOLD_RMS_DBFS: -75, + METER_BAR_THIN: 0.55, + GONI_METER_GAP: 6, + GONIO_DISPLAY_GAIN_DB: -5, + GONIO_MANUAL_GAIN_DB: -5, + GONIO_AGC_ENABLED: false, + GONIO_LINE_FADE_MS: 50, + PHASE_DISPLAY_GAIN_DB: 0, + PHASE_AGC_ENABLED: false, + PHASE_TRAIL_ENABLED: true, + PHASE_AMPLITUDE_MODE: 'ppm-din', // 'bandpass' | 'ppm-din' + CLASSIC_NEEDLES_SLOT: 'vu', + PANEL_DIVIDERS_ENABLED: true, + LUFS_RED_START: -1.0, + LUFS_YELLOW_START: -2.0, + LUFS_GREEN_START: -5.0, + LUFS_COLOR_GREEN: '#34d399', + LUFS_COLOR_YELLOW: '#ffe066', + LUFS_COLOR_RED: '#ff3b3b', + LUFS_COLOR_I_GREEN: '#34d399', + LUFS_COLOR_I_YELLOW: '#ffe066', + LUFS_COLOR_I_RED: '#ff3b3b', + LUFS_COLOR_I: '#34d399', + LUFS_COLOR_M_GREEN: '#34d399', + LUFS_COLOR_M_YELLOW: '#ffe066', + LUFS_COLOR_M_RED: '#ff3b3b', + LUFS_COLOR_M: '#ffe066', + LUFS_COLOR_S_GREEN: '#34d399', + LUFS_COLOR_S_YELLOW: '#ffe066', + LUFS_COLOR_S_RED: '#ff3b3b', + LUFS_COLOR_S: '#ff3b3b', + LUFS_SCALE_LABEL_COLOR: '#8fd3d4', + // LUFS Integrated: rollierendes Zeitfenster (Minuten). + // Hinweis: Erhöhen kann bereits verworfene Historie nicht zurückholen. + LUFS_I_WINDOW_MIN: 4, + // LUFS Integrated: normgerecht über gesamte Laufzeit (seit Start/Reset), + // deaktiviert das rollierende Zeitfenster. + LUFS_I_NORM_ENABLED: false, + // Stoppuhr: Anzeige-Stil im Slot-Meter. + // 'mono' = normale Canvas-Schrift, 'seven' = 7-Segment-Rendering. + STOPWATCH_DISPLAY_STYLE: 'mono', + STOPWATCH_AUTO_THRESHOLD_DBFS: -70, + // UI: zuletzt gewählte Ansicht (Fallback, falls localStorage-Flush bei hartem Neustart zu spät ist). + UI_LAST_STYLE: 'realtime', + UI_LAST_NON_OPTION_STYLE: 'realtime', + RECORD_OUTPUT_FORMAT: 'wav', // 'wav' | 'mp3' | 'webm' + RECORD_MP3_BITRATE_KBPS: 192, + RECORD_TARGET: 'download', // 'download' | 'analyzer' | 'volumio' + RECORD_PI_TARGET: 'analyzer', // globales Pi-Ziel: 'analyzer' | 'volumio' + RECORD_AUTO_DOWNLOAD: false, + RECORD_AUTO_SPLIT_GAP_SEC: 1.5, + RECORD_AUTO_THRESHOLD_DBFS: -50, + RECORD_SHOW_RECORDER_AB: true, + SPLIT_VIEW_LEFT: 'realtime', + SPLIT_VIEW_RIGHT: 'goniometer-rtw', + SPLIT_VIEW_TAP_ACTION: 'popup', // 'switch' | 'popup' + QUAD_VIEW_TL: 'peak-history', + QUAD_VIEW_TR: 'realtime', + QUAD_VIEW_BL: 'phase-wheel', + QUAD_VIEW_BR: 'goniometer-rtw', + QUAD_VIEW_TAP_ACTION: 'popup', // 'switch' | 'popup' + QUAD_VIEW_METER_COUNT: 1, // 0..3 + QUAD_VIEW_METER_1: 'ppm-din', + QUAD_VIEW_METER_1_POS: 'right', // 'left' | 'center' | 'right' + QUAD_VIEW_METER_2: 'ppm-din', + QUAD_VIEW_METER_2_POS: 'right', + QUAD_VIEW_METER_3: 'lufs', + QUAD_VIEW_METER_3_POS: 'right', + SPLIT_VIEW_METER_COUNT: 1, // 0..3 + SPLIT_VIEW_METER_1: 'ppm-din', + SPLIT_VIEW_METER_1_POS: 'center', // 'left' | 'center' | 'right' + SPLIT_VIEW_METER_2: 'ppm-din', + SPLIT_VIEW_METER_2_POS: 'right', + SPLIT_VIEW_METER_3: 'lufs', + SPLIT_VIEW_METER_3_POS: 'right', + + // Hold and decay times for VU meters. + // VU_HOLD_MS: how long (in ms) to hold the VU peak before it starts decaying. + // VU_DECAY_DB_PER_S: decay speed in dB per second for VU peaks. + VU_HOLD_MS: 1000, + VU_DECAY_DB_PER_S: 11.1, + TP_HOLD_MS: 1000, + TP_DECAY_DB_PER_S: 20, + PPM_DIN_TRIM_DB: 0, + PPM_DIN_MODE: 'al_minus6', + SOFTWARE_PRESET: 'default', +}; +const CONFIG_DEFAULTS = JSON.parse(JSON.stringify(CONFIG)); +const SOFTWARE_PRESET_IDS = Object.freeze(['default', 'claus', 'michael']); +const LAYOUT_PRESET_IDS = Object.freeze(['1', '2', '3', '4']); +const LAYOUT_PRESET_KEYS = Object.freeze([ + 'UI_LAST_STYLE', + 'UI_LAST_NON_OPTION_STYLE', + 'SPLIT_VIEW_LEFT', + 'SPLIT_VIEW_RIGHT', + 'SPLIT_VIEW_TAP_ACTION', + 'SPLIT_VIEW_METER_COUNT', + 'SPLIT_VIEW_METER_1', + 'SPLIT_VIEW_METER_1_POS', + 'SPLIT_VIEW_METER_2', + 'SPLIT_VIEW_METER_2_POS', + 'SPLIT_VIEW_METER_3', + 'SPLIT_VIEW_METER_3_POS', + 'QUAD_VIEW_TL', + 'QUAD_VIEW_TR', + 'QUAD_VIEW_BL', + 'QUAD_VIEW_BR', + 'QUAD_VIEW_TAP_ACTION', + 'QUAD_VIEW_METER_COUNT', + 'QUAD_VIEW_METER_1', + 'QUAD_VIEW_METER_1_POS', + 'QUAD_VIEW_METER_2', + 'QUAD_VIEW_METER_2_POS', + 'QUAD_VIEW_METER_3', + 'QUAD_VIEW_METER_3_POS', +]); + +function cloneDefaultConfigForPreset(id) { + const snapshot = JSON.parse(JSON.stringify(CONFIG_DEFAULTS)); + snapshot.SOFTWARE_PRESET = id; + return snapshot; +} + +const SOFTWARE_PRESETS = Object.freeze({ + default: Object.freeze(cloneDefaultConfigForPreset('default')), + claus: Object.freeze(cloneDefaultConfigForPreset('claus')), + michael: Object.freeze(cloneDefaultConfigForPreset('michael')), +}); + +function resolvePhoenixApiUrl(pathname) { + const base = normalizePhoenixBaseUrlForCurrentClient(CONFIG.PHOENIX_BASE_URL || DEFAULT_PHOENIX_BASE_URL || ''); + return `${base}${pathname}`; +} + +function resolveCurrentClientPhoenixApiUrl(pathname) { + const host = String(globalThis?.location?.hostname || '').trim(); + if (!host) return resolvePhoenixApiUrl(pathname); + return `http://${host}:8789${pathname}`; +} + +async function fetchJsonWithPhoenixFallback(pathname, init = {}) { + const primaryUrl = resolvePhoenixApiUrl(pathname); + const fallbackUrl = resolveCurrentClientPhoenixApiUrl(pathname); + let lastError = null; + + const run = async (url) => { + const res = await fetch(url, { cache: 'no-store', ...init }); + if (!res.ok) throw new Error(`request failed (${res.status})`); + return await res.json(); + }; + + try { + return await run(primaryUrl); + } catch (err) { + lastError = err; + if (fallbackUrl !== primaryUrl) { + try { + return await run(fallbackUrl); + } catch (err2) { + lastError = err2; + } + } + } + throw lastError || new Error('request failed'); +} + +function buildSoftwarePresetSnapshot(extra = null) { + const snapshot = JSON.parse(JSON.stringify(CONFIG)); + delete snapshot.Y_TICKS; + delete snapshot.INPUT_OFFSET_GAIN_L; + delete snapshot.INPUT_OFFSET_GAIN_R; + if (extra && typeof extra === 'object' && !Array.isArray(extra)) { + snapshot.__PHOENIX_UI_STATE = JSON.parse(JSON.stringify(extra)); + } else { + delete snapshot.__PHOENIX_UI_STATE; + } + return snapshot; +} + +function applyConfigSnapshot(snapshot, opts = {}) { + const presetId = normalizeSoftwarePresetId(opts.presetId || snapshot?.SOFTWARE_PRESET || 'default'); + resetConfigToDefaults(); + let uiState = null; + if (snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot)) { + for (const k of Object.keys(snapshot)) { + if (k === '__PHOENIX_UI_STATE') { + const extra = snapshot[k]; + if (extra && typeof extra === 'object' && !Array.isArray(extra)) { + uiState = JSON.parse(JSON.stringify(extra)); + } + continue; + } + if (k in CONFIG) CONFIG[k] = snapshot[k]; + } + } + CONFIG.PHOENIX_BASE_URL = normalizePhoenixBaseUrlForCurrentClient(CONFIG.PHOENIX_BASE_URL || CONFIG_DEFAULTS.PHOENIX_BASE_URL || DEFAULT_PHOENIX_BASE_URL); + CONFIG.SOFTWARE_PRESET = presetId; + applyAlignmentProfile((CONFIG.ALIGNMENT_MODE === 'ard' || CONFIG.PPM_DIN_MODE === 'al_minus9') ? 'ard' : 'ebu', { persist: false, skipDinStorage: true }); + updateInputOffsetGain({ L: CONFIG.INPUT_OFFSET_DB_L, R: CONFIG.INPUT_OFFSET_DB_R }); + if (opts.persist) saveConfig(); + return { presetId, uiState }; +} + +async function fetchSoftwarePresetStore() { + const payload = await fetchJsonWithPhoenixFallback(`${SOFTWARE_PRESET_ENDPOINT}?_=${Date.now()}`); + const presets = payload?.presets; + return (presets && typeof presets === 'object' && !Array.isArray(presets)) ? presets : {}; +} + +function buildLayoutPresetSnapshot(extra = null) { + const snapshot = {}; + for (const key of LAYOUT_PRESET_KEYS) { + snapshot[key] = JSON.parse(JSON.stringify(CONFIG[key])); + } + if (extra && typeof extra === 'object' && !Array.isArray(extra)) { + snapshot.__PHOENIX_UI_STATE = JSON.parse(JSON.stringify(extra)); + } + return snapshot; +} + +function applyLayoutSnapshot(snapshot, opts = {}) { + const layoutId = normalizeLayoutPresetId(opts.layoutId || snapshot?.LAYOUT_PRESET || ''); + let uiState = null; + if (snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot)) { + for (const key of Object.keys(snapshot)) { + if (key === '__PHOENIX_UI_STATE') { + const extra = snapshot[key]; + if (extra && typeof extra === 'object' && !Array.isArray(extra)) { + uiState = JSON.parse(JSON.stringify(extra)); + } + continue; + } + if (LAYOUT_PRESET_KEYS.includes(key) && key in CONFIG) { + CONFIG[key] = snapshot[key]; + } + } + } + if (opts.persist) saveConfig(); + return { layoutId, uiState, snapshot }; +} + +async function fetchLayoutPresetStore() { + const payload = await fetchJsonWithPhoenixFallback(`${LAYOUT_PRESET_ENDPOINT}?_=${Date.now()}`); + const layouts = payload?.layouts; + return (layouts && typeof layouts === 'object' && !Array.isArray(layouts)) ? layouts : {}; +} + +async function loadSoftwarePreset(presetId = 'default') { + const id = normalizeSoftwarePresetId(presetId); + if (id === 'default') { + applySoftwarePreset('default', { persist: true }); + return { presetId: 'default', source: 'builtin', snapshot: JSON.parse(JSON.stringify(SOFTWARE_PRESETS.default)), uiState: null }; + } + const presets = await fetchSoftwarePresetStore(); + const snapshot = presets[id]; + if (snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot)) { + const applied = applyConfigSnapshot(snapshot, { presetId: id, persist: true }); + return { presetId: id, source: 'stored', snapshot: JSON.parse(JSON.stringify(snapshot)), uiState: applied?.uiState || null }; + } + applySoftwarePreset(id, { persist: true }); + return { presetId: id, source: 'builtin', snapshot: JSON.parse(JSON.stringify(SOFTWARE_PRESETS[id] || SOFTWARE_PRESETS.default)), uiState: null }; +} + +async function saveSoftwarePreset(presetId = 'default', snapshot = null) { + const id = normalizeSoftwarePresetId(presetId); + if (id === 'default') return 'default'; + CONFIG.SOFTWARE_PRESET = id; + saveConfig(); + const configSnapshot = (snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot)) + ? JSON.parse(JSON.stringify(snapshot)) + : buildSoftwarePresetSnapshot(); + configSnapshot.SOFTWARE_PRESET = id; + const body = JSON.stringify({ preset_id: id, config: configSnapshot }); + const primaryUrl = resolvePhoenixApiUrl(SOFTWARE_PRESET_ENDPOINT); + const fallbackUrl = resolveCurrentClientPhoenixApiUrl(SOFTWARE_PRESET_ENDPOINT); + let res = null; + let lastError = null; + try { + res = await fetch(primaryUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + cache: 'no-store', + body, + }); + if (!res.ok) throw new Error(`preset save failed (${res.status})`); + } catch (err) { + lastError = err; + if (fallbackUrl !== primaryUrl) { + res = await fetch(fallbackUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + cache: 'no-store', + body, + }); + if (!res.ok) throw new Error(`preset save failed (${res.status})`); + } else { + throw lastError; + } + } + return id; +} + +async function loadLayoutPreset(layoutId = '1') { + const id = normalizeLayoutPresetId(layoutId); + if (!id) return null; + const layouts = await fetchLayoutPresetStore(); + const snapshot = layouts[id]; + if (snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot)) { + const applied = applyLayoutSnapshot(snapshot, { layoutId: id, persist: true }); + return { layoutId: id, source: 'stored', snapshot: JSON.parse(JSON.stringify(snapshot)), uiState: applied?.uiState || null }; + } + return null; +} + +async function saveLayoutPreset(layoutId = '1', extra = null) { + const id = normalizeLayoutPresetId(layoutId); + if (!id) throw new Error('invalid layout id'); + const configSnapshot = buildLayoutPresetSnapshot(extra); + const body = JSON.stringify({ preset_id: id, config: configSnapshot }); + const primaryUrl = resolvePhoenixApiUrl(LAYOUT_PRESET_ENDPOINT); + const fallbackUrl = resolveCurrentClientPhoenixApiUrl(LAYOUT_PRESET_ENDPOINT); + let res = null; + try { + res = await fetch(primaryUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + cache: 'no-store', + body, + }); + if (!res.ok) throw new Error(`layout save failed (${res.status})`); + } catch (err) { + if (fallbackUrl !== primaryUrl) { + res = await fetch(fallbackUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + cache: 'no-store', + body, + }); + if (!res.ok) throw new Error(`layout save failed (${res.status})`); + } else { + throw err; + } + } + return id; +} + +async function exportSoftwarePreset(presetId = 'default') { + const id = normalizeSoftwarePresetId(presetId); + let snapshot = null; + if (id === 'default') { + snapshot = JSON.parse(JSON.stringify(SOFTWARE_PRESETS.default)); + } else { + const presets = await fetchSoftwarePresetStore(); + snapshot = (presets[id] && typeof presets[id] === 'object' && !Array.isArray(presets[id])) + ? JSON.parse(JSON.stringify(presets[id])) + : buildSoftwarePresetSnapshot(); + } + snapshot.SOFTWARE_PRESET = id; + return { presetId: id, config: snapshot }; +} + +async function importSoftwarePreset(presetId = 'default', payload = null) { + const id = normalizeSoftwarePresetId(presetId); + if (id === 'default') return 'default'; + let snapshot = payload; + if (snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot) && snapshot.config && typeof snapshot.config === 'object' && !Array.isArray(snapshot.config)) { + snapshot = snapshot.config; + } + if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) { + throw new Error('invalid preset import payload'); + } + const normalized = JSON.parse(JSON.stringify(snapshot)); + normalized.SOFTWARE_PRESET = id; + await saveSoftwarePreset(id, normalized); + return loadSoftwarePreset(id); +} +const PHOENIX_GLOBAL_OPTION_KEYS = Object.freeze([ + 'FFT_SIZE', + 'RTA_BPO_MODE', + 'INPUT_OFFSET_DB_L', + 'INPUT_OFFSET_DB_R', + 'MONO_INPUT', + 'LR_FRACTIONAL_DELAY_ENABLED', + 'LR_FRACTIONAL_DELAY_SAMPLES', + 'PPM_DIN_ATTACK_MS', + 'PPM_DIN_DECAY_DB_PER_S', + 'PPM_DIN_FAST_ATTACK', + 'PPM_EBU_ATTACK_MS', + 'PPM_EBU_DECAY_DB_PER_S', + 'LUFS_I_WINDOW_MIN', + 'LUFS_I_NORM_ENABLED', + 'PPM_DIN_LOUDNESS_BOXES', + 'PPM_DIN_LOUDNESS_OFFSET_DB', + 'XY_POINTS', + 'GONIO_DISPLAY_GAIN_DB', + 'RECORD_OUTPUT_FORMAT', + 'RECORD_MP3_BITRATE_KBPS', + 'RECORD_PI_TARGET', + 'RECORD_AUTO_SPLIT_GAP_SEC', + 'RECORD_AUTO_THRESHOLD_DBFS', + 'SCREENSAVER_ENABLED', + 'SCREENSAVER_IDLE_MIN', + 'SCREENSAVER_ACTIVITY_DB', + 'SCREENSAVER_LED_GLOW', + 'SCREENSAVER_LED_COLOR', + 'CLOCK_LED_COLOR', + 'HEADER_TEXT_COLOR', + 'RTA_BAR_BASE_COLOR', + 'PEAK_HISTORY_SCROLL_MODE', + 'PEAK_HISTORY_FILL_ENABLED', + 'PEAK_HISTORY_FILL_INVERT', + 'PEAK_HISTORY_SLOT', + 'PHASE_DISPLAY_GAIN_DB', + 'PHASE_AGC_ENABLED', + 'PHASE_TRAIL_ENABLED', + 'PHASE_AMPLITUDE_MODE', + 'CLASSIC_NEEDLES_SLOT', + 'WAVEFORM_COLOR_LEFT', + 'WAVEFORM_COLOR_RIGHT', + 'WAVEFORM_COLOR_DIFF', + 'VU_COLOR_NORMAL', + 'VU_COLOR_WARN', + 'PPM_DIN_COLOR_NORMAL', + 'PPM_DIN_COLOR_WARN', + 'PPM_EBU_COLOR_NORMAL', + 'PPM_EBU_COLOR_WARN', + 'TP_COLOR_NORMAL', + 'TP_COLOR_WARN', + 'RMS_COLOR_NORMAL', + 'RMS_COLOR_WARN', + 'LUFS_COLOR_I', + 'LUFS_COLOR_M', + 'LUFS_COLOR_S', + 'LUFS_SCALE_LABEL_COLOR', +]); + +const ALIGNMENT_PROFILES = { + ebu: { + id: 'ebu', + dinMode: 'al_minus6', + digitalRefRms: -18, + }, + ard: { + id: 'ard', + dinMode: 'al_minus9', + digitalRefRms: -15, + }, +}; + +const BROADCAST_STANDARDS = { + 'EBU R128': { + VU_DBFS_REF: -18.0, PPM_EBU_TOP: +12, PPM_EBU_RED_START: +9, TP_RED_START: -1.0, + LUFS_RED_START: -1.0, LUFS_YELLOW_START: -2.0, LUFS_GREEN_START: -5.0, + VU_RED_START: 0, + }, + 'ATSC A/85': { + VU_DBFS_REF: -20.0, PPM_EBU_TOP: +12, PPM_EBU_RED_START: +9, TP_RED_START: -2.0, + LUFS_RED_START: -2.0, LUFS_YELLOW_START: -4.0, LUFS_GREEN_START: -8.0, + VU_RED_START: +2, + }, + 'BBC': { + VU_DBFS_REF: -18.0, PPM_EBU_TOP: +12, PPM_EBU_RED_START: +9, TP_RED_START: -1.0, + LUFS_RED_START: -1.0, LUFS_YELLOW_START: -3.0, LUFS_GREEN_START: -6.0, + VU_RED_START: +1, + }, + 'DIN': { + VU_DBFS_REF: -15.0, PPM_EBU_TOP: +12, PPM_EBU_RED_START: +9, TP_RED_START: 0.0, + LUFS_RED_START: 0.0, LUFS_YELLOW_START: -2.0, LUFS_GREEN_START: -4.0, + VU_RED_START: +3, + } + +}; + +const STORAGE_KEY = 'analyzer_config'; +const VU_OFFSET_ALIGNMENT_DB = 1.92; +const VU_OFFSET_ALIGNMENT_MIGRATION_KEY = 'migrate_vu_offset_alignment_plus2_v1'; +const INPUT_OFFSET_DB_MIN = -60; +const INPUT_OFFSET_DB_MAX = 20; +const LR_DELAY_ZERO_MIGRATION_KEY = 'migrate_lr_delay_zero_to_0_05_v1'; +let configLoadedOnce = false; + +function normalizeSoftwarePresetId(value) { + const id = String(value || '').trim().toLowerCase(); + return SOFTWARE_PRESET_IDS.includes(id) ? id : 'default'; +} + +function normalizeLayoutPresetId(value) { + const id = String(value || '').trim().toLowerCase(); + if (LAYOUT_PRESET_IDS.includes(id)) return id; + if (id === 'layout1' || id === 'slot1' || id === 'a') return '1'; + if (id === 'layout2' || id === 'slot2' || id === 'b') return '2'; + if (id === 'layout3' || id === 'slot3' || id === 'c') return '3'; + if (id === 'layout4' || id === 'slot4' || id === 'd') return '4'; + return ''; +} + +function applySoftwarePreset(presetId = 'default', opts = {}) { + const id = normalizeSoftwarePresetId(presetId); + const snapshot = SOFTWARE_PRESETS[id] || SOFTWARE_PRESETS.default; + Object.assign(CONFIG, JSON.parse(JSON.stringify(snapshot))); + CONFIG.PHOENIX_BASE_URL = normalizePhoenixBaseUrlForCurrentClient(CONFIG.PHOENIX_BASE_URL || CONFIG_DEFAULTS.PHOENIX_BASE_URL || DEFAULT_PHOENIX_BASE_URL); + CONFIG.SOFTWARE_PRESET = id; + applyAlignmentProfile((CONFIG.ALIGNMENT_MODE === 'ard' || CONFIG.PPM_DIN_MODE === 'al_minus9') ? 'ard' : 'ebu', { persist: false, skipDinStorage: true }); + updateInputOffsetGain({ L: CONFIG.INPUT_OFFSET_DB_L, R: CONFIG.INPUT_OFFSET_DB_R }); + if (opts.persist) saveConfig(); + return id; +} + +function updateVuReference() { + const digitalRef = Number.isFinite(CONFIG.DIGITAL_REF_DBFS_RMS) ? CONFIG.DIGITAL_REF_DBFS_RMS : -18; + const offset = CONFIG.VU_REF_IS_PLUS4 ? 4 : 0; + CONFIG.VU_DBFS_REF = digitalRef + offset; +} + +function updateInputOffsetGain(dbValue = { L: CONFIG.INPUT_OFFSET_DB_L, R: CONFIG.INPUT_OFFSET_DB_R }) { + const clampDb = (raw, fallback) => { + let val = Number(raw); + if (!Number.isFinite(val)) val = Number(fallback); + if (!Number.isFinite(val)) val = -5; + return Math.max(INPUT_OFFSET_DB_MIN, Math.min(INPUT_OFFSET_DB_MAX, val)); + }; + + let dbL, dbR; + if (dbValue && typeof dbValue === 'object') { + dbL = clampDb(dbValue.L ?? dbValue.left ?? CONFIG.INPUT_OFFSET_DB_L, CONFIG.INPUT_OFFSET_DB_L); + dbR = clampDb(dbValue.R ?? dbValue.right ?? CONFIG.INPUT_OFFSET_DB_R, CONFIG.INPUT_OFFSET_DB_R); + } else { + const both = clampDb(dbValue, CONFIG.INPUT_OFFSET_DB_L); + dbL = both; + dbR = both; + } + + CONFIG.INPUT_OFFSET_DB_L = dbL; + CONFIG.INPUT_OFFSET_DB_R = dbR; + CONFIG.INPUT_OFFSET_GAIN_L = Math.pow(10, dbL / 20); + CONFIG.INPUT_OFFSET_GAIN_R = Math.pow(10, dbR / 20); + return { L: CONFIG.INPUT_OFFSET_GAIN_L, R: CONFIG.INPUT_OFFSET_GAIN_R }; +} + +function applyAlignmentProfile(mode = 'ebu', opts = {}) { + const key = (mode === 'ard' || mode === 'al_minus9') ? 'ard' + : (mode === 'ebu' || mode === 'al_minus6') ? 'ebu' + : 'ebu'; + const profile = ALIGNMENT_PROFILES[key] || ALIGNMENT_PROFILES.ebu; + const digitalRef = profile.digitalRefRms; + + CONFIG.ALIGNMENT_MODE = profile.id; + CONFIG.PPM_DIN_MODE = profile.dinMode; + CONFIG.DIGITAL_REF_DBFS_RMS = digitalRef; + CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU = digitalRef + 3; + CONFIG.RMS_REF_DBFS_FOR_REF_DBU = digitalRef; + CONFIG.RMS_MODE = 'dbfs'; + CONFIG.RMS_REF_DBU = 0; + updateVuReference(); + + if (!opts.skipDinStorage && typeof localStorage !== 'undefined') { + try { localStorage.setItem('ppm_din_mode', CONFIG.PPM_DIN_MODE); } + catch (_) {} + } + if (opts.persist) saveConfig(); + return profile.id; +} + +function loadConfig(opts = {}) { + const force = !!opts.force; + if (configLoadedOnce && !force) return; + configLoadedOnce = true; + let saved = null; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return; + saved = JSON.parse(raw); + for (const k of Object.keys(saved)) if (k in CONFIG) CONFIG[k] = saved[k]; + if (typeof saved.PPM_TOP === 'number' && !('PPM_EBU_TOP' in saved)) CONFIG.PPM_EBU_TOP = saved.PPM_TOP; + if (typeof saved.PPM_BOTTOM === 'number' && !('PPM_EBU_BOTTOM' in saved)) CONFIG.PPM_EBU_BOTTOM = saved.PPM_BOTTOM; + if (typeof saved.PPM_RED_START === 'number' && !('PPM_EBU_RED_START' in saved)) CONFIG.PPM_EBU_RED_START = saved.PPM_RED_START; + if (typeof saved.PPM_DIN_TOP === 'number') CONFIG.PPM_DIN_TOP = saved.PPM_DIN_TOP; + if (typeof saved.PPM_DIN_BOTTOM === 'number') CONFIG.PPM_DIN_BOTTOM = saved.PPM_DIN_BOTTOM; + if (typeof saved.PPM_DIN_RED_START === 'number') CONFIG.PPM_DIN_RED_START = saved.PPM_DIN_RED_START; + if (typeof saved.PPM_DIN_ATTACK_MS === 'number') CONFIG.PPM_DIN_ATTACK_MS = saved.PPM_DIN_ATTACK_MS; + if (typeof saved.PPM_DIN_DECAY_DB_PER_S === 'number') CONFIG.PPM_DIN_DECAY_DB_PER_S = saved.PPM_DIN_DECAY_DB_PER_S; + if (typeof saved.PPM_DIN_HOLD_MS === 'number') CONFIG.PPM_DIN_HOLD_MS = saved.PPM_DIN_HOLD_MS; + if (CONFIG.RTA_BAR_LAYOUT === 'classic') CONFIG.RTA_BAR_LAYOUT = 'rtw'; + if (!Number.isFinite(CONFIG.DBFS_TOP)) CONFIG.DBFS_TOP = CONFIG_DEFAULTS.DBFS_TOP; + if (!Number.isFinite(CONFIG.DBFS_BOTTOM)) CONFIG.DBFS_BOTTOM = CONFIG_DEFAULTS.DBFS_BOTTOM; + // Clamp to sensible bounds while keeping the user-selected display range. + CONFIG.DBFS_TOP = Math.max(9, Math.min(24, CONFIG.DBFS_TOP)); + CONFIG.DBFS_BOTTOM = Math.max(-160, Math.min(CONFIG.DBFS_TOP - 1, CONFIG.DBFS_BOTTOM)); + + if (!Number.isFinite(CONFIG.SPECTRO_GAMMA)) CONFIG.SPECTRO_GAMMA = CONFIG_DEFAULTS.SPECTRO_GAMMA; + CONFIG.SPECTRO_GAMMA = Math.max(0.3, Math.min(1.2, CONFIG.SPECTRO_GAMMA)); + if (!Number.isFinite(CONFIG.WAVEFORM_WINDOW_SEC)) CONFIG.WAVEFORM_WINDOW_SEC = CONFIG_DEFAULTS.WAVEFORM_WINDOW_SEC; + CONFIG.WAVEFORM_WINDOW_SEC = Math.max(0.05, Math.min(15, CONFIG.WAVEFORM_WINDOW_SEC)); + CONFIG.WAVEFORM_MODE = (CONFIG.WAVEFORM_MODE === 'overlay' || CONFIG.WAVEFORM_MODE === 'diff' || CONFIG.WAVEFORM_MODE === 'stacked') + ? CONFIG.WAVEFORM_MODE + : CONFIG_DEFAULTS.WAVEFORM_MODE; + + // UI/layout: keep user value, clamp to avoid NaN/layout issues. + if (!Number.isFinite(CONFIG.AXIS_GUTTER_LEFT)) CONFIG.AXIS_GUTTER_LEFT = CONFIG_DEFAULTS.AXIS_GUTTER_LEFT; + CONFIG.AXIS_GUTTER_LEFT = Math.max(8, Math.min(60, CONFIG.AXIS_GUTTER_LEFT)); + + if (!Number.isFinite(CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB)) CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB = 0; + CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB = Math.max(-7, Math.min(7, CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB)); + try { + const needsVuMigration = !localStorage.getItem(VU_OFFSET_ALIGNMENT_MIGRATION_KEY); + const savedVuOffset = Number(saved?.VU_OFFSET_DB); + const oldDefaultOffset = Number(CONFIG_DEFAULTS?.VU_OFFSET_DB); + const looksLikeOldDefault = !Number.isFinite(savedVuOffset) + || Math.abs(savedVuOffset - oldDefaultOffset) < 0.02 + || Math.abs(savedVuOffset - (-0.08)) < 0.02 + || Math.abs(savedVuOffset - (-0.05)) < 0.02; + if (needsVuMigration && looksLikeOldDefault) { + CONFIG.VU_OFFSET_DB = VU_OFFSET_ALIGNMENT_DB; + } + if (needsVuMigration) localStorage.setItem(VU_OFFSET_ALIGNMENT_MIGRATION_KEY, '1'); + } catch (_) {} + + if (!Number.isFinite(CONFIG.LUFS_I_WINDOW_MIN)) CONFIG.LUFS_I_WINDOW_MIN = CONFIG_DEFAULTS.LUFS_I_WINDOW_MIN ?? 4; + CONFIG.LUFS_I_WINDOW_MIN = Math.max(1, Math.min(10, Math.round(CONFIG.LUFS_I_WINDOW_MIN))); + + CONFIG.LUFS_I_NORM_ENABLED = !!CONFIG.LUFS_I_NORM_ENABLED; + CONFIG.STOPWATCH_DISPLAY_STYLE = (CONFIG.STOPWATCH_DISPLAY_STYLE === 'seven') ? 'seven' : 'mono'; + if (!Number.isFinite(CONFIG.STOPWATCH_AUTO_THRESHOLD_DBFS)) CONFIG.STOPWATCH_AUTO_THRESHOLD_DBFS = CONFIG_DEFAULTS.STOPWATCH_AUTO_THRESHOLD_DBFS; + CONFIG.STOPWATCH_AUTO_THRESHOLD_DBFS = Math.max(-120, Math.min(20, Number(CONFIG.STOPWATCH_AUTO_THRESHOLD_DBFS))); + try { + const needsMigration = !localStorage.getItem(LR_DELAY_ZERO_MIGRATION_KEY); + const savedDelay = Number(saved?.LR_FRACTIONAL_DELAY_SAMPLES); + if (needsMigration && Number.isFinite(savedDelay) && savedDelay === 0) { + CONFIG.LR_FRACTIONAL_DELAY_SAMPLES = 0.996; + localStorage.setItem(LR_DELAY_ZERO_MIGRATION_KEY, '1'); + } + } catch (_) {} + if (!Number.isFinite(CONFIG.LR_FRACTIONAL_DELAY_SAMPLES)) CONFIG.LR_FRACTIONAL_DELAY_SAMPLES = CONFIG_DEFAULTS.LR_FRACTIONAL_DELAY_SAMPLES; + CONFIG.LR_FRACTIONAL_DELAY_SAMPLES = Math.max(-1.5, Math.min(1.5, Number(CONFIG.LR_FRACTIONAL_DELAY_SAMPLES))); + CONFIG.PHOENIX_BASE_URL = normalizePhoenixBaseUrlForCurrentClient(CONFIG.PHOENIX_BASE_URL || CONFIG_DEFAULTS.PHOENIX_BASE_URL || DEFAULT_PHOENIX_BASE_URL); + CONFIG.PPM_DIN_FAST_ATTACK = !!CONFIG.PPM_DIN_FAST_ATTACK; + CONFIG.HIFI_PEAK_ALIGNMENT = (CONFIG.HIFI_PEAK_ALIGNMENT === 'ppm_din_zero' || CONFIG.HIFI_PEAK_ALIGNMENT === 'dbfs_zero') + ? 'ppm_din_zero' + : 'ppm_din_minus5'; + if (!Number.isFinite(CONFIG.RECORD_AUTO_THRESHOLD_DBFS)) CONFIG.RECORD_AUTO_THRESHOLD_DBFS = CONFIG_DEFAULTS.RECORD_AUTO_THRESHOLD_DBFS; + CONFIG.RECORD_AUTO_THRESHOLD_DBFS = Math.max(-120, Math.min(20, Number(CONFIG.RECORD_AUTO_THRESHOLD_DBFS))); + CONFIG.SOFTWARE_PRESET = normalizeSoftwarePresetId(CONFIG.SOFTWARE_PRESET); + if (typeof CONFIG.UI_LAST_STYLE !== 'string') CONFIG.UI_LAST_STYLE = CONFIG_DEFAULTS.UI_LAST_STYLE; + if (typeof CONFIG.UI_LAST_NON_OPTION_STYLE !== 'string') CONFIG.UI_LAST_NON_OPTION_STYLE = CONFIG_DEFAULTS.UI_LAST_NON_OPTION_STYLE; + + // Split-View: normalize ids/counts to avoid invalid config values. + const normSplitPlot = (v, fallback) => { + return ( + v === 'phase-wheel' || + v === 'realtime' || + v === 'goniometer-rtw' || + v === 'peak-history' || + v === 'classic-needles' || + v === 'panel' || + v === 'clock' || + v === 'none' + ) ? v : fallback; + }; + const normSplitCount3 = (v) => { + const n = Number(v); + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.min(3, n | 0)); + }; + const normSplitMeter = (v, fallback) => { + const id = String(v || ''); + const ok = (id === 'none' || id === 'vu' || id === 'ppm-ebu' || id === 'ppm-din' || id === 'tp' || id === 'hifi-peak' || id === 'rms' || id === 'lufs' || id === 'stopwatch'); + return ok ? id : fallback; + }; + const normSplitPos = (v, fallback) => { + const id = String(v || ''); + return (id === 'left' || id === 'center' || id === 'right') ? id : fallback; + }; + CONFIG.SPLIT_VIEW_LEFT = normSplitPlot(CONFIG.SPLIT_VIEW_LEFT, 'phase-wheel'); + CONFIG.SPLIT_VIEW_RIGHT = normSplitPlot(CONFIG.SPLIT_VIEW_RIGHT, 'realtime'); + CONFIG.SPLIT_VIEW_TAP_ACTION = (CONFIG.SPLIT_VIEW_TAP_ACTION === 'popup') ? 'popup' : 'switch'; + CONFIG.QUAD_VIEW_TL = normSplitPlot(CONFIG.QUAD_VIEW_TL, 'phase-wheel'); + CONFIG.QUAD_VIEW_TR = normSplitPlot(CONFIG.QUAD_VIEW_TR, 'realtime'); + CONFIG.QUAD_VIEW_BL = normSplitPlot(CONFIG.QUAD_VIEW_BL, 'goniometer-rtw'); + CONFIG.QUAD_VIEW_BR = normSplitPlot(CONFIG.QUAD_VIEW_BR, 'none'); + CONFIG.QUAD_VIEW_TAP_ACTION = (CONFIG.QUAD_VIEW_TAP_ACTION === 'popup') ? 'popup' : 'switch'; + CONFIG.QUAD_VIEW_METER_COUNT = normSplitCount3(CONFIG.QUAD_VIEW_METER_COUNT); + CONFIG.QUAD_VIEW_METER_1 = normSplitMeter(CONFIG.QUAD_VIEW_METER_1, 'vu'); + CONFIG.QUAD_VIEW_METER_2 = normSplitMeter(CONFIG.QUAD_VIEW_METER_2, 'ppm-din'); + CONFIG.QUAD_VIEW_METER_3 = normSplitMeter(CONFIG.QUAD_VIEW_METER_3, 'lufs'); + CONFIG.QUAD_VIEW_METER_1_POS = normSplitPos(CONFIG.QUAD_VIEW_METER_1_POS, 'right'); + CONFIG.QUAD_VIEW_METER_2_POS = normSplitPos(CONFIG.QUAD_VIEW_METER_2_POS, 'right'); + CONFIG.QUAD_VIEW_METER_3_POS = normSplitPos(CONFIG.QUAD_VIEW_METER_3_POS, 'right'); + CONFIG.SPLIT_VIEW_METER_COUNT = normSplitCount3(CONFIG.SPLIT_VIEW_METER_COUNT); + CONFIG.SPLIT_VIEW_METER_1 = normSplitMeter(CONFIG.SPLIT_VIEW_METER_1, 'vu'); + CONFIG.SPLIT_VIEW_METER_2 = normSplitMeter(CONFIG.SPLIT_VIEW_METER_2, 'ppm-din'); + CONFIG.SPLIT_VIEW_METER_3 = normSplitMeter(CONFIG.SPLIT_VIEW_METER_3, 'lufs'); + CONFIG.SPLIT_VIEW_METER_1_POS = normSplitPos(CONFIG.SPLIT_VIEW_METER_1_POS, 'right'); + CONFIG.SPLIT_VIEW_METER_2_POS = normSplitPos(CONFIG.SPLIT_VIEW_METER_2_POS, 'right'); + CONFIG.SPLIT_VIEW_METER_3_POS = normSplitPos(CONFIG.SPLIT_VIEW_METER_3_POS, 'right'); + + // Migration (older Split-View config): per-side meters → global 3-slot model. + const hasNew = saved && ('SPLIT_VIEW_METER_COUNT' in saved); + const hasOld = saved && ('SPLIT_VIEW_LEFT_METER_COUNT' in saved || 'SPLIT_VIEW_RIGHT_METER_COUNT' in saved); + if (!hasNew && hasOld) { + const oldClamp2 = (v) => { + const n = Number(v); + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.min(2, n | 0)); + }; + const lCount = oldClamp2(saved?.SPLIT_VIEW_LEFT_METER_COUNT); + const rCount = oldClamp2(saved?.SPLIT_VIEW_RIGHT_METER_COUNT); + const slots = []; + if (lCount >= 1) slots.push({ id: normSplitMeter(saved?.SPLIT_VIEW_LEFT_METER_1, 'vu'), pos: 'left' }); + if (lCount >= 2) slots.push({ id: normSplitMeter(saved?.SPLIT_VIEW_LEFT_METER_2, 'ppm-din'), pos: 'left' }); + if (rCount >= 1) slots.push({ id: normSplitMeter(saved?.SPLIT_VIEW_RIGHT_METER_1, 'vu'), pos: 'right' }); + if (rCount >= 2) slots.push({ id: normSplitMeter(saved?.SPLIT_VIEW_RIGHT_METER_2, 'ppm-din'), pos: 'right' }); + CONFIG.SPLIT_VIEW_METER_COUNT = Math.max(0, Math.min(3, slots.length)); + if (slots[0]) { CONFIG.SPLIT_VIEW_METER_1 = slots[0].id; CONFIG.SPLIT_VIEW_METER_1_POS = slots[0].pos; } + if (slots[1]) { CONFIG.SPLIT_VIEW_METER_2 = slots[1].id; CONFIG.SPLIT_VIEW_METER_2_POS = slots[1].pos; } + if (slots[2]) { CONFIG.SPLIT_VIEW_METER_3 = slots[2].id; CONFIG.SPLIT_VIEW_METER_3_POS = slots[2].pos; } + } + + // Derived state; keep it deterministic. + CONFIG.Y_TICKS = []; + if (!Number.isFinite(CONFIG.GONIO_MANUAL_GAIN_DB)) { + const fallback = Number.isFinite(CONFIG.GONIO_DISPLAY_GAIN_DB) ? CONFIG.GONIO_DISPLAY_GAIN_DB : 0; + CONFIG.GONIO_MANUAL_GAIN_DB = Math.max(-35, Math.min(35, Math.round(fallback / 5) * 5)); + } + } catch (e) { + console.warn('Config load error:', e); + } + const loadedMode = (CONFIG.ALIGNMENT_MODE === 'ard' || CONFIG.PPM_DIN_MODE === 'al_minus9') ? 'ard' : 'ebu'; + applyAlignmentProfile(loadedMode, { persist: false, skipDinStorage: true }); + + // Migration: older configs only stored INPUT_OFFSET_DB. + const hasPerChannelOffset = !!(saved && ('INPUT_OFFSET_DB_L' in saved || 'INPUT_OFFSET_DB_R' in saved)); + if (!hasPerChannelOffset && Number.isFinite(saved?.INPUT_OFFSET_DB)) { + CONFIG.INPUT_OFFSET_DB_L = saved.INPUT_OFFSET_DB; + CONFIG.INPUT_OFFSET_DB_R = saved.INPUT_OFFSET_DB; + } + if (!(saved && ('RECORD_PI_TARGET' in saved))) { + CONFIG.RECORD_PI_TARGET = (CONFIG.RECORD_TARGET === 'volumio') ? 'volumio' : 'analyzer'; + } + updateInputOffsetGain({ + L: CONFIG.INPUT_OFFSET_DB_L, + R: CONFIG.INPUT_OFFSET_DB_R, + }); + try { saveConfig(); } catch (_) {} +} + +function saveConfig() { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(CONFIG)); + } catch (e) { + console.warn('Config save error:', e); + } +} + +function buildPhoenixGlobalConfigPayload() { + const rawFft = Number(CONFIG.FFT_SIZE); + const fftSize = (rawFft === 2048 || rawFft === 4096 || rawFft === 8192 || rawFft === 16384) + ? rawFft + : (rawFft < 3072 ? 2048 : (rawFft < 6144 ? 4096 : (rawFft < 12288 ? 8192 : 16384))); + const rawXyPoints = Number(CONFIG.XY_POINTS); + const xyPoints = [128, 256, 512, 1024, 2048].includes(rawXyPoints) + ? rawXyPoints + : (rawXyPoints < 192 ? 128 : (rawXyPoints < 384 ? 256 : (rawXyPoints < 768 ? 512 : (rawXyPoints < 1536 ? 1024 : 2048)))); + const normalizeColor = (value, fallback) => { + const color = String(value || '').trim(); + return color || fallback; + }; + const normalizeMeterSlot = (value, fallback) => { + const raw = String(value || '').trim().toLowerCase(); + if (raw === 'vu') return 'vu'; + if (raw === 'ppm' || raw === 'ppm-ebu' || raw === 'ppm_ebu') return 'ppm-ebu'; + if (raw === 'ppm-din' || raw === 'ppm_din') return 'ppm-din'; + if (raw === 'tp') return 'tp'; + if (raw === 'hifi-peak' || raw === 'hifi_peak') return 'hifi-peak'; + if (raw === 'rms') return 'rms'; + if (raw === 'lufs') return 'lufs'; + if (raw === 'stopwatch') return 'stopwatch'; + return fallback; + }; + const normalizePeakHistoryScroll = (value) => { + const allowed = new Set([0.5, 1, 2, 4, 6]); + const num = Number(value); + return allowed.has(num) ? num : 1; + }; + const normalizePhaseAmplitudeMode = (value) => { + return String(value || '').trim().toLowerCase() === 'ppm-din' ? 'ppm-din' : 'bandpass'; + }; + const normalizeRtaBpoMode = (value) => { + const mode = String(value || '').trim(); + return (mode === '1_3' || mode === '1_6' || mode === '1_12') ? mode : '1_6'; + }; + const phaseAmplitudeMode = normalizePhaseAmplitudeMode(CONFIG.PHASE_AMPLITUDE_MODE); + return { + fftSize, + rtaBpoMode: normalizeRtaBpoMode(CONFIG.RTA_BPO_MODE), + 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, + monoInput: !!CONFIG.MONO_INPUT, + lrFractionalDelayEnabled: !!CONFIG.LR_FRACTIONAL_DELAY_ENABLED, + lrFractionalDelaySamples: Number.isFinite(CONFIG.LR_FRACTIONAL_DELAY_SAMPLES) ? CONFIG.LR_FRACTIONAL_DELAY_SAMPLES : (CONFIG_DEFAULTS.LR_FRACTIONAL_DELAY_SAMPLES ?? 0.996), + ppmDinAttackMs: Number.isFinite(CONFIG.PPM_DIN_ATTACK_MS) ? CONFIG.PPM_DIN_ATTACK_MS : (CONFIG_DEFAULTS.PPM_DIN_ATTACK_MS ?? 5), + ppmDinDecayDbPerS: Number.isFinite(CONFIG.PPM_DIN_DECAY_DB_PER_S) ? CONFIG.PPM_DIN_DECAY_DB_PER_S : (CONFIG_DEFAULTS.PPM_DIN_DECAY_DB_PER_S ?? 11.8), + ppmDinFastAttack: !!CONFIG.PPM_DIN_FAST_ATTACK, + ppmEbuAttackMs: Number.isFinite(CONFIG.PPM_EBU_ATTACK_MS) ? CONFIG.PPM_EBU_ATTACK_MS : (CONFIG_DEFAULTS.PPM_EBU_ATTACK_MS ?? 10), + ppmEbuDecayDbPerS: Number.isFinite(CONFIG.PPM_EBU_DECAY_DB_PER_S) ? CONFIG.PPM_EBU_DECAY_DB_PER_S : (CONFIG_DEFAULTS.PPM_EBU_DECAY_DB_PER_S ?? 8.6), + lufsIWindowMin: Number.isFinite(CONFIG.LUFS_I_WINDOW_MIN) ? CONFIG.LUFS_I_WINDOW_MIN : (CONFIG_DEFAULTS.LUFS_I_WINDOW_MIN ?? 4), + lufsINormEnabled: !!CONFIG.LUFS_I_NORM_ENABLED, + ppmDinLoudnessBoxes: !!CONFIG.PPM_DIN_LOUDNESS_BOXES, + ppmDinLoudnessOffsetDb: Number.isFinite(CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB) ? CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB : (CONFIG_DEFAULTS.PPM_DIN_LOUDNESS_OFFSET_DB ?? 0), + xyPoints, + gonioDisplayGainDb: Number.isFinite(CONFIG.GONIO_DISPLAY_GAIN_DB) ? CONFIG.GONIO_DISPLAY_GAIN_DB : (CONFIG_DEFAULTS.GONIO_DISPLAY_GAIN_DB ?? -5), + recordOutputFormat: (CONFIG.RECORD_OUTPUT_FORMAT === 'webm') ? 'webm' : ((CONFIG.RECORD_OUTPUT_FORMAT === 'mp3') ? 'mp3' : 'wav'), + recordMp3BitrateKbps: [64, 128, 192, 256, 320].includes(Number(CONFIG.RECORD_MP3_BITRATE_KBPS)) ? Number(CONFIG.RECORD_MP3_BITRATE_KBPS) : 192, + recordTarget: (CONFIG.RECORD_TARGET === 'analyzer' || CONFIG.RECORD_TARGET === 'volumio') + ? CONFIG.RECORD_TARGET + : ((CONFIG.RECORD_PI_TARGET === 'volumio') ? 'volumio' : 'analyzer'), + recordAutoSplitGapSec: Number.isFinite(CONFIG.RECORD_AUTO_SPLIT_GAP_SEC) ? CONFIG.RECORD_AUTO_SPLIT_GAP_SEC : (CONFIG_DEFAULTS.RECORD_AUTO_SPLIT_GAP_SEC ?? 1.5), + recordAutoThresholdDbfs: Number.isFinite(CONFIG.RECORD_AUTO_THRESHOLD_DBFS) ? CONFIG.RECORD_AUTO_THRESHOLD_DBFS : (CONFIG_DEFAULTS.RECORD_AUTO_THRESHOLD_DBFS ?? -50), + screensaverEnabled: CONFIG.SCREENSAVER_ENABLED !== false, + screensaverIdleMin: Number.isFinite(CONFIG.SCREENSAVER_IDLE_MIN) ? CONFIG.SCREENSAVER_IDLE_MIN : (CONFIG_DEFAULTS.SCREENSAVER_IDLE_MIN ?? 5), + screensaverActivityDb: Number.isFinite(CONFIG.SCREENSAVER_ACTIVITY_DB) ? CONFIG.SCREENSAVER_ACTIVITY_DB : (CONFIG_DEFAULTS.SCREENSAVER_ACTIVITY_DB ?? -50), + screensaverLedGlow: CONFIG.SCREENSAVER_LED_GLOW !== false, + screensaverLedColor: normalizeColor(CONFIG.SCREENSAVER_LED_COLOR, CONFIG_DEFAULTS.SCREENSAVER_LED_COLOR || '#ff0000'), + clockLedColor: normalizeColor(CONFIG.CLOCK_LED_COLOR, CONFIG_DEFAULTS.CLOCK_LED_COLOR || '#ff0000'), + headerTextColor: normalizeColor(CONFIG.HEADER_TEXT_COLOR, CONFIG_DEFAULTS.HEADER_TEXT_COLOR || '#ffe066'), + rtaBarBaseColor: normalizeColor(CONFIG.RTA_BAR_BASE_COLOR, CONFIG_DEFAULTS.RTA_BAR_BASE_COLOR || '#ffe066'), + peakHistoryScrollMode: normalizePeakHistoryScroll(CONFIG.PEAK_HISTORY_SCROLL_MODE), + peakHistoryFillEnabled: !!CONFIG.PEAK_HISTORY_FILL_ENABLED, + peakHistoryFillInvert: !!CONFIG.PEAK_HISTORY_FILL_INVERT, + peakHistorySlot: normalizeMeterSlot(CONFIG.PEAK_HISTORY_SLOT, CONFIG_DEFAULTS.PEAK_HISTORY_SLOT || 'ppm-din'), + phaseDisplayGainDb: Number.isFinite(CONFIG.PHASE_DISPLAY_GAIN_DB) ? CONFIG.PHASE_DISPLAY_GAIN_DB : (CONFIG_DEFAULTS.PHASE_DISPLAY_GAIN_DB ?? 0), + phaseAgcEnabled: phaseAmplitudeMode === 'ppm-din' ? false : !!CONFIG.PHASE_AGC_ENABLED, + phaseTrailEnabled: !!CONFIG.PHASE_TRAIL_ENABLED, + phaseAmplitudeMode, + classicNeedlesSlot: normalizeMeterSlot(CONFIG.CLASSIC_NEEDLES_SLOT, CONFIG_DEFAULTS.CLASSIC_NEEDLES_SLOT || 'vu'), + waveformColorLeft: normalizeColor(CONFIG.WAVEFORM_COLOR_LEFT, CONFIG_DEFAULTS.WAVEFORM_COLOR_LEFT || '#00e7ff'), + waveformColorRight: normalizeColor(CONFIG.WAVEFORM_COLOR_RIGHT, CONFIG_DEFAULTS.WAVEFORM_COLOR_RIGHT || '#ff6b81'), + waveformColorDiff: normalizeColor(CONFIG.WAVEFORM_COLOR_DIFF, CONFIG_DEFAULTS.WAVEFORM_COLOR_DIFF || '#00e7ff'), + vuColorNormal: normalizeColor(CONFIG.VU_COLOR_NORMAL, CONFIG_DEFAULTS.VU_COLOR_NORMAL || '#ffe066'), + vuColorWarn: normalizeColor(CONFIG.VU_COLOR_WARN, CONFIG_DEFAULTS.VU_COLOR_WARN || '#ff3b3b'), + ppmDinColorNormal: normalizeColor(CONFIG.PPM_DIN_COLOR_NORMAL, CONFIG_DEFAULTS.PPM_DIN_COLOR_NORMAL || '#ffe066'), + ppmDinColorWarn: normalizeColor(CONFIG.PPM_DIN_COLOR_WARN, CONFIG_DEFAULTS.PPM_DIN_COLOR_WARN || '#ff3b3b'), + ppmEbuColorNormal: normalizeColor(CONFIG.PPM_EBU_COLOR_NORMAL, CONFIG_DEFAULTS.PPM_EBU_COLOR_NORMAL || '#ffe066'), + ppmEbuColorWarn: normalizeColor(CONFIG.PPM_EBU_COLOR_WARN, CONFIG_DEFAULTS.PPM_EBU_COLOR_WARN || '#ff3b3b'), + tpColorNormal: normalizeColor(CONFIG.TP_COLOR_NORMAL, CONFIG_DEFAULTS.TP_COLOR_NORMAL || '#ffe066'), + tpColorWarn: normalizeColor(CONFIG.TP_COLOR_WARN, CONFIG_DEFAULTS.TP_COLOR_WARN || '#ff3b3b'), + rmsColorNormal: normalizeColor(CONFIG.RMS_COLOR_NORMAL, CONFIG_DEFAULTS.RMS_COLOR_NORMAL || '#34d399'), + rmsColorWarn: normalizeColor(CONFIG.RMS_COLOR_WARN, CONFIG_DEFAULTS.RMS_COLOR_WARN || '#ff3b3b'), + lufsColorI: normalizeColor(CONFIG.LUFS_COLOR_I, CONFIG_DEFAULTS.LUFS_COLOR_I || '#34d399'), + lufsColorM: normalizeColor(CONFIG.LUFS_COLOR_M, CONFIG_DEFAULTS.LUFS_COLOR_M || '#ffe066'), + lufsColorS: normalizeColor(CONFIG.LUFS_COLOR_S, CONFIG_DEFAULTS.LUFS_COLOR_S || '#ff3b3b'), + lufsScaleLabelColor: normalizeColor(CONFIG.LUFS_SCALE_LABEL_COLOR, CONFIG_DEFAULTS.LUFS_SCALE_LABEL_COLOR || '#8fd3d4'), + }; +} + +function applyPhoenixGlobalConfig(payload = {}) { + if (!payload || typeof payload !== 'object') return null; + const normalizeColor = (value, fallback) => { + const color = String(value || '').trim(); + return color || fallback; + }; + const normalizeMeterSlot = (value, fallback) => { + const raw = String(value || '').trim().toLowerCase(); + if (raw === 'vu') return 'vu'; + if (raw === 'ppm' || raw === 'ppm-ebu' || raw === 'ppm_ebu') return 'ppm-ebu'; + if (raw === 'ppm-din' || raw === 'ppm_din') return 'ppm-din'; + if (raw === 'tp') return 'tp'; + if (raw === 'hifi-peak' || raw === 'hifi_peak') return 'hifi-peak'; + if (raw === 'rms') return 'rms'; + if (raw === 'lufs') return 'lufs'; + if (raw === 'stopwatch') return 'stopwatch'; + return fallback; + }; + const fft = Number(payload.fftSize); + if (Number.isFinite(fft)) { + CONFIG.FFT_SIZE = (fft === 2048 || fft === 4096 || fft === 8192 || fft === 16384) + ? fft + : (fft < 3072 ? 2048 : (fft < 6144 ? 4096 : (fft < 12288 ? 8192 : 16384))); + } + if ('rtaBpoMode' in payload) { + const mode = String(payload.rtaBpoMode || '').trim(); + CONFIG.RTA_BPO_MODE = (mode === '1_3' || mode === '1_6' || mode === '1_12') ? mode : '1_6'; + } + const dinAttack = Number(payload.ppmDinAttackMs); + if (Number.isFinite(dinAttack)) CONFIG.PPM_DIN_ATTACK_MS = Math.max(0.1, Math.min(100, dinAttack)); + const dinDecay = Number(payload.ppmDinDecayDbPerS); + if (Number.isFinite(dinDecay)) CONFIG.PPM_DIN_DECAY_DB_PER_S = Math.max(0.1, Math.min(120, dinDecay)); + if ('ppmDinFastAttack' in payload) CONFIG.PPM_DIN_FAST_ATTACK = !!payload.ppmDinFastAttack; + const ebuAttack = Number(payload.ppmEbuAttackMs); + if (Number.isFinite(ebuAttack)) CONFIG.PPM_EBU_ATTACK_MS = Math.max(0.1, Math.min(100, ebuAttack)); + const ebuDecay = Number(payload.ppmEbuDecayDbPerS); + if (Number.isFinite(ebuDecay)) CONFIG.PPM_EBU_DECAY_DB_PER_S = Math.max(0.1, Math.min(120, ebuDecay)); + const lufsWin = Number(payload.lufsIWindowMin); + if (Number.isFinite(lufsWin)) CONFIG.LUFS_I_WINDOW_MIN = Math.max(1, Math.min(10, Math.round(lufsWin))); + if ('lufsINormEnabled' in payload) CONFIG.LUFS_I_NORM_ENABLED = !!payload.lufsINormEnabled; + if ('ppmDinLoudnessBoxes' in payload) CONFIG.PPM_DIN_LOUDNESS_BOXES = !!payload.ppmDinLoudnessBoxes; + const ppmDinLoudnessOffsetDb = Number(payload.ppmDinLoudnessOffsetDb); + if (Number.isFinite(ppmDinLoudnessOffsetDb)) CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB = Math.max(-7, Math.min(7, ppmDinLoudnessOffsetDb)); + const xyPoints = Number(payload.xyPoints); + if (Number.isFinite(xyPoints)) { + CONFIG.XY_POINTS = [128, 256, 512, 1024, 2048].includes(xyPoints) + ? xyPoints + : (xyPoints < 192 ? 128 : (xyPoints < 384 ? 256 : (xyPoints < 768 ? 512 : (xyPoints < 1536 ? 1024 : 2048)))); + } + const gonioDisplayGainDb = Number(payload.gonioDisplayGainDb); + if (Number.isFinite(gonioDisplayGainDb)) { + CONFIG.GONIO_DISPLAY_GAIN_DB = Math.max(-35, Math.min(35, Math.round(gonioDisplayGainDb / 5) * 5)); + } + if ('recordOutputFormat' in payload) { + const fmt = String(payload.recordOutputFormat || '').toLowerCase(); + CONFIG.RECORD_OUTPUT_FORMAT = (fmt === 'webm') ? 'webm' : ((fmt === 'mp3') ? 'mp3' : 'wav'); + } + const mp3Rate = Number(payload.recordMp3BitrateKbps); + if ([64, 128, 192, 256, 320].includes(mp3Rate)) CONFIG.RECORD_MP3_BITRATE_KBPS = mp3Rate; + if ('recordTarget' in payload) { + const target = String(payload.recordTarget || '').toLowerCase(); + CONFIG.RECORD_PI_TARGET = (target === 'volumio') ? 'volumio' : 'analyzer'; + if (CONFIG.RECORD_TARGET !== 'download') { + CONFIG.RECORD_TARGET = CONFIG.RECORD_PI_TARGET; + } + } + const autoGap = Number(payload.recordAutoSplitGapSec); + if (Number.isFinite(autoGap)) CONFIG.RECORD_AUTO_SPLIT_GAP_SEC = Math.max(0.5, Math.min(3, Math.round(autoGap * 2) / 2)); + const autoThr = Number(payload.recordAutoThresholdDbfs); + if (Number.isFinite(autoThr)) CONFIG.RECORD_AUTO_THRESHOLD_DBFS = Math.max(-120, Math.min(20, autoThr)); + if ('screensaverEnabled' in payload) CONFIG.SCREENSAVER_ENABLED = !!payload.screensaverEnabled; + const idle = Number(payload.screensaverIdleMin); + if (Number.isFinite(idle)) CONFIG.SCREENSAVER_IDLE_MIN = Math.max(0, Math.min(120, idle)); + const activity = Number(payload.screensaverActivityDb); + if (Number.isFinite(activity)) CONFIG.SCREENSAVER_ACTIVITY_DB = Math.max(-120, Math.min(0, activity)); + if ('screensaverLedGlow' in payload) CONFIG.SCREENSAVER_LED_GLOW = !!payload.screensaverLedGlow; + if ('screensaverLedColor' in payload) { + CONFIG.SCREENSAVER_LED_COLOR = normalizeColor(payload.screensaverLedColor, CONFIG_DEFAULTS.SCREENSAVER_LED_COLOR || '#ff0000'); + } + if ('clockLedColor' in payload) { + CONFIG.CLOCK_LED_COLOR = normalizeColor(payload.clockLedColor, CONFIG_DEFAULTS.CLOCK_LED_COLOR || '#ff0000'); + } + if ('headerTextColor' in payload) { + CONFIG.HEADER_TEXT_COLOR = normalizeColor(payload.headerTextColor, CONFIG_DEFAULTS.HEADER_TEXT_COLOR || '#ffe066'); + } + if ('rtaBarBaseColor' in payload) { + CONFIG.RTA_BAR_BASE_COLOR = normalizeColor(payload.rtaBarBaseColor, CONFIG_DEFAULTS.RTA_BAR_BASE_COLOR || '#ffe066'); + } + const peakHistoryScrollMode = Number(payload.peakHistoryScrollMode); + if ([0.5, 1, 2, 4, 6].includes(peakHistoryScrollMode)) { + CONFIG.PEAK_HISTORY_SCROLL_MODE = peakHistoryScrollMode; + } + if ('peakHistoryFillEnabled' in payload) CONFIG.PEAK_HISTORY_FILL_ENABLED = !!payload.peakHistoryFillEnabled; + if ('peakHistoryFillInvert' in payload) CONFIG.PEAK_HISTORY_FILL_INVERT = !!payload.peakHistoryFillInvert; + if ('peakHistorySlot' in payload) { + CONFIG.PEAK_HISTORY_SLOT = normalizeMeterSlot(payload.peakHistorySlot, CONFIG_DEFAULTS.PEAK_HISTORY_SLOT || 'ppm-din'); + } + const phaseDisplayGainDb = Number(payload.phaseDisplayGainDb); + if (Number.isFinite(phaseDisplayGainDb)) { + const clamped = Math.max(-35, Math.min(35, phaseDisplayGainDb)); + CONFIG.PHASE_DISPLAY_GAIN_DB = Math.round(clamped / 5) * 5; + } + if ('phaseAmplitudeMode' in payload) { + CONFIG.PHASE_AMPLITUDE_MODE = String(payload.phaseAmplitudeMode || '').trim().toLowerCase() === 'ppm-din' ? 'ppm-din' : 'bandpass'; + } + if ('phaseAgcEnabled' in payload) { + CONFIG.PHASE_AGC_ENABLED = (CONFIG.PHASE_AMPLITUDE_MODE === 'ppm-din') ? false : !!payload.phaseAgcEnabled; + } + if (CONFIG.PHASE_AMPLITUDE_MODE === 'ppm-din') { + CONFIG.PHASE_AGC_ENABLED = false; + } else if (CONFIG.PHASE_AGC_ENABLED) { + CONFIG.PHASE_DISPLAY_GAIN_DB = 0; + } + if ('phaseTrailEnabled' in payload) CONFIG.PHASE_TRAIL_ENABLED = !!payload.phaseTrailEnabled; + if ('classicNeedlesSlot' in payload) { + CONFIG.CLASSIC_NEEDLES_SLOT = normalizeMeterSlot(payload.classicNeedlesSlot, CONFIG_DEFAULTS.CLASSIC_NEEDLES_SLOT || 'vu'); + } + if ('waveformColorLeft' in payload) { + CONFIG.WAVEFORM_COLOR_LEFT = normalizeColor(payload.waveformColorLeft, CONFIG_DEFAULTS.WAVEFORM_COLOR_LEFT || '#00e7ff'); + } + if ('waveformColorRight' in payload) { + CONFIG.WAVEFORM_COLOR_RIGHT = normalizeColor(payload.waveformColorRight, CONFIG_DEFAULTS.WAVEFORM_COLOR_RIGHT || '#ff6b81'); + } + if ('waveformColorDiff' in payload) { + CONFIG.WAVEFORM_COLOR_DIFF = normalizeColor(payload.waveformColorDiff, CONFIG_DEFAULTS.WAVEFORM_COLOR_DIFF || '#00e7ff'); + } + if ('vuColorNormal' in payload) CONFIG.VU_COLOR_NORMAL = normalizeColor(payload.vuColorNormal, CONFIG_DEFAULTS.VU_COLOR_NORMAL || '#ffe066'); + if ('vuColorWarn' in payload) CONFIG.VU_COLOR_WARN = normalizeColor(payload.vuColorWarn, CONFIG_DEFAULTS.VU_COLOR_WARN || '#ff3b3b'); + if ('ppmDinColorNormal' in payload) CONFIG.PPM_DIN_COLOR_NORMAL = normalizeColor(payload.ppmDinColorNormal, CONFIG_DEFAULTS.PPM_DIN_COLOR_NORMAL || '#ffe066'); + if ('ppmDinColorWarn' in payload) CONFIG.PPM_DIN_COLOR_WARN = normalizeColor(payload.ppmDinColorWarn, CONFIG_DEFAULTS.PPM_DIN_COLOR_WARN || '#ff3b3b'); + if ('ppmEbuColorNormal' in payload) CONFIG.PPM_EBU_COLOR_NORMAL = normalizeColor(payload.ppmEbuColorNormal, CONFIG_DEFAULTS.PPM_EBU_COLOR_NORMAL || '#ffe066'); + if ('ppmEbuColorWarn' in payload) CONFIG.PPM_EBU_COLOR_WARN = normalizeColor(payload.ppmEbuColorWarn, CONFIG_DEFAULTS.PPM_EBU_COLOR_WARN || '#ff3b3b'); + if ('tpColorNormal' in payload) CONFIG.TP_COLOR_NORMAL = normalizeColor(payload.tpColorNormal, CONFIG_DEFAULTS.TP_COLOR_NORMAL || '#ffe066'); + if ('tpColorWarn' in payload) CONFIG.TP_COLOR_WARN = normalizeColor(payload.tpColorWarn, CONFIG_DEFAULTS.TP_COLOR_WARN || '#ff3b3b'); + if ('rmsColorNormal' in payload) CONFIG.RMS_COLOR_NORMAL = normalizeColor(payload.rmsColorNormal, CONFIG_DEFAULTS.RMS_COLOR_NORMAL || '#34d399'); + if ('rmsColorWarn' in payload) CONFIG.RMS_COLOR_WARN = normalizeColor(payload.rmsColorWarn, CONFIG_DEFAULTS.RMS_COLOR_WARN || '#ff3b3b'); + if ('lufsColorI' in payload) CONFIG.LUFS_COLOR_I = normalizeColor(payload.lufsColorI, CONFIG_DEFAULTS.LUFS_COLOR_I || '#34d399'); + if ('lufsColorM' in payload) CONFIG.LUFS_COLOR_M = normalizeColor(payload.lufsColorM, CONFIG_DEFAULTS.LUFS_COLOR_M || '#ffe066'); + if ('lufsColorS' in payload) CONFIG.LUFS_COLOR_S = normalizeColor(payload.lufsColorS, CONFIG_DEFAULTS.LUFS_COLOR_S || '#ff3b3b'); + if ('lufsScaleLabelColor' in payload) { + CONFIG.LUFS_SCALE_LABEL_COLOR = normalizeColor(payload.lufsScaleLabelColor, CONFIG_DEFAULTS.LUFS_SCALE_LABEL_COLOR || '#8fd3d4'); + } + if ('monoInput' in payload) CONFIG.MONO_INPUT = !!payload.monoInput; + if ('lrFractionalDelayEnabled' in payload) CONFIG.LR_FRACTIONAL_DELAY_ENABLED = !!payload.lrFractionalDelayEnabled; + const frac = Number(payload.lrFractionalDelaySamples); + if (Number.isFinite(frac)) CONFIG.LR_FRACTIONAL_DELAY_SAMPLES = Math.max(-1.5, Math.min(1.5, frac)); + updateInputOffsetGain({ + L: Number.isFinite(Number(payload.inputOffsetDbL)) ? Number(payload.inputOffsetDbL) : CONFIG.INPUT_OFFSET_DB_L, + R: Number.isFinite(Number(payload.inputOffsetDbR)) ? Number(payload.inputOffsetDbR) : CONFIG.INPUT_OFFSET_DB_R, + }); + return buildPhoenixGlobalConfigPayload(); +} + +// Preset anwenden (wie Button „Apply"") +function applyBroadcastStandard(name) { + const p = BROADCAST_STANDARDS[name]; + if (!p) return; + CONFIG.VU_DBFS_REF = p.VU_DBFS_REF; + if (typeof p.PPM_EBU_TOP === 'number') CONFIG.PPM_EBU_TOP = p.PPM_EBU_TOP; + if (typeof p.PPM_EBU_RED_START === 'number') CONFIG.PPM_EBU_RED_START = p.PPM_EBU_RED_START; + if (typeof p.PPM_DIN_TOP === 'number') CONFIG.PPM_DIN_TOP = p.PPM_DIN_TOP; + if (typeof p.PPM_DIN_RED_START === 'number') CONFIG.PPM_DIN_RED_START = p.PPM_DIN_RED_START; + CONFIG.TP_RED_START = p.TP_RED_START; + CONFIG.LUFS_RED_START = p.LUFS_RED_START; + CONFIG.LUFS_YELLOW_START = p.LUFS_YELLOW_START; + CONFIG.LUFS_GREEN_START = p.LUFS_GREEN_START; + CONFIG.VU_RED_START = p.VU_RED_START; + + // Offsets zurücksetzen (wie bisher) + CONFIG.VU_OFFSET_DB = VU_OFFSET_ALIGNMENT_DB; + CONFIG.TP_OFFSET_DB = 0; + CONFIG.RMS_OFFSET_DB = 0; + + saveConfig(); +} + +function resetConfigToDefaults() { + applySoftwarePreset('default', { persist: false }); +} + +// Export everything at the bottom +export { + CONFIG, + SOFTWARE_PRESET_IDS, + LAYOUT_PRESET_IDS, + PHOENIX_GLOBAL_OPTION_KEYS, + BROADCAST_STANDARDS, + loadConfig, + saveConfig, + buildPhoenixGlobalConfigPayload, + applyPhoenixGlobalConfig, + applyBroadcastStandard, + applySoftwarePreset, + loadSoftwarePreset, + saveSoftwarePreset, + loadLayoutPreset, + saveLayoutPreset, + exportSoftwarePreset, + importSoftwarePreset, + applyAlignmentProfile, + updateVuReference, + updateInputOffsetGain, + resetConfigToDefaults + +}; diff --git a/www/core/registry.js b/www/core/registry.js new file mode 100644 index 0000000..ea68d84 --- /dev/null +++ b/www/core/registry.js @@ -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)); + } +} diff --git a/www/core/rtw_centers.js b/www/core/rtw_centers.js new file mode 100644 index 0000000..753fef8 --- /dev/null +++ b/www/core/rtw_centers.js @@ -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; +} diff --git a/www/core/screensaver.js b/www/core/screensaver.js new file mode 100644 index 0000000..acfaacc --- /dev/null +++ b/www/core/screensaver.js @@ -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})`; +} diff --git a/www/core/theme.js b/www/core/theme.js new file mode 100644 index 0000000..e8d6407 --- /dev/null +++ b/www/core/theme.js @@ -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; diff --git a/www/core/utils.js b/www/core/utils.js new file mode 100644 index 0000000..15ff19d --- /dev/null +++ b/www/core/utils.js @@ -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(); +} diff --git a/www/index.html b/www/index.html new file mode 100644 index 0000000..aab7ac1 --- /dev/null +++ b/www/index.html @@ -0,0 +1,1892 @@ + + + + + + Audio Analyzer – RTW Look (dBFS) + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + diff --git a/www/main.js b/www/main.js new file mode 100644 index 0000000..a30a707 --- /dev/null +++ b/www/main.js @@ -0,0 +1,2740 @@ +// main.js — App-Bootstrap: Env bauen, Views/Audio/Options verdrahten, Loop fahren + +// --- One-time migration: enforce new dBFS range [+9 .. -63] +try { + const k2 = 'MIGRATE_DBFS_BOUNDS_9__63'; + +// --- Sanity: guard AXIS_GUTTER_LEFT to avoid layout/NaN issues +try { + const k = 'MIGRATE_SANITY_GUTTER'; + if (!localStorage.getItem(k)) { + const g = Number(CONFIG && CONFIG.AXIS_GUTTER_LEFT); + if (!Number.isFinite(g) || g < 8) CONFIG.AXIS_GUTTER_LEFT = 14; + localStorage.setItem(k, '1'); + } +} catch(_) {} + if (!localStorage.getItem(k2)) { + if (typeof CONFIG?.DBFS_TOP === 'number' && CONFIG.DBFS_TOP !== 9) CONFIG.DBFS_TOP = 9; + if (typeof CONFIG?.DBFS_BOTTOM === 'number' && CONFIG.DBFS_BOTTOM !== -63) CONFIG.DBFS_BOTTOM = -63; + localStorage.setItem(k2, '1'); + } +} catch(_) {} + +import { CONFIG, loadConfig, saveConfig, loadLayoutPreset } from './core/config.js'; +import * as utils from './core/utils.js'; +import { meterFacade, registerMeter } from './core/registry.js'; +import { initAudio, audioLost, reloadAudio } from './core/audio.js'; +import { createScreensaver } from './core/screensaver.js'; + +// Views +import * as viewGoni from './views/goniometer_rtw.js'; +import * as viewPhaseWheel from './views/phase_wheel.js'; +import * as viewPanel from './views/panel.js'; +import * as viewRealtime from './views/realtime.js'; +import * as viewClassicNeedles from './views/classic_needles.js'; +import * as viewPeakHistory from './views/peak_history.js'; +import * as viewSpectrogram from './views/spectrogram.js'; +import * as viewWaveform from './views/waveform.js'; +import * as viewRecorder from './views/recorder.js'; +import * as viewSplit from './views/split_view.js'; +import * as viewQuad from './views/quad_view.js'; + +// Meters +import * as vu from './meters/vu.js'; +import * as ppmEbu from './meters/ppm_ebu.js'; +import * as ppmDin from './meters/ppm_din.js'; +import * as tp from './meters/tp.js'; +import * as hifiPeak from './meters/hifi_peak.js'; +import * as rms from './meters/rms.js'; +import * as lufs from './meters/lufs.js'; +import * as stopwatch from './meters/stopwatch.js'; + +// Options UI +import { setupOptions } from './ui/options.js'; + +// --- Canvas & Context --------------------------------------------------------- +const C = document.getElementById('cv'); +const g = C.getContext('2d'); + +function resizeCanvas() { + const dpr = Math.max(1, window.devicePixelRatio || 1); + const width = innerWidth * dpr; + const height = innerHeight * dpr; + C.width = width; + C.height = height; + C.style.width = `${innerWidth}px`; + C.style.height = `${innerHeight}px`; + g.setTransform(dpr, 0, 0, dpr, 0, 0); + g.textBaseline = 'alphabetic'; + g.font = 'bold 14px ui-monospace, monospace'; + g.lineWidth = 1.6; +} +addEventListener('resize', resizeCanvas); +resizeCanvas(); + +// --- HUD --------------------------------------------------------------------- +const styleSel = document.getElementById('styleSel'); +const slotWrap = document.getElementById('slotWrap'); +const slotLabel = document.getElementById('slotLabel'); +const slotSel1 = document.getElementById('slotSel1'); +const slotSel2 = document.getElementById('slotSel2'); +const slotSel3 = document.getElementById('slotSel3'); +const slotSel4 = document.getElementById('slotSel4'); +const slotSel5 = document.getElementById('slotSel5'); +const slotSelects = [slotSel1, slotSel2, slotSel3, slotSel4, slotSel5]; +const peakHistScrollWrap = document.getElementById('peakHistScrollWrap'); +const peakHistScrollSel = document.getElementById('peakHistScrollSel'); +const goniAgcToggle = document.getElementById('goniAgcToggle'); +const goniAgcChk = document.getElementById('goniAgcChk'); +const spectroLegendWrap = document.getElementById('spectroLegendWrap'); +const waveWindowWrap = document.getElementById('waveWindowWrap'); +const waveWindowSel = document.getElementById('waveWindowSel'); +const waveModeWrap = document.getElementById('waveModeWrap'); +const waveModeSel = document.getElementById('waveModeSel'); +const rtaBpoWrap = document.getElementById('rtaBpoWrap'); +const rtaBpoSel = document.getElementById('rtaBpoSel'); +const splitViewWrap = document.getElementById('splitViewWrap'); +const splitLeftSel = document.getElementById('splitLeftSel'); +const splitRightSel = document.getElementById('splitRightSel'); +const splitMeterBtnWrap = document.getElementById('splitMeterBtnWrap'); +const splitMeterBtn = document.getElementById('splitMeterBtn'); +const splitMeterPopup = document.getElementById('splitMeterPopup'); +const splitMeterPopupTitle = document.getElementById('splitMeterPopupTitle'); +const splitMeterPopupClose = document.getElementById('splitMeterPopupClose'); +const splitMeterRow1 = document.getElementById('splitMeterRow1'); +const splitMeterRow2 = document.getElementById('splitMeterRow2'); +const splitMeterRow3 = document.getElementById('splitMeterRow3'); +const splitMeterSel1 = document.getElementById('splitMeterSel1'); +const splitMeterSel2 = document.getElementById('splitMeterSel2'); +const splitMeterSel3 = document.getElementById('splitMeterSel3'); +const quadViewWrap = document.getElementById('quadViewWrap'); +const quadPlotBtn = document.getElementById('quadPlotBtn'); +const quadMeterBtnWrap = document.getElementById('quadMeterBtnWrap'); +const quadMeterBtn = document.getElementById('quadMeterBtn'); +const quadPlotPopup = document.getElementById('quadPlotPopup'); +const quadPlotPopupClose = document.getElementById('quadPlotPopupClose'); +const quadPlotSelTL = document.getElementById('quadPlotSelTL'); +const quadPlotSelTR = document.getElementById('quadPlotSelTR'); +const quadPlotSelBL = document.getElementById('quadPlotSelBL'); +const quadPlotSelBR = document.getElementById('quadPlotSelBR'); +const gainWrap = document.getElementById('gainWrap'); +const gainSel = document.getElementById('gainSel'); +const resetPeakBtn= document.getElementById('resetPeakBtn'); +const phaseTrailToggle = document.getElementById('phaseTrailToggle'); +const phaseTrailChk = document.getElementById('phaseTrailChk'); +const recorderHud = document.getElementById('recorderHud'); +const recTargetSel = document.getElementById('recTargetSel'); +const recStartBtn = document.getElementById('recStartBtn'); +const recStopBtn = document.getElementById('recStopBtn'); +const recAutoBtn = document.getElementById('recAutoBtn'); +const recLabel = document.getElementById('recLabel'); +const recList = document.getElementById('recList'); +const recTimer = document.getElementById('recTimer'); +const recProcessing = document.getElementById('recProcessing'); +const recWarning = document.getElementById('recWarning'); +const recWarningAck = document.getElementById('recWarningAck'); +const recWarningBtn = document.getElementById('recWarningBtn'); +const calibrationNotice = document.getElementById('calibrationNotice'); +const calibrationNoticeBtn = document.getElementById('calibrationNoticeBtn'); +const CALIBRATION_NOTICE_KEY = 'calibration_notice_seen_v1'; +const REC_WARNING_KEY = 'recorder_warning_ack_v1'; +let calibrationNoticeSeen = false; +try { + calibrationNoticeSeen = localStorage.getItem(CALIBRATION_NOTICE_KEY) === '1'; +} catch (_) {} +if (!calibrationNoticeSeen) { + try { calibrationNoticeSeen = sessionStorage.getItem(CALIBRATION_NOTICE_KEY) === '1'; } catch (_) {} +} +const recorderState = { + status: 'idle', + target: null, + error: '', + startTs: 0, + lastDurationMs: 0, + history: [], + timerRaf: null, + activeSession: null, + processingJobs: 0, + nextSessionId: 1, + nextRecorderSlot: 'A', + activeRecorderSlot: null, + processingRecorderSlots: [], + autoEnabled: false, + autoMonitorRaf: null, + autoSilenceSince: 0, + autoPendingStart: false, + autoPendingStop: false, +}; +const RECORDER_HISTORY_LIMIT = 100; + +function getRecorderAutoSplitGapMs() { + const raw = Number(CONFIG.RECORD_AUTO_SPLIT_GAP_SEC); + const sec = Math.max(0.5, Math.min(3, Number.isFinite(raw) ? raw : 1.5)); + return sec * 1000; +} + +function getRecorderAutoThresholdDbfs() { + const raw = Number(CONFIG.RECORD_AUTO_THRESHOLD_DBFS); + return Math.max(-120, Math.min(20, Number.isFinite(raw) ? raw : -50)); +} +const optionsPanelNew = document.getElementById('optionsPanelNew'); +const errBox = document.getElementById('err'); + +const REALTIME_GAIN_CHOICES = [-10, -5, 0, 3, 15, 20, 25]; +const WAVE_WINDOW_CHOICES = [0.05, 0.1, 0.5, 1, 2, 5, 10, 15]; +const WAVE_MODE_CHOICES = [ + ['stacked', 'Gestapelt'], + ['overlay', 'Überlagert'], + ['diff', 'Differenz (L−R)'], +]; +const RTA_BPO_CHOICES = [ + ['1_3', '1/3 Oktave'], + ['1_6', '1/6 Oktave'], + ['1_12', '1/12 Oktave'], +]; +const PEAK_HISTORY_SCROLL_CHOICES = [0.5, 1, 2, 4, 6]; +const RECORDER_TARGETS = [ + { id: 'download', label: 'Download auf diesem Rechner' }, + { id: 'analyzer', label: 'Pi: /home/analyzer/Aufnahmen' }, + { id: 'volumio', label: 'Pi: /home/volumio/Aufnahmen' }, +]; +if (!recorderState.target) { + recorderState.target = CONFIG.RECORD_TARGET || RECORDER_TARGETS[0]?.id || 'download'; +} +const GONIO_GAIN_MIN_DB = -35; +const GONIO_GAIN_MAX_DB = 35; +if (recTargetSel) { + recTargetSel.innerHTML = ''; + RECORDER_TARGETS.forEach((t) => { + const opt = document.createElement('option'); + opt.value = t.id; + opt.textContent = t.label; + recTargetSel.appendChild(opt); + }); + recorderState.target = CONFIG.RECORD_TARGET || RECORDER_TARGETS[0]?.id || 'download'; + recTargetSel.disabled = false; +} +function renderRecordingList() { + if (!recList) return; + recList.innerHTML = ''; + const title = document.createElement('h4'); + title.textContent = 'Aufnahmen'; + recList.appendChild(title); + if (!recorderState.history || !recorderState.history.length) { + const empty = document.createElement('div'); + empty.textContent = 'Keine Aufnahmen'; + recList.appendChild(empty); + return; + } + recorderState.history.forEach((entry) => { + const row = document.createElement('div'); + row.className = 'rec-item'; + const name = document.createElement('div'); + name.className = 'rec-name'; + name.textContent = entry.name; + const actions = document.createElement('div'); + actions.style.display = 'flex'; + actions.style.gap = '6px'; + const btnSave = document.createElement('button'); + btnSave.className = 'btn btn-compact'; + btnSave.textContent = entry.saving + ? 'Speichert…' + : (entry.saved + ? 'Gespeichert' + : (entry.released + ? 'Download gestartet' + : (entry.downloadRequested ? 'Erneut laden' : 'Speichern'))); + btnSave.disabled = !!entry.saving || !!entry.saved || !!entry.released; + btnSave.onclick = () => { + void saveRecordingEntry(entry); + }; + const btnDel = document.createElement('button'); + btnDel.className = 'btn btn-compact'; + btnDel.textContent = 'Löschen'; + btnDel.onclick = () => { + removeRecordingFromHistory(entry); + }; + actions.appendChild(btnSave); + actions.appendChild(btnDel); + row.appendChild(name); + row.appendChild(actions); + recList.appendChild(row); + }); +} + +function downloadRecordingEntry(entry, { rerender = true } = {}) { + if (!entry?.url) return false; + const a = document.createElement('a'); + a.href = entry.url; + a.download = entry.name || 'recording.webm'; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + entry.downloadRequested = true; + if (rerender) renderRecordingList(); + return true; +} + +function releaseRecordingEntryBlob(entry) { + if (!entry?.url) return false; + try { URL.revokeObjectURL(entry.url); } catch (_) {} + entry.url = ''; + entry.released = true; + return true; +} + +function isPhoenixRecorderSaveAvailable() { + return String(env?.audio?.backendMode || 'phoenix') === 'phoenix' && !!env?.audio?.baseUrl; +} + +function getRecorderSaveTarget() { + const raw = CONFIG.RECORD_TARGET || recorderState.target || 'download'; + return (raw === 'analyzer' || raw === 'volumio') ? raw : 'download'; +} + +async function saveRecordingEntryToPhoenix(entry) { + if (!entry?.url) throw new Error('Keine Aufnahme vorhanden'); + const baseUrl = String(env?.audio?.baseUrl || '').trim(); + if (!baseUrl) throw new Error('Phoenix nicht verbunden'); + const target = getRecorderSaveTarget(); + if (target !== 'analyzer' && target !== 'volumio') { + throw new Error('Ungültiges Phoenix-Ziel'); + } + const filename = encodeURIComponent(String(entry.name || 'recording.wav')); + const blob = await fetch(entry.url).then(async (res) => { + if (!res.ok) throw new Error(`Blob-Zugriff fehlgeschlagen (${res.status})`); + return await res.blob(); + }); + const res = await fetch(`${baseUrl}/api/v1/recordings/save/${target}/${filename}`, { + method: 'POST', + cache: 'no-store', + headers: { 'content-type': blob.type || 'audio/wav' }, + body: blob, + }); + if (!res.ok) { + throw new Error(`Speichern fehlgeschlagen (${res.status})`); + } + return await res.json(); +} + +async function saveRecordingEntry(entry, { rerender = true } = {}) { + if (!entry || entry.saving || entry.saved) return false; + entry.saving = true; + if (rerender) renderRecordingList(); + try { + if (getRecorderSaveTarget() !== 'download') { + if (!isPhoenixRecorderSaveAvailable()) { + throw new Error('Phoenix-Speicherweg ist aktuell nicht verfügbar'); + } + const result = await saveRecordingEntryToPhoenix(entry); + entry.saved = true; + entry.savedPath = result?.path || ''; + releaseRecordingEntryBlob(entry); + } else { + const downloaded = downloadRecordingEntry(entry, { rerender: false }); + if (!downloaded) return false; + entry.saved = true; + releaseRecordingEntryBlob(entry); + } + return true; + } catch (err) { + recorderState.error = err?.message || 'Speichern fehlgeschlagen'; + showErr(`Recorder: ${recorderState.error}`); + return false; + } finally { + entry.saving = false; + if (rerender) renderRecordingList(); + } +} +if (gainSel) { + gainSel.innerHTML = ''; + REALTIME_GAIN_CHOICES.forEach((db) => { + const opt = document.createElement('option'); + const label = `${db > 0 ? '+' : ''}${db} dB`; + opt.value = String(db); + opt.textContent = label; + gainSel.appendChild(opt); + }); +} +if (waveWindowSel) { + waveWindowSel.innerHTML = ''; + WAVE_WINDOW_CHOICES.forEach((sec) => { + const opt = document.createElement('option'); + opt.value = String(sec); + opt.textContent = formatWaveWindowLabel(sec); + waveWindowSel.appendChild(opt); + }); +} +if (waveModeSel) { + waveModeSel.innerHTML = ''; + WAVE_MODE_CHOICES.forEach(([val, label]) => { + const opt = document.createElement('option'); + opt.value = val; + opt.textContent = label; + waveModeSel.appendChild(opt); + }); +} +if (rtaBpoSel) { + rtaBpoSel.innerHTML = ''; + RTA_BPO_CHOICES.forEach(([val, label]) => { + const opt = document.createElement('option'); + opt.value = val; + opt.textContent = label; + rtaBpoSel.appendChild(opt); + }); +} + +function showErr(msg){ + if (!msg) return; + console.error('App Error:', msg); + errBox.textContent = String(msg); + errBox.style.display = 'block'; + setTimeout(hideErr, 5000); +} +function hideErr(){ errBox.style.display = 'none'; } + +function getPersistentItem(key) { + try { + const v = localStorage.getItem(key); + if (v !== null && v !== undefined) return v; + } catch (_) {} + return null; +} + +function setPersistentItem(key, value) { + const str = String(value ?? ''); + try { localStorage.setItem(key, str); } catch (_) {} +} + +// Persistenz +loadConfig(); +const STYLE_STORAGE_KEY = 'analyzer_style'; +const LAST_NON_OPTION_STYLE_KEY = 'analyzer_last_non_option_style'; +const SLOT_FALLBACK_ID = 'vu'; +const SLOT_OPTIONS = [ + ['vu','VU'], + ['ppm-ebu','PPM (EBU Type IIb)'], + ['ppm-din','PPM (DIN Type I)'], + ['tp','True Peak'], + ['hifi-peak','HiFi Peak'], + ['rms','True RMS'], + ['lufs','LUFS (EBU R128)'], + ['stopwatch','Stoppuhr'], + ['none','(leer)'], +]; +const SLOT_VALUE_SET = new Set(SLOT_OPTIONS.map(([val]) => val)); +const SLOT_STORAGE_KEY = 'analyzer_slots_v2'; +const SLOT_STORAGE_VIEW_PREFIX = `${SLOT_STORAGE_KEY}__`; +const SLOT_CUSTOMIZED_KEY = 'analyzer_slots_customized_v1'; +const LEGACY_SLOT_KEY = 'analyzer_slots'; +const VIEWS_DISALLOW_EMPTY_SLOT = new Set(['peak-history', 'classic-needles']); +const VIEW_SLOT_COUNTS = { + 'realtime': 1, + 'classic-needles': 1, + 'peak-history': 1, + 'goniometer-rtw': 3, + 'phase-wheel': 3, + 'panel': 5, + 'spectrogram': 1, + 'waveform': 1, + 'recorder': 3, + 'split-view': 0, + 'quad-view': 0, + 'clock': 0, +}; +const VIEW_SLOT_DEFAULTS = { + 'realtime': ['ppm-din'], + 'classic-needles': ['vu'], + 'peak-history': ['ppm-din'], + 'goniometer-rtw': ['ppm-ebu', 'ppm-din', 'lufs'], + 'phase-wheel': ['vu', 'ppm-ebu', 'tp'], + 'panel': ['vu', 'ppm-ebu', 'ppm-din', 'tp', 'rms'], + 'spectrogram': ['ppm-din'], + 'waveform': ['ppm-din'], + 'recorder': ['ppm-ebu', 'ppm-din', 'rms'], + 'split-view': [], + 'quad-view': [], + 'clock': [], +}; +const CONFIG_BACKED_GLOBAL_SLOTS = Object.freeze({ + 'peak-history': 'PEAK_HISTORY_SLOT', + 'classic-needles': 'CLASSIC_NEEDLES_SLOT', +}); +function resolveStyleId(viewId){ + return (viewId in VIEW_SLOT_DEFAULTS) ? viewId : 'panel'; +} +function sanitizeSlotId(id){ + if (id === 'ppm') return 'ppm-ebu'; + return SLOT_VALUE_SET.has(id) ? id : null; +} +function normalizeSlots(viewId, arr){ + const resolved = resolveStyleId(viewId); + const need = (resolved in VIEW_SLOT_COUNTS) ? VIEW_SLOT_COUNTS[resolved] : 1; + const defaults = (resolved in VIEW_SLOT_DEFAULTS) ? VIEW_SLOT_DEFAULTS[resolved] : VIEW_SLOT_DEFAULTS.panel; + const disallowEmpty = VIEWS_DISALLOW_EMPTY_SLOT.has(resolved); + const out = []; + for (let i = 0; i < need; i++){ + let desired = Array.isArray(arr) ? sanitizeSlotId(arr[i]) : null; + if (disallowEmpty && desired === 'none') desired = null; + out[i] = desired ?? defaults[i] ?? defaults[0] ?? SLOT_FALLBACK_ID; + } + return out; +} +function loadLegacySlots(){ + try { + const raw = getPersistentItem(LEGACY_SLOT_KEY); + if (!raw) return null; + const arr = JSON.parse(raw); + if (!Array.isArray(arr)) return null; + const legacy = {}; + for (const key of Object.keys(VIEW_SLOT_DEFAULTS)){ + legacy[key] = normalizeSlots(key, arr); + } + return legacy; + } catch { + return null; + } +} +function saveSlotState(state){ + try { setPersistentItem(SLOT_STORAGE_KEY, JSON.stringify(state)); } catch (_) {} + // Also persist per-view to be resilient against any single corrupted value. + try { + for (const key of Object.keys(VIEW_SLOT_DEFAULTS)) { + setPersistentItem(`${SLOT_STORAGE_VIEW_PREFIX}${key}`, JSON.stringify(state?.[key] || [])); + } + } catch (_) {} +} + +function loadSlotCustomizedState() { + try { + const raw = getPersistentItem(SLOT_CUSTOMIZED_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + return (parsed && typeof parsed === 'object') ? parsed : {}; + } catch { + return {}; + } +} + +function saveSlotCustomizedState(state) { + try { setPersistentItem(SLOT_CUSTOMIZED_KEY, JSON.stringify(state)); } catch (_) {} +} + +function loadSlotState(){ + const readPersistentJson = (key) => { + const candidates = []; + try { candidates.push(localStorage.getItem(key)); } catch (_) {} + for (const raw of candidates) { + if (!raw) continue; + try { return JSON.parse(raw); } catch (_) {} + } + return null; + }; + const loadPerViewSlots = () => { + const out = {}; + let any = false; + for (const key of Object.keys(VIEW_SLOT_DEFAULTS)) { + const parsed = readPersistentJson(`${SLOT_STORAGE_VIEW_PREFIX}${key}`); + if (Array.isArray(parsed)) { + out[key] = parsed; + any = true; + } + } + return any ? out : null; + }; + + let source = readPersistentJson(SLOT_STORAGE_KEY); + if (!(source && typeof source === 'object')) source = null; + if (!source) source = loadPerViewSlots(); + if (!source) source = loadLegacySlots(); + if (!source) source = {}; + const state = {}; + const customized = loadSlotCustomizedState(); + for (const key of Object.keys(VIEW_SLOT_DEFAULTS)){ + state[key] = normalizeSlots(key, source?.[key]); + } + + // Classic-Needles soll standardmäßig immer mit VU starten und erst nach manueller Änderung + // den zuletzt gewählten Slot beibehalten. + if (customized['classic-needles'] !== true) { + state['classic-needles'] = normalizeSlots('classic-needles', VIEW_SLOT_DEFAULTS['classic-needles']); + } + + saveSlotState(state); + return state; +} + +function syncConfigBackedSlots() { + let changed = false; + for (const [viewId, configKey] of Object.entries(CONFIG_BACKED_GLOBAL_SLOTS)) { + const fallback = (VIEW_SLOT_DEFAULTS[viewId] && VIEW_SLOT_DEFAULTS[viewId][0]) || SLOT_FALLBACK_ID; + const desired = sanitizeSlotId(CONFIG?.[configKey]); + const normalized = normalizeSlots(viewId, [desired && desired !== 'none' ? desired : fallback]); + const current = normalizeSlots(viewId, slotsState?.[viewId]); + if (current[0] !== normalized[0]) { + slotsState[viewId] = normalized; + changed = true; + } + CONFIG[configKey] = normalized[0] || fallback; + } + if (changed) { + saveSlotState(slotsState); + } + return changed; +} + +let slotsState = loadSlotState(); +syncConfigBackedSlots(); +const DEFAULT_STYLE = 'realtime'; +const storedStyleRaw = getPersistentItem(STYLE_STORAGE_KEY); +const cfgStyleRaw = (typeof CONFIG?.UI_LAST_STYLE === 'string' && CONFIG.UI_LAST_STYLE) ? CONFIG.UI_LAST_STYLE : null; +let style = storedStyleRaw || cfgStyleRaw || DEFAULT_STYLE; +if (style === 'options') style = 'options-panel'; +const VALID_STYLES = new Set(['realtime','split-view','quad-view','classic-needles','peak-history','panel','goniometer-rtw','phase-wheel','spectrogram','waveform','recorder','clock','options-panel']); +if (!VALID_STYLES.has(style)) style = DEFAULT_STYLE; +// First boot: ensure the Real Time Analyzer is used and persisted. +if (!storedStyleRaw) { + style = DEFAULT_STYLE; + try { setPersistentItem(STYLE_STORAGE_KEY, style); } catch (_) {} + try { setPersistentItem(LAST_NON_OPTION_STYLE_KEY, style); } catch (_) {} +} + +let lastNonOptionStyle = isOptionsStyle(style) ? DEFAULT_STYLE : style; +if (isOptionsStyle(style)) { + const savedLast = getPersistentItem(LAST_NON_OPTION_STYLE_KEY); + if (savedLast && VALID_STYLES.has(savedLast) && !isOptionsStyle(savedLast)) { + lastNonOptionStyle = savedLast; + } + const cfgLast = (typeof CONFIG?.UI_LAST_NON_OPTION_STYLE === 'string' && CONFIG.UI_LAST_NON_OPTION_STYLE) ? CONFIG.UI_LAST_NON_OPTION_STYLE : null; + if (cfgLast && VALID_STYLES.has(cfgLast) && !isOptionsStyle(cfgLast)) { + lastNonOptionStyle = cfgLast; + } +} +styleSel.value = style; + +const popupStates = { + split: { open: false, viewId: null }, + quad: { open: false, viewId: null }, +}; + +function getSlotsForStyle(viewId = style){ + const resolved = resolveStyleId(viewId); + return (slotsState[resolved] || VIEW_SLOT_DEFAULTS[resolved] || []).slice(); +} + +function getRequiredPlotSlots(viewId) { + const resolved = resolveStyleId(viewId); + if (resolved === 'peak-history' || resolved === 'classic-needles' || resolved === 'panel') { + const slots = getSlotsForStyle(resolved).filter((slotId) => slotId && slotId !== 'none'); + if (slots.length) return slots; + } + if (resolved === 'peak-history') return ['ppm-din']; + if (resolved === 'classic-needles') return ['vu']; + if (resolved === 'panel') { + return (VIEW_SLOT_DEFAULTS.panel || []).filter((slotId) => slotId && slotId !== 'none'); + } + return []; +} + +function getActiveMeterIds(viewId = style) { + const resolvedView = resolveStyleId(viewId); + const pushPlotRequiredMeters = (plotId, out) => { + if (!plotId || !out) return; + out.push(...getRequiredPlotSlots(plotId)); + }; + const pushViewSlotMeters = (viewId, out) => { + if (!viewId || !out) return; + const slots = getSlotsForStyle(viewId); + for (const s of (slots || [])) { + if (s && s !== 'none') out.push(s); + } + }; + if (resolvedView === 'split-view') { + const ids = []; + const count = Math.max(0, Math.min(3, Number(CONFIG.SPLIT_VIEW_METER_COUNT) || 0)); + const pushIf = (v) => { if (v && v !== 'none') ids.push(v); }; + if (count >= 1) pushIf(CONFIG.SPLIT_VIEW_METER_1); + if (count >= 2) pushIf(CONFIG.SPLIT_VIEW_METER_2); + if (count >= 3) pushIf(CONFIG.SPLIT_VIEW_METER_3); + pushPlotRequiredMeters(CONFIG.SPLIT_VIEW_LEFT, ids); + pushPlotRequiredMeters(CONFIG.SPLIT_VIEW_RIGHT, ids); + if (popupStates.split.open && popupStates.split.viewId) { + pushViewSlotMeters(popupStates.split.viewId, ids); + pushPlotRequiredMeters(popupStates.split.viewId, ids); + } + return Array.from(new Set(ids)); + } + if (resolvedView === 'quad-view') { + const ids = []; + const count = Math.max(0, Math.min(3, Number(CONFIG.QUAD_VIEW_METER_COUNT) || 0)); + const pushIf = (v) => { if (v && v !== 'none') ids.push(v); }; + if (count >= 1) pushIf(CONFIG.QUAD_VIEW_METER_1); + if (count >= 2) pushIf(CONFIG.QUAD_VIEW_METER_2); + if (count >= 3) pushIf(CONFIG.QUAD_VIEW_METER_3); + pushPlotRequiredMeters(CONFIG.QUAD_VIEW_TL, ids); + pushPlotRequiredMeters(CONFIG.QUAD_VIEW_TR, ids); + pushPlotRequiredMeters(CONFIG.QUAD_VIEW_BL, ids); + pushPlotRequiredMeters(CONFIG.QUAD_VIEW_BR, ids); + if (popupStates.quad.open && popupStates.quad.viewId) { + pushViewSlotMeters(popupStates.quad.viewId, ids); + pushPlotRequiredMeters(popupStates.quad.viewId, ids); + } + return Array.from(new Set(ids)); + } + const resolvedSlots = getSlotsForStyle(resolvedView); + return Array.from(new Set(resolvedSlots.filter((v) => v && v !== 'none'))); +} + +function getActivePlotIds(viewId = style) { + const resolvedView = resolveStyleId(viewId); + const ids = []; + const pushIf = (plotId) => { + const resolved = resolveStyleId(plotId); + if (resolved && resolved !== 'none') ids.push(resolved); + }; + if (resolvedView === 'split-view') { + pushIf(CONFIG.SPLIT_VIEW_LEFT); + pushIf(CONFIG.SPLIT_VIEW_RIGHT); + if (popupStates.split.open && popupStates.split.viewId) pushIf(popupStates.split.viewId); + return Array.from(new Set(ids)); + } + if (resolvedView === 'quad-view') { + pushIf(CONFIG.QUAD_VIEW_TL); + pushIf(CONFIG.QUAD_VIEW_TR); + pushIf(CONFIG.QUAD_VIEW_BL); + pushIf(CONFIG.QUAD_VIEW_BR); + if (popupStates.quad.open && popupStates.quad.viewId) pushIf(popupStates.quad.viewId); + return Array.from(new Set(ids)); + } + pushIf(resolvedView); + return Array.from(new Set(ids)); +} + +function buildProcessingProfile(viewId = style) { + const renderView = resolveStyleId(viewId); + const profile = { + needXy: false, + needRta: false, + needVu: false, + needPpmDin: false, + needPpmEbu: false, + needTp: false, + needRms: false, + needWaveform: false, + }; + + const plotIds = getActivePlotIds(renderView); + for (const plotId of plotIds) { + if (plotId === 'realtime') { + profile.needRta = true; + } else if (plotId === 'goniometer-rtw') { + profile.needXy = true; + profile.needRms = true; + } else if (plotId === 'phase-wheel') { + profile.needXy = true; + if ((CONFIG.PHASE_AMPLITUDE_MODE || 'bandpass') === 'ppm-din') { + profile.needPpmDin = true; + } + } else if (plotId === 'waveform') { + profile.needWaveform = true; + } else if (plotId === 'clock') { + const clockStyle = CONFIG.CLOCK_STYLE || 'analog'; + if (clockStyle === 'analog-ppm' || clockStyle === 'digital-ppm') { + profile.needPpmDin = true; + } + } + } + + const meterIds = getActiveMeterIds(renderView); + for (const meterId of meterIds) { + if (meterId === 'vu') profile.needVu = true; + else if (meterId === 'ppm-din') profile.needPpmDin = true; + else if (meterId === 'ppm-ebu') profile.needPpmEbu = true; + else if (meterId === 'tp') profile.needTp = true; + else if (meterId === 'hifi-peak') profile.needTp = true; + else if (meterId === 'rms') profile.needRms = true; + else if (meterId === 'lufs') profile.needTp = true; + } + + if (CONFIG.SCREENSAVER_ENABLED !== false) { + profile.needRms = true; + } + + if (recorderState.autoEnabled) { + profile.needRms = true; + } + + return profile; +} +function getVisibleSlotCount(viewId = style){ + const resolved = resolveStyleId(viewId); + return (resolved in VIEW_SLOT_COUNTS) ? VIEW_SLOT_COUNTS[resolved] : 1; +} +function setSlotForView(viewId, idx, value){ + const resolved = resolveStyleId(viewId); + if (idx >= getVisibleSlotCount(resolved)) return; + const valid = sanitizeSlotId(value); + if (!valid) return; + if (VIEWS_DISALLOW_EMPTY_SLOT.has(resolved) && valid === 'none') return; + const current = Array.isArray(slotsState[resolved]) ? slotsState[resolved].slice() : []; + current[idx] = valid; + slotsState[resolved] = normalizeSlots(resolved, current); + saveSlotState(slotsState); + + const customized = loadSlotCustomizedState(); + customized[resolved] = true; + saveSlotCustomizedState(customized); + + const configBackedKey = CONFIG_BACKED_GLOBAL_SLOTS[resolved]; + if (configBackedKey) { + CONFIG[configBackedKey] = slotsState[resolved]?.[0] || VIEW_SLOT_DEFAULTS[resolved]?.[0] || SLOT_FALLBACK_ID; + try { saveConfig?.(); } catch (_) {} + try { env?.audio?.updatePhoenixGlobalConfig?.(); } catch (_) {} + } +} + +function resetAllSlotsToDefaults() { + const next = {}; + for (const key of Object.keys(VIEW_SLOT_DEFAULTS)) { + next[key] = normalizeSlots(key, VIEW_SLOT_DEFAULTS[key]); + } + slotsState = next; + syncConfigBackedSlots(); + saveSlotState(slotsState); + saveSlotCustomizedState({}); + refreshSlotEditors(); +} +function initSlotSelect(sel, idx){ + if (!sel) return; + sel.dataset.slotIndex = String(idx); + sel.innerHTML = ''; + for (const [val,label] of SLOT_OPTIONS){ + const o = document.createElement('option'); + o.value = val; o.textContent = label; + sel.appendChild(o); + } + sel.onchange = () => { + if (isOptionsStyle(style)) return; + setSlotForView(style, idx, sel.value); + syncSlotSelectValues(style); + }; +} +slotSelects.forEach((sel, idx) => initSlotSelect(sel, idx)); + +if (phaseTrailChk) { + phaseTrailChk.addEventListener('change', () => { + CONFIG.PHASE_TRAIL_ENABLED = !!phaseTrailChk.checked; + saveConfig(); + }); +} +if (goniAgcChk) { + goniAgcChk.addEventListener('change', () => { + setGoniAgcEnabled(!!goniAgcChk.checked); + saveConfig(); + }); +} + +function syncSlotSelectValues(viewId = style){ + const resolved = resolveStyleId(viewId); + const slots = getSlotsForStyle(resolved); + const visibleCount = getVisibleSlotCount(resolved); + const disallowEmpty = VIEWS_DISALLOW_EMPTY_SLOT.has(resolved); + const tip = disallowEmpty + ? "Hinweis: Diese Anzeige ist vom Slot abhängig; '(leer)' ist hier nicht verfügbar." + : ''; + slotSelects.forEach((sel, idx) => { + if (!sel) return; + const show = idx < visibleCount; + sel.style.display = show ? 'inline-block' : 'none'; + if (show) { + sel.title = tip; + for (const opt of sel.options) { + if (opt && opt.value === 'none') { + opt.disabled = disallowEmpty; + opt.title = tip; + } + } + sel.value = slots[idx] || SLOT_FALLBACK_ID; + } else { + sel.title = ''; + } + }); +} + +function ensureNoEmptySlotsForView(viewId = style){ + const resolved = resolveStyleId(viewId); + if (!VIEWS_DISALLOW_EMPTY_SLOT.has(resolved)) return; + const current = Array.isArray(slotsState?.[resolved]) ? slotsState[resolved].slice() : []; + if (!current.includes('none')) return; + slotsState[resolved] = normalizeSlots(resolved, current); + saveSlotState(slotsState); +} + +function updateSlotLabel(viewId = style){ + if (!slotLabel) return; + const count = getVisibleSlotCount(viewId); + slotLabel.textContent = count === 1 ? 'Slot' : 'Slots'; +} +function refreshSlotEditors(){ + if (!slotWrap) return; + const isOptionsView = isOptionsStyle(style); + const isClockView = style === 'clock'; + const isSplitView = style === 'split-view'; + const isQuadView = style === 'quad-view'; + const visibleCount = getVisibleSlotCount(style); + + if (isOptionsView || isClockView) { + slotWrap.style.display = 'none'; + if (splitViewWrap) splitViewWrap.style.display = 'none'; + if (quadViewWrap) quadViewWrap.style.display = 'none'; + setSplitMeterPopupOpen(false); + setQuadPlotPopupOpen(false); + if (gainWrap) gainWrap.style.display = 'none'; + if (spectroLegendWrap) spectroLegendWrap.style.display = 'none'; + if (waveWindowWrap) waveWindowWrap.style.display = 'none'; + if (waveModeWrap) waveModeWrap.style.display = 'none'; + if (rtaBpoWrap) rtaBpoWrap.style.display = 'none'; + if (goniAgcToggle) goniAgcToggle.style.display = 'none'; + if (peakHistScrollWrap) peakHistScrollWrap.style.display = 'none'; + if (waveWindowSel) { + waveWindowSel.value = String(getNearestWaveWindow(CONFIG.WAVEFORM_WINDOW_SEC ?? 1)); + } + if (waveModeSel) { + waveModeSel.value = normalizeWaveMode(CONFIG.WAVEFORM_MODE); + } + if (rtaBpoSel) { + rtaBpoSel.value = normalizeRtaBpo(CONFIG.RTA_BPO_MODE); + } + if (recorderHud) recorderHud.style.display = 'none'; + return; + } + + if (isSplitView) { + slotWrap.style.display = 'none'; + if (quadViewWrap) quadViewWrap.style.display = 'none'; + setQuadPlotPopupOpen(false); + if (gainWrap) gainWrap.style.display = 'none'; + if (spectroLegendWrap) spectroLegendWrap.style.display = 'none'; + if (waveWindowWrap) waveWindowWrap.style.display = 'none'; + if (waveModeWrap) waveModeWrap.style.display = 'none'; + if (rtaBpoWrap) rtaBpoWrap.style.display = 'none'; + if (goniAgcToggle) goniAgcToggle.style.display = 'none'; + if (phaseTrailToggle) phaseTrailToggle.style.display = 'none'; + if (peakHistScrollWrap) peakHistScrollWrap.style.display = 'none'; + if (recorderHud) recorderHud.style.display = 'none'; + syncSplitViewControls(); + return; + } + if (isQuadView) { + slotWrap.style.display = 'none'; + if (splitViewWrap) splitViewWrap.style.display = 'none'; + if (gainWrap) gainWrap.style.display = 'none'; + if (spectroLegendWrap) spectroLegendWrap.style.display = 'none'; + if (waveWindowWrap) waveWindowWrap.style.display = 'none'; + if (waveModeWrap) waveModeWrap.style.display = 'none'; + if (rtaBpoWrap) rtaBpoWrap.style.display = 'none'; + if (goniAgcToggle) goniAgcToggle.style.display = 'none'; + if (phaseTrailToggle) phaseTrailToggle.style.display = 'none'; + if (peakHistScrollWrap) peakHistScrollWrap.style.display = 'none'; + if (recorderHud) recorderHud.style.display = 'none'; + syncQuadViewControls(); + return; + } + + if (splitViewWrap) splitViewWrap.style.display = 'none'; + if (quadViewWrap) quadViewWrap.style.display = 'none'; + setSplitMeterPopupOpen(false); + setQuadPlotPopupOpen(false); + const hideSlots = visibleCount === 0; + if (hideSlots) { + slotWrap.style.display = 'none'; + if (gainWrap) gainWrap.style.display = 'none'; + if (spectroLegendWrap) spectroLegendWrap.style.display = 'none'; + if (waveWindowWrap) waveWindowWrap.style.display = 'none'; + if (waveModeWrap) waveModeWrap.style.display = 'none'; + if (rtaBpoWrap) rtaBpoWrap.style.display = 'none'; + if (goniAgcToggle) goniAgcToggle.style.display = 'none'; + if (phaseTrailToggle) phaseTrailToggle.style.display = 'none'; + if (peakHistScrollWrap) peakHistScrollWrap.style.display = 'none'; + if (recorderHud) recorderHud.style.display = 'none'; + return; + } + + ensureNoEmptySlotsForView(style); + slotWrap.style.display = 'flex'; + if (recorderHud) recorderHud.style.display = (style === 'recorder') ? 'flex' : 'none'; + updateSlotLabel(style); + syncSlotSelectValues(style); + syncRealtimeGainControl(); + syncWaveWindowControl(); + syncWaveModeControl(); + syncRtaBpoControl(); + syncSpectrogramLegendVisibility(); + syncPhaseTrailToggle(); + syncGoniAgcToggle(); + syncPeakHistScrollControl(); +} + +const SPLIT_PLOT_OPTIONS = [ + ['none', '(leer)'], + ['phase-wheel', 'Phase Wheel'], + ['realtime', 'Real Time Analyzer'], + ['goniometer-rtw', 'XY-Display'], + ['peak-history', 'Peak History'], + ['classic-needles', 'Classic Needles'], + ['panel', 'Meters'], + ['clock', 'Studio Clock'], + ['waveform', 'Waveform'], + ['spectrogram', 'Spectrogram'], +]; +const SPLIT_PLOT_VALUE_SET = new Set(SPLIT_PLOT_OPTIONS.map(([id]) => id)); +function sanitizeSplitPlotId(id) { + return SPLIT_PLOT_VALUE_SET.has(id) ? id : null; +} +function initSplitPlotSelect(sel, onChange) { + if (!sel) return; + sel.innerHTML = ''; + for (const [val, label] of SPLIT_PLOT_OPTIONS) { + const o = document.createElement('option'); + o.value = val; + o.textContent = label; + sel.appendChild(o); + } + sel.addEventListener('change', () => onChange(sel.value)); +} +initSplitPlotSelect(splitLeftSel, (val) => { + const clean = sanitizeSplitPlotId(val) ?? 'none'; + CONFIG.SPLIT_VIEW_LEFT = clean; + saveConfig(); +}); +initSplitPlotSelect(splitRightSel, (val) => { + const clean = sanitizeSplitPlotId(val) ?? 'none'; + CONFIG.SPLIT_VIEW_RIGHT = clean; + saveConfig(); +}); + +function clampMeterCount3(v) { + const n = Number(v); + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.min(3, n | 0)); +} + +function initSplitMeterTypeSelect(sel, onChange) { + if (!sel) return; + sel.innerHTML = ''; + for (const [val, label] of SLOT_OPTIONS) { + const o = document.createElement('option'); + o.value = val; + o.textContent = label; + sel.appendChild(o); + } + sel.addEventListener('change', () => onChange(sel.value)); +} + +let meterPopupPrefix = 'SPLIT_VIEW'; +function getMeterKey(prefix, idx) { + const i = Math.max(1, Math.min(3, Number(idx) || 1)); + return `${prefix}_METER_${i}`; +} +function getMeterPosKey(prefix, idx) { + const i = Math.max(1, Math.min(3, Number(idx) || 1)); + return `${prefix}_METER_${i}_POS`; +} +function getMeterCountKey(prefix) { + return `${prefix}_METER_COUNT`; +} +function readMeterFromConfig(prefix, idx) { + const key = getMeterKey(prefix, idx); + return sanitizeSlotId(CONFIG?.[key]) ?? 'vu'; +} + +function writeMeterToConfig(prefix, idx, val) { + const key = getMeterKey(prefix, idx); + CONFIG[key] = sanitizeSlotId(val) ?? 'vu'; + saveConfig(); +} + +function setSplitMeterPopupOpen(open) { + if (!splitMeterPopup) return; + splitMeterPopup.style.display = open ? 'flex' : 'none'; +} + +function syncSplitMeterPopup() { + const meterCount = clampMeterCount3(CONFIG?.[getMeterCountKey(meterPopupPrefix)]); + if (splitMeterRow1) splitMeterRow1.style.display = meterCount >= 1 ? 'flex' : 'none'; + if (splitMeterRow2) splitMeterRow2.style.display = meterCount >= 2 ? 'flex' : 'none'; + if (splitMeterRow3) splitMeterRow3.style.display = meterCount >= 3 ? 'flex' : 'none'; + if (splitMeterSel1) splitMeterSel1.value = readMeterFromConfig(meterPopupPrefix, 1); + if (splitMeterSel2) splitMeterSel2.value = readMeterFromConfig(meterPopupPrefix, 2); + if (splitMeterSel3) splitMeterSel3.value = readMeterFromConfig(meterPopupPrefix, 3); +} + +initSplitMeterTypeSelect(splitMeterSel1, (val) => { + if (isOptionsStyle(style) || (style !== 'split-view' && style !== 'quad-view')) return; + writeMeterToConfig(meterPopupPrefix, 1, val); + syncSplitMeterPopup(); +}); +initSplitMeterTypeSelect(splitMeterSel2, (val) => { + if (isOptionsStyle(style) || (style !== 'split-view' && style !== 'quad-view')) return; + writeMeterToConfig(meterPopupPrefix, 2, val); + syncSplitMeterPopup(); +}); +initSplitMeterTypeSelect(splitMeterSel3, (val) => { + if (isOptionsStyle(style) || (style !== 'split-view' && style !== 'quad-view')) return; + writeMeterToConfig(meterPopupPrefix, 3, val); + syncSplitMeterPopup(); +}); + +function setQuadPlotPopupOpen(open) { + if (!quadPlotPopup) return; + quadPlotPopup.style.display = open ? 'flex' : 'none'; +} + +function syncQuadPlotPopup() { + if (quadPlotSelTL) quadPlotSelTL.value = sanitizeSplitPlotId(CONFIG.QUAD_VIEW_TL) ?? 'phase-wheel'; + if (quadPlotSelTR) quadPlotSelTR.value = sanitizeSplitPlotId(CONFIG.QUAD_VIEW_TR) ?? 'realtime'; + if (quadPlotSelBL) quadPlotSelBL.value = sanitizeSplitPlotId(CONFIG.QUAD_VIEW_BL) ?? 'goniometer-rtw'; + if (quadPlotSelBR) quadPlotSelBR.value = sanitizeSplitPlotId(CONFIG.QUAD_VIEW_BR) ?? 'none'; +} + +initSplitPlotSelect(quadPlotSelTL, (val) => { CONFIG.QUAD_VIEW_TL = sanitizeSplitPlotId(val) ?? 'phase-wheel'; saveConfig(); }); +initSplitPlotSelect(quadPlotSelTR, (val) => { CONFIG.QUAD_VIEW_TR = sanitizeSplitPlotId(val) ?? 'realtime'; saveConfig(); }); +initSplitPlotSelect(quadPlotSelBL, (val) => { CONFIG.QUAD_VIEW_BL = sanitizeSplitPlotId(val) ?? 'goniometer-rtw'; saveConfig(); }); +initSplitPlotSelect(quadPlotSelBR, (val) => { CONFIG.QUAD_VIEW_BR = sanitizeSplitPlotId(val) ?? 'none'; saveConfig(); }); + +if (splitMeterBtn) { + splitMeterBtn.addEventListener('click', () => { + if (style !== 'split-view' || isOptionsStyle(style)) return; + if (splitMeterPopupTitle) splitMeterPopupTitle.textContent = 'Split-View Slots'; + meterPopupPrefix = 'SPLIT_VIEW'; + syncSplitMeterPopup(); + setSplitMeterPopupOpen(true); + }); +} +if (quadMeterBtn) { + const openQuadSlots = (ev) => { + if (isOptionsStyle(style) || style !== 'quad-view') return; + try { ev?.preventDefault?.(); } catch (_) {} + if (splitMeterPopupTitle) splitMeterPopupTitle.textContent = 'Quad-View Slots'; + meterPopupPrefix = 'QUAD_VIEW'; + syncSplitMeterPopup(); + setSplitMeterPopupOpen(true); + }; + quadMeterBtn.addEventListener('click', openQuadSlots); +} +if (quadPlotBtn) { + const openQuadPlots = (ev) => { + if (isOptionsStyle(style) || style !== 'quad-view') return; + try { ev?.preventDefault?.(); } catch (_) {} + syncQuadPlotPopup(); + setQuadPlotPopupOpen(true); + }; + quadPlotBtn.addEventListener('click', openQuadPlots); +} +if (splitMeterPopupClose) { + splitMeterPopupClose.addEventListener('click', () => setSplitMeterPopupOpen(false)); +} +if (splitMeterPopup) { + splitMeterPopup.addEventListener('pointerdown', (e) => { + if (e?.target === splitMeterPopup) setSplitMeterPopupOpen(false); + }); + const box = splitMeterPopup.querySelector?.('.split-popup__box'); + box?.addEventListener?.('pointerdown', (e) => e.stopPropagation()); +} +if (quadPlotPopupClose) { + quadPlotPopupClose.addEventListener('click', () => setQuadPlotPopupOpen(false)); +} +if (quadPlotPopup) { + quadPlotPopup.addEventListener('pointerdown', (e) => { + if (e?.target === quadPlotPopup) setQuadPlotPopupOpen(false); + }); + const box = quadPlotPopup.querySelector?.('.split-popup__box'); + box?.addEventListener?.('pointerdown', (e) => e.stopPropagation()); +} +addEventListener('keydown', (e) => { + if (e.key !== 'Escape') return; + if (splitMeterPopup && splitMeterPopup.style.display !== 'none') setSplitMeterPopupOpen(false); + if (quadPlotPopup && quadPlotPopup.style.display !== 'none') setQuadPlotPopupOpen(false); + if (env?.splitPopup?.open) { + env.splitPopup.open = false; + env.splitPopup.viewId = null; + } + if (env?.quadPopup?.open) { + env.quadPopup.open = false; + env.quadPopup.viewId = null; + } +}); + +function syncSplitViewControls() { + if (!splitViewWrap || !splitLeftSel || !splitRightSel) return; + const show = style === 'split-view' && !isOptionsStyle(style); + splitViewWrap.style.display = show ? 'flex' : 'none'; + if (!show) return; + splitLeftSel.value = sanitizeSplitPlotId(CONFIG.SPLIT_VIEW_LEFT) ?? 'phase-wheel'; + splitRightSel.value = sanitizeSplitPlotId(CONFIG.SPLIT_VIEW_RIGHT) ?? 'realtime'; + + const meterCount = clampMeterCount3(CONFIG.SPLIT_VIEW_METER_COUNT); + const showMetersBtn = meterCount >= 1; + if (splitMeterBtnWrap) splitMeterBtnWrap.style.display = showMetersBtn ? 'inline-flex' : 'none'; + if (showMetersBtn) syncSplitMeterPopup(); + else setSplitMeterPopupOpen(false); +} +syncSplitViewControls(); + +function syncQuadViewControls() { + if (!quadViewWrap) return; + const show = style === 'quad-view' && !isOptionsStyle(style); + quadViewWrap.style.display = show ? 'flex' : 'none'; + if (!show) { + setQuadPlotPopupOpen(false); + return; + } + const meterCount = clampMeterCount3(CONFIG.QUAD_VIEW_METER_COUNT); + const showMetersBtn = meterCount >= 1; + if (quadMeterBtnWrap) quadMeterBtnWrap.style.display = showMetersBtn ? 'inline-flex' : 'none'; + if (!showMetersBtn) setSplitMeterPopupOpen(false); +} +syncQuadViewControls(); + +refreshSlotEditors(); + +function syncRealtimeGainControl(){ + const isRealtime = (style === 'realtime'); + const iirEngine = (CONFIG.RTA_ENGINE || 'fft') === 'iir'; + if (gainWrap && gainSel) { + const showGain = isRealtime && iirEngine; + gainWrap.style.display = showGain ? 'flex' : 'none'; + if (showGain) { + let val = Number(CONFIG.RTA_DISPLAY_GAIN_IIR_DB); + if (!REALTIME_GAIN_CHOICES.includes(val)) val = 0; + gainSel.value = String(val); + } + } + if (resetPeakBtn) { + const showBtn = isRealtime && (CONFIG.RTA_PEAK_HOLD_MODE === 'manual'); + resetPeakBtn.style.display = showBtn ? 'inline-flex' : 'none'; + } +} + +function syncSpectrogramLegendVisibility(){ + if (!spectroLegendWrap) return; + const show = (style === 'spectrogram') && !isOptionsStyle(style); + spectroLegendWrap.style.display = show ? 'flex' : 'none'; +} + +function syncWaveWindowControl(){ + if (!waveWindowWrap || !waveWindowSel) return; + const show = (style === 'waveform') && !isOptionsStyle(style); + waveWindowWrap.style.display = show ? 'flex' : 'none'; + const val = getNearestWaveWindow(CONFIG.WAVEFORM_WINDOW_SEC ?? 1); + waveWindowSel.value = String(val); +} + +function syncWaveModeControl(){ + if (!waveModeWrap || !waveModeSel) return; + const show = (style === 'waveform') && !isOptionsStyle(style); + waveModeWrap.style.display = show ? 'flex' : 'none'; + waveModeSel.value = normalizeWaveMode(CONFIG.WAVEFORM_MODE); +} + +function syncPeakHistScrollControl(){ + if (!peakHistScrollWrap || !peakHistScrollSel) return; + const show = (style === 'peak-history') && !isOptionsStyle(style); + peakHistScrollWrap.style.display = show ? 'flex' : 'none'; + if (show) { + const val = normalizePeakHistScroll(CONFIG.PEAK_HISTORY_SCROLL_MODE); + peakHistScrollSel.value = String(val); + } +} + +function hasActivePhaseWheelPlot() { + if (isOptionsStyle(style)) return false; + return getActivePlotIds(getRenderableStyle()).includes('phase-wheel'); +} + +function syncPhaseTrailToggle(){ + if (!phaseTrailToggle || !phaseTrailChk) return; + const show = hasActivePhaseWheelPlot(); + phaseTrailToggle.style.display = show ? 'inline-flex' : 'none'; + if (show) { + phaseTrailChk.checked = !!CONFIG.PHASE_TRAIL_ENABLED; + } +} + +function syncRtaBpoControl(){ + if (!rtaBpoWrap || !rtaBpoSel) return; + const show = (style === 'realtime') && !isOptionsStyle(style); + rtaBpoWrap.style.display = show ? 'flex' : 'none'; + rtaBpoSel.value = normalizeRtaBpo(CONFIG.RTA_BPO_MODE); +} + +function syncGoniAgcToggle(){ + if (!goniAgcToggle || !goniAgcChk) return; + const show = style === 'goniometer-rtw' && !isOptionsStyle(style); + goniAgcToggle.style.display = show ? 'inline-flex' : 'none'; + if (show) { + goniAgcChk.checked = !!CONFIG.GONIO_AGC_ENABLED; + } +} + +function setGoniAgcEnabled(enabled){ + const targetState = !!enabled; + const wasEnabled = !!CONFIG.GONIO_AGC_ENABLED; + const gainSliderEl = document.getElementById('opt_goniGain'); + const gainLabel = document.getElementById('val_goniGain'); + const optionsToggle = document.getElementById('opt_goniAgc'); + const hudToggle = document.getElementById('goniAgcChk'); + const sliderStored = gainSliderEl && gainSliderEl.dataset && typeof gainSliderEl.dataset.lastManualGain === 'string' + ? sanitizeGoniGain(gainSliderEl.dataset.lastManualGain) + : null; + const sliderVal = (!wasEnabled && gainSliderEl) + ? sanitizeGoniGain(gainSliderEl.value) + : null; + let manual = sanitizeGoniGain(CONFIG.GONIO_MANUAL_GAIN_DB ?? sliderStored ?? CONFIG.GONIO_DISPLAY_GAIN_DB); + if (targetState) { + if (sliderVal !== null) manual = sliderVal; + else if (sliderStored !== null) manual = sliderStored; + CONFIG.GONIO_MANUAL_GAIN_DB = manual; + CONFIG.GONIO_DISPLAY_GAIN_DB = 0; + } else { + const restore = sanitizeGoniGain(CONFIG.GONIO_MANUAL_GAIN_DB ?? sliderStored ?? sliderVal ?? CONFIG.GONIO_DISPLAY_GAIN_DB ?? manual); + CONFIG.GONIO_MANUAL_GAIN_DB = restore; + CONFIG.GONIO_DISPLAY_GAIN_DB = restore; + manual = restore; + } + CONFIG.GONIO_AGC_ENABLED = targetState; + if (gainSliderEl) { + gainSliderEl.disabled = targetState; + gainSliderEl.value = targetState ? '0' : String(manual); + gainSliderEl.dataset.lastManualGain = targetState ? String(manual) : String(manual); + } + if (gainLabel) { + const labelVal = targetState ? 0 : manual; + gainLabel.textContent = `${labelVal} dB`; + } + if (optionsToggle && optionsToggle.checked !== targetState) { + optionsToggle.checked = targetState; + } + if (hudToggle && hudToggle.checked !== targetState) { + hudToggle.checked = targetState; + } +} + +function applyStyleSelection(nextStyle, opts = {}) { + const persistStorage = opts.persistStorage !== false; + const persistConfig = opts.persistConfig !== false; + const targetStyle = VALID_STYLES.has(nextStyle) ? nextStyle : DEFAULT_STYLE; + if (targetStyle === 'clock' && style !== 'clock') { + lastViewBeforeClock = getRenderableStyle(); + } + if (style === 'recorder' && targetStyle !== 'recorder' && recorderState.autoEnabled) { + setRecorderAutoEnabled(false); + } + style = targetStyle; + if (styleSel && styleSel.value !== targetStyle) { + styleSel.value = targetStyle; + } + if (persistStorage) { + setPersistentItem(STYLE_STORAGE_KEY, style); + } + adjustStyleDropdownWidth(); + refreshSlotEditors(); + toggleOptionsPanels(); + if (!isOptionsStyle(style)) { + lastNonOptionStyle = style; + if (persistStorage) { + try { setPersistentItem(LAST_NON_OPTION_STYLE_KEY, style); } catch (_) {} + } + setView(style); + } else { + const renderStyle = getRenderableStyle(); + if (!currentView || currentViewId !== renderStyle) { + setView(renderStyle); + } + } + if (persistConfig) { + persistStyleToConfig(style); + } +} + +styleSel.onchange = () => { + applyStyleSelection(styleSel.value); +}; + +import * as viewClock from './views/clock.js'; + +// --- Register Meters --- +registerMeter(vu); +registerMeter(ppmEbu); +registerMeter(ppmDin); +registerMeter(tp); +registerMeter(hifiPeak); +registerMeter(rms); +registerMeter(lufs); +registerMeter(stopwatch); + +const screensaver = createScreensaver({ config: CONFIG, body: document.body }); +screensaver.setEnabled(CONFIG.SCREENSAVER_ENABLED !== false); + +// --- Options UI -------------------------------------------------------------- +const opts = setupOptions({ + audioReload: async () => { await bootAudio(true); }, + resetSlotsToDefaults: () => resetAllSlotsToDefaults(), + invalidateMeters: () => { + if (typeof meterFacade.invalidateAll === 'function') { + meterFacade.invalidateAll(); + } + }, + notifyRtaConfig: () => { + try { env.audio.updateRtaConfig?.(); } catch (_) {} + }, + notifyPhoenixGlobalConfig: () => { + try { env.audio.updatePhoenixGlobalConfig?.(); } catch (_) {} + }, + notifyPpmConfig: () => { + try { env.audio.updatePpmConfig?.(); } catch (_) {} + }, + syncRealtimeGain: () => { + syncRealtimeGainControl(); + }, + updateInputOffset: () => { + try { env.audio.updateInputOffset?.(); } catch (_) {} + }, + updateMonoInput: () => { + try { env.audio.updateMonoMode?.(); } catch (_) {} + }, + syncWaveformWindow: () => { + syncWaveWindowControl(); + }, + syncWaveformMode: () => { + syncWaveModeControl(); + }, + syncRtaBpo: () => { + syncRtaBpoControl(); + }, + syncSplitView: () => { + syncSplitViewControls(); + syncQuadViewControls(); + }, + buildPresetSnapshot: () => { + const snapshot = JSON.parse(JSON.stringify(CONFIG)); + delete snapshot.Y_TICKS; + delete snapshot.INPUT_OFFSET_GAIN_L; + delete snapshot.INPUT_OFFSET_GAIN_R; + snapshot.__PHOENIX_UI_STATE = buildPresetUiState(); + return snapshot; + }, + buildPresetUiState: () => { + return buildPresetUiState(); + }, + applyPresetUiState: (uiState) => { + applyPresetUiState(uiState); + }, + switchView: (id) => { + const target = VALID_STYLES.has(id) ? id : DEFAULT_STYLE; + if (styleSel) { + styleSel.value = target; + styleSel.dispatchEvent(new Event('change')); + } else { + style = target; + setView(target); + requestRender('preset-switch-view'); + } + }, + requestRender: () => { + requestRender('preset-refresh'); + }, + updateLrDelay: () => { + try { env.audio.updateLrDelay?.(); } catch (_) {} + }, + screensaver, +}); +toggleOptionsPanels(); +adjustStyleDropdownWidth(); +maybeShowCalibrationNotice(); + +function buildPresetUiState() { + const slots = {}; + for (const key of Object.keys(VIEW_SLOT_DEFAULTS)) { + slots[key] = normalizeSlots(key, slotsState?.[key]); + } + return { + style: VALID_STYLES.has(style) ? style : DEFAULT_STYLE, + lastNonOptionStyle: (VALID_STYLES.has(lastNonOptionStyle) && !isOptionsStyle(lastNonOptionStyle)) ? lastNonOptionStyle : DEFAULT_STYLE, + slots, + slotCustomized: loadSlotCustomizedState(), + }; +} + +function applyPresetUiState(uiState = null) { + if (!uiState || typeof uiState !== 'object' || Array.isArray(uiState)) return; + + if (uiState.slots && typeof uiState.slots === 'object' && !Array.isArray(uiState.slots)) { + const next = {}; + for (const key of Object.keys(VIEW_SLOT_DEFAULTS)) { + next[key] = normalizeSlots(key, uiState.slots[key]); + } + slotsState = next; + saveSlotState(slotsState); + } + syncConfigBackedSlots(); + + const customized = (uiState.slotCustomized && typeof uiState.slotCustomized === 'object' && !Array.isArray(uiState.slotCustomized)) + ? uiState.slotCustomized + : {}; + saveSlotCustomizedState(customized); + + const savedLast = String(uiState.lastNonOptionStyle || '').trim(); + if (savedLast && VALID_STYLES.has(savedLast) && !isOptionsStyle(savedLast)) { + lastNonOptionStyle = savedLast; + try { setPersistentItem(LAST_NON_OPTION_STYLE_KEY, savedLast); } catch (_) {} + } + + const savedStyle = String(uiState.style || '').trim(); + if (savedStyle && VALID_STYLES.has(savedStyle)) { + try { setPersistentItem(STYLE_STORAGE_KEY, savedStyle); } catch (_) {} + } + + refreshSlotEditors(); +} + +function adjustStyleDropdownWidth(){ + if (!styleSel) return; + const opt = styleSel.options[styleSel.selectedIndex]; + const rawText = (opt ? opt.textContent : '') || ''; + const targetText = (rawText.trim() === 'Meters') ? rawText : 'Real Time Analyzer'; + const computed = window.getComputedStyle(styleSel); + const font = computed.font || '16px ui-monospace, monospace'; + const paddingLeft = parseFloat(computed.paddingLeft) || 0; + const paddingRight = parseFloat(computed.paddingRight) || 0; + const arrowAllowance = 24; + if (!adjustStyleDropdownWidth.ctx) { + adjustStyleDropdownWidth.canvas = document.createElement('canvas'); + adjustStyleDropdownWidth.ctx = adjustStyleDropdownWidth.canvas.getContext('2d'); + } + const ctx = adjustStyleDropdownWidth.ctx; + ctx.font = font; + const measured = ctx.measureText(targetText).width; + const width = Math.ceil(measured + paddingLeft + paddingRight + arrowAllowance); + styleSel.style.width = width + 'px'; +} +adjustStyleDropdownWidth.canvas = null; +adjustStyleDropdownWidth.ctx = null; + +function isOptionsStyle(val) { + return val === 'options-panel'; +} + +function getRenderableStyle() { + return isOptionsStyle(style) ? (lastNonOptionStyle || DEFAULT_STYLE) : style; +} + +let persistStyleFollowupTimer = 0; +function persistStyleToConfig(currentStyle) { + CONFIG.UI_LAST_STYLE = currentStyle; + if (!isOptionsStyle(currentStyle)) { + CONFIG.UI_LAST_NON_OPTION_STYLE = currentStyle; + } + // Sofort speichern (damit ein schneller Neustart nicht 3–5s Wartezeit braucht). + try { saveConfig(); } catch (_) {} + // Best effort: einmal kurz danach nochmal speichern. + if (persistStyleFollowupTimer) { + try { clearTimeout(persistStyleFollowupTimer); } catch (_) {} + } + persistStyleFollowupTimer = setTimeout(() => { + persistStyleFollowupTimer = 0; + if (style !== currentStyle) return; + CONFIG.UI_LAST_STYLE = currentStyle; + if (!isOptionsStyle(currentStyle)) CONFIG.UI_LAST_NON_OPTION_STYLE = currentStyle; + try { saveConfig(); } catch (_) {} + }, 800); +} + +function toggleOptionsPanels() { + if (!optionsPanelNew) return; + const showNewPanel = isOptionsStyle(style); + optionsPanelNew.style.display = showNewPanel ? 'block' : 'none'; + if (showNewPanel) collapseNewOptionsSections(); +} + +function collapseNewOptionsSections() { + if (!optionsPanelNew) return; + const detailsNodes = optionsPanelNew.querySelectorAll('details'); + detailsNodes.forEach((node) => { + node.open = false; + }); +} + +// --- Views-Tabelle ----------------------------------------------------------- +const VIEWS = { + 'realtime': viewRealtime, + 'split-view': viewSplit, + 'quad-view': viewQuad, + 'classic-needles': viewClassicNeedles, + 'peak-history': viewPeakHistory, + 'goniometer-rtw': viewGoni, + 'phase-wheel': viewPhaseWheel, + 'panel': viewPanel, + 'spectrogram': viewSpectrogram, + 'waveform': viewWaveform, + 'recorder': viewRecorder, + 'clock': viewClock, +}; +let currentView = null; +let viewState = null; +let currentViewId = null; +let frameCounter = 0; +let lastViewBeforeClock = null; + +function markCalibrationNoticeSeen() { + calibrationNoticeSeen = true; + try { localStorage.setItem(CALIBRATION_NOTICE_KEY, '1'); } catch (_) {} + try { sessionStorage.setItem(CALIBRATION_NOTICE_KEY, '1'); } catch (_) {} +} + +function hasSeenCalibrationNotice() { + if (calibrationNoticeSeen) return true; + let seen = false; + try { seen = localStorage.getItem(CALIBRATION_NOTICE_KEY) === '1'; } catch (_) {} + if (!seen) { + try { seen = sessionStorage.getItem(CALIBRATION_NOTICE_KEY) === '1'; } catch (_) {} + } + calibrationNoticeSeen = seen; + return seen; +} + +function maybeShowCalibrationNotice() { + if (!calibrationNotice || !calibrationNoticeBtn) return; + if (hasSeenCalibrationNotice()) return; + const close = () => { + calibrationNotice.style.display = 'none'; + markCalibrationNoticeSeen(); + calibrationNoticeBtn.removeEventListener('click', close); + calibrationNotice.removeEventListener('click', onBackdrop); + }; + const onBackdrop = (ev) => { + if (ev.target === calibrationNotice) close(); + }; + calibrationNoticeBtn.addEventListener('click', close); + calibrationNotice.addEventListener('click', onBackdrop); + calibrationNotice.style.display = 'flex'; +} + +function setView(id){ + const fallbackId = DEFAULT_STYLE; + const targetId = (id && VIEWS[id]) ? id : fallbackId; + if (targetId !== 'split-view' && env?.splitPopup) { + env.splitPopup.open = false; + env.splitPopup.viewId = null; + } + if (targetId !== 'quad-view' && env?.quadPopup) { + env.quadPopup.open = false; + env.quadPopup.viewId = null; + } + if (targetId !== 'clock' && currentViewId === 'clock') { + lastViewBeforeClock = null; + } + if (currentView && currentView.destroy){ + try { currentView.destroy(viewState); } catch(e){ console.warn('View destroy error:', e); } + } + currentView = VIEWS[targetId] || viewGoni; + currentViewId = targetId; + viewState = currentView.init ? currentView.init(env) : {}; + if (currentView.resize){ + try { currentView.resize({ rect: env.rect }, viewState); } + catch(e){ console.warn('View resize error:', e); } + } + maybeShowRecorderWarning(targetId); + requestRender('set-view'); +} + +function maybeShowRecorderWarning(targetId) { + if (targetId !== 'recorder') return; + if (!recWarning || !recWarningAck || !recWarningBtn) return; + let seen = false; + try { seen = localStorage.getItem(REC_WARNING_KEY) === '1'; } catch (_) {} + if (seen) return; + bindRecorderWarningHandlers(); + recWarningAck.checked = false; + recWarningBtn.disabled = true; + recWarning.style.display = 'flex'; +} + +function bindRecorderWarningHandlers() { + if (!recWarningAck || !recWarningBtn || recWarningBtn.dataset.bound === '1') return; + const syncBtn = () => { + recWarningBtn.disabled = !recWarningAck.checked; + }; + recWarningAck.addEventListener('change', syncBtn); + recWarningBtn.addEventListener('click', () => { + if (!recWarningAck.checked) return; + try { localStorage.setItem(REC_WARNING_KEY, '1'); } catch (_) {} + recWarning.style.display = 'none'; + }); + syncBtn(); + recWarningBtn.dataset.bound = '1'; +} + +// --- Env --------------------------------------------------------------------- +const audioState = { + alive: false, + backendMode: 'phoenix', + backendLabel: 'Phoenix', + baseUrl: null, + nyq: 24000, + sampleRate: 48000, + getAnalyser: null, + getFreqBuffer: null, + getRtaData: () => audioState.rtaData, + lastSampleTs: 0, + lastSignalTs: (typeof performance !== 'undefined' ? performance.now() : Date.now()), + xyL: null, + xyR: null, + xySeq: 0, + rtaData: null, + rmsDb: { L: -120, R: -120, mono: -120 }, + updateRtaConfig: null, + updatePpmConfig: null, + updateInputOffset: null, + updateMonoMode: null, + updateLrDelay: null, + updatePhoenixGlobalConfig: null, +}; + +const env = { + ctx: g, + rect: { x:0, y:0, w:C.clientWidth, h:C.clientHeight }, + config: CONFIG, + utils: Object.assign({}, utils, { showErr, hideErr }), + audio: audioState, + screensaverWakeTs: 0, + slots: (viewId) => getSlotsForStyle(viewId), + getActiveMeterIds: () => getActiveMeterIds(getRenderableStyle()), + getActivePlotIds: (viewId) => getActivePlotIds(viewId), + getProcessingProfile: () => buildProcessingProfile(getRenderableStyle()), + meters: null, + invalidateMeters: () => { try { meterFacade.invalidateAll?.(); } catch (_) {} }, + requestRender: () => {}, + ui: { showErr, hideErr }, + syncOptionsUI: () => { try { opts.syncUI?.(); } catch (_) {} }, + recorder: recorderState, + recorderDom: { hud: recorderHud, label: recLabel, start: recStartBtn, stop: recStopBtn, auto: recAutoBtn, list: recList, timer: recTimer }, + screensaver, + resetSlotsToDefaults: () => resetAllSlotsToDefaults(), + syncConfigBackedSlots: () => { + syncConfigBackedSlots(); + refreshSlotEditors(); + }, + buildPresetUiState: () => buildPresetUiState(), + splitPopup: popupStates.split, + quadPopup: popupStates.quad, + switchView: (id) => { + const target = VALID_STYLES.has(id) ? id : DEFAULT_STYLE; + if (styleSel) { + styleSel.value = target; + styleSel.dispatchEvent(new Event('change')); + } else { + style = target; + setView(target); + requestRender('switch-view'); + } + }, +}; + +// Meter pointer routing (für interaktive Slot-Meter wie "Stoppuhr") +const meterHitRects = []; +const meters = { + update: (...args) => meterFacade.update(...args), + draw: async (ctx, rect, id, config) => { + if (rect && id) meterHitRects.push({ rect: { ...rect }, id }); + return meterFacade.draw(ctx, rect, id, config); + }, + pointer: (...args) => meterFacade.pointer?.(...args), + getState: (...args) => meterFacade.getState?.(...args), +}; +env.meters = meters; + +C.addEventListener('pointerdown', async (e) => { + if (!meterHitRects.length) return; + const r = C.getBoundingClientRect(); + const x = e.clientX - r.left; + const y = e.clientY - r.top; + if (!Number.isFinite(x) || !Number.isFinite(y)) return; + + for (let i = meterHitRects.length - 1; i >= 0; i--) { + const hit = meterHitRects[i]; + const rect = hit.rect; + if (!rect) continue; + if (x < rect.x || x > rect.x + rect.w || y < rect.y || y > rect.y + rect.h) continue; + const handled = await meterFacade.pointer?.( + { type: 'pointerdown', x, y, button: e.button }, + rect, + hit.id, + CONFIG, + ); + if (handled) { + try { e.preventDefault(); } catch (_) {} + try { C.setPointerCapture?.(e.pointerId); } catch (_) {} + C.__meterPointerCapture = { id: hit.id, rect, pointerId: e.pointerId }; + } + return; + } +}, { passive: false }); + +C.addEventListener('pointermove', async (e) => { + const cap = C.__meterPointerCapture; + if (!cap) return; + const r = C.getBoundingClientRect(); + const x = e.clientX - r.left; + const y = e.clientY - r.top; + if (!Number.isFinite(x) || !Number.isFinite(y)) return; + const handled = await meterFacade.pointer?.( + { type: 'pointermove', x, y, pointerId: e.pointerId }, + cap.rect, + cap.id, + CONFIG, + ); + if (handled) { + try { e.preventDefault(); } catch (_) {} + } +}, { passive: false }); + +const clearMeterPointerCapture = (e) => { + const cap = C.__meterPointerCapture; + if (!cap) return; + const r = C.getBoundingClientRect(); + const x = e && Number.isFinite(e.clientX) ? (e.clientX - r.left) : NaN; + const y = e && Number.isFinite(e.clientY) ? (e.clientY - r.top) : NaN; + meterFacade.pointer?.( + { type: e?.type === 'pointercancel' ? 'pointercancel' : 'pointerup', x, y, pointerId: e?.pointerId }, + cap.rect, + cap.id, + CONFIG, + ); + try { C.releasePointerCapture?.(cap.pointerId); } catch (_) {} + C.__meterPointerCapture = null; +}; + +C.addEventListener('pointerup', clearMeterPointerCapture, { passive: true }); +C.addEventListener('pointercancel', clearMeterPointerCapture, { passive: true }); + +// Quad-View: Tap/Click in Quadrant -> in Single-View wechseln +let quadTap = null; +function pointInRect(x, y, r) { + if (!r) return false; + return x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h; +} +function isInAnyMeterHitRect(x, y) { + for (const hit of meterHitRects) { + const r = hit?.rect; + if (r && pointInRect(x, y, r)) return true; + } + return false; +} + +// Split-View: Tap/Click in Linker/Rechter Plot -> in Single-View wechseln / Popup +let splitTap = null; +function handleSplitTapUp(x, y) { + if (document?.body?.classList?.contains('screensaver-active')) return; + if (isOptionsStyle(style) || currentViewId !== 'split-view') return; + const hit = viewState?._splitHit; + if (!hit) return; + if (isInAnyMeterHitRect(x, y)) return; + for (const mr of hit.meters || []) { + if (pointInRect(x, y, mr)) return; + } + const plots = hit.plots; + const left = plots?.left; + const right = plots?.right; + const target = + (left && pointInRect(x, y, left)) ? left.viewId + : ((right && pointInRect(x, y, right)) ? right.viewId : null); + if (!target || target === 'none') return; + + const action = (CONFIG.SPLIT_VIEW_TAP_ACTION === 'popup') ? 'popup' : 'switch'; + if (action === 'popup') { + if (env?.splitPopup?.open) { + env.splitPopup.open = false; + env.splitPopup.viewId = null; + return; + } + env.splitPopup.open = true; + env.splitPopup.viewId = target; + return; + } + env.switchView?.(target); +} +function handleQuadTapUp(x, y) { + if (document?.body?.classList?.contains('screensaver-active')) return; + if (isOptionsStyle(style) || currentViewId !== 'quad-view') return; + const hit = viewState?._quadHit; + if (!hit) return; + if (y < (hit.contentY ?? 0)) return; + const action = (CONFIG.QUAD_VIEW_TAP_ACTION === 'popup') ? 'popup' : 'switch'; + if (action === 'popup') { + if (env?.quadPopup?.open && hit.popupBox) { + // Popup ist offen: Tap innerhalb/außerhalb schließt + env.quadPopup.open = false; + env.quadPopup.viewId = null; + return; + } + if (isInAnyMeterHitRect(x, y)) return; + for (const mr of hit.meters || []) { + if (pointInRect(x, y, mr)) return; + } + const q = (hit.quadrants || []).find((it) => pointInRect(x, y, it?.rect)); + const target = q?.viewId; + if (!target || target === 'none') return; + if (env?.quadPopup) { + env.quadPopup.open = true; + env.quadPopup.viewId = target; + } + return; + } + + // Default: direkt wechseln + if (isInAnyMeterHitRect(x, y)) return; + for (const mr of hit.meters || []) { + if (pointInRect(x, y, mr)) return; + } + const q = (hit.quadrants || []).find((it) => pointInRect(x, y, it?.rect)); + const target = q?.viewId; + if (!target || target === 'none') return; + env.switchView?.(target); +} + +C.addEventListener('pointerdown', (e) => { + // Popup-Modus: Klick schließt sofort (Capture, damit Meter/Views nicht reagieren) + if (document?.body?.classList?.contains('screensaver-active')) return; + if (isOptionsStyle(style) || currentViewId !== 'split-view') return; + if (CONFIG.SPLIT_VIEW_TAP_ACTION !== 'popup') return; + if (!env?.splitPopup?.open) return; + env.splitPopup.open = false; + env.splitPopup.viewId = null; + try { e.preventDefault?.(); } catch (_) {} + try { e.stopPropagation?.(); } catch (_) {} +}, { capture: true, passive: false }); + +C.addEventListener('pointerdown', (e) => { + if (isOptionsStyle(style) || currentViewId !== 'split-view') return; + if (CONFIG.SPLIT_VIEW_TAP_ACTION === 'popup' && env?.splitPopup?.open) return; + const r = C.getBoundingClientRect(); + const x = e.clientX - r.left; + const y = e.clientY - r.top; + if (!Number.isFinite(x) || !Number.isFinite(y)) return; + if (isInAnyMeterHitRect(x, y)) return; + splitTap = { pointerId: e.pointerId, x0: x, y0: y, maxD2: 0, t0: performance.now() }; +}, { passive: true }); + +C.addEventListener('pointermove', (e) => { + if (!splitTap || splitTap.pointerId !== e.pointerId) return; + const r = C.getBoundingClientRect(); + const x = e.clientX - r.left; + const y = e.clientY - r.top; + if (!Number.isFinite(x) || !Number.isFinite(y)) return; + const dx = x - splitTap.x0; + const dy = y - splitTap.y0; + splitTap.maxD2 = Math.max(splitTap.maxD2, dx * dx + dy * dy); +}, { passive: true }); + +C.addEventListener('pointerup', (e) => { + if (!splitTap || splitTap.pointerId !== e.pointerId) return; + const r = C.getBoundingClientRect(); + const x = e.clientX - r.left; + const y = e.clientY - r.top; + const dt = performance.now() - splitTap.t0; + const moved = splitTap.maxD2 > (10 * 10); + splitTap = null; + if (!Number.isFinite(x) || !Number.isFinite(y)) return; + if (moved || dt > 650) return; + handleSplitTapUp(x, y); +}, { passive: true }); + +C.addEventListener('pointerdown', (e) => { + // Popup-Modus: Klick außerhalb schließt sofort (Capture, damit Meter/Quad nicht reagieren) + if (document?.body?.classList?.contains('screensaver-active')) return; + if (isOptionsStyle(style) || currentViewId !== 'quad-view') return; + if (CONFIG.QUAD_VIEW_TAP_ACTION !== 'popup') return; + if (!env?.quadPopup?.open) return; + const hit = viewState?._quadHit; + const box = hit?.popupBox; + if (!box) return; + const r = C.getBoundingClientRect(); + const x = e.clientX - r.left; + const y = e.clientY - r.top; + if (!Number.isFinite(x) || !Number.isFinite(y)) return; + if (y < (hit.contentY ?? 0)) return; + if (pointInRect(x, y, box)) return; + env.quadPopup.open = false; + env.quadPopup.viewId = null; + try { e.preventDefault?.(); } catch (_) {} + try { e.stopPropagation?.(); } catch (_) {} +}, { capture: true, passive: false }); + +C.addEventListener('pointerdown', (e) => { + if (isOptionsStyle(style) || currentViewId !== 'quad-view') return; + const r = C.getBoundingClientRect(); + const x = e.clientX - r.left; + const y = e.clientY - r.top; + if (!Number.isFinite(x) || !Number.isFinite(y)) return; + if (isInAnyMeterHitRect(x, y)) return; + quadTap = { pointerId: e.pointerId, x0: x, y0: y, maxD2: 0, t0: performance.now() }; +}, { passive: true }); + +C.addEventListener('pointermove', (e) => { + if (!quadTap || quadTap.pointerId !== e.pointerId) return; + const r = C.getBoundingClientRect(); + const x = e.clientX - r.left; + const y = e.clientY - r.top; + if (!Number.isFinite(x) || !Number.isFinite(y)) return; + const dx = x - quadTap.x0; + const dy = y - quadTap.y0; + quadTap.maxD2 = Math.max(quadTap.maxD2, dx * dx + dy * dy); +}, { passive: true }); + +C.addEventListener('pointerup', (e) => { + if (!quadTap || quadTap.pointerId !== e.pointerId) return; + const r = C.getBoundingClientRect(); + const x = e.clientX - r.left; + const y = e.clientY - r.top; + const dt = performance.now() - quadTap.t0; + const moved = quadTap.maxD2 > (10 * 10); + quadTap = null; + if (!Number.isFinite(x) || !Number.isFinite(y)) return; + if (moved || dt > 650) return; + handleQuadTapUp(x, y); +}, { passive: true }); + +C.addEventListener('wheel', async (e) => { + if (!meterHitRects.length) return; + const r = C.getBoundingClientRect(); + const x = e.clientX - r.left; + const y = e.clientY - r.top; + if (!Number.isFinite(x) || !Number.isFinite(y)) return; + for (let i = meterHitRects.length - 1; i >= 0; i--) { + const hit = meterHitRects[i]; + const rect = hit.rect; + if (!rect) continue; + if (x < rect.x || x > rect.x + rect.w || y < rect.y || y > rect.y + rect.h) continue; + const handled = await meterFacade.pointer?.( + { type: 'wheel', x, y, deltaY: e.deltaY, deltaX: e.deltaX }, + rect, + hit.id, + CONFIG, + ); + if (handled) { + try { e.preventDefault(); } catch (_) {} + } + return; + } +}, { passive: false }); + +['mousemove','keydown','click','touchstart'].forEach((ev) => { + addEventListener(ev, () => { + screensaver?.markActivity?.(); + void maybeRecoverAudio(`activity:${ev}`); + }, { passive: true }); +}); + +if (gainSel) { + gainSel.addEventListener('change', () => { + const raw = Number(gainSel.value); + const val = Number.isFinite(raw) ? raw : 0; + handleRealtimeGainSelection(val); + }); +} +if (peakHistScrollSel) { + peakHistScrollSel.addEventListener('change', () => { + handlePeakHistScrollSelection(peakHistScrollSel.value); + }); +} +if (resetPeakBtn) { + resetPeakBtn.addEventListener('click', () => { + try { + if (typeof window.resetRealTimeAnalyzerPeakHold === 'function') { + window.resetRealTimeAnalyzerPeakHold(); + } + } catch (_) {} + }); +} +if (waveWindowSel) { + waveWindowSel.addEventListener('change', () => { + handleWaveWindowSelection(Number(waveWindowSel.value)); + }); +} +if (waveModeSel) { + waveModeSel.addEventListener('change', () => { + handleWaveModeSelection(waveModeSel.value); + }); +} +if (rtaBpoSel) { + rtaBpoSel.addEventListener('change', () => { + handleRtaBpoSelection(rtaBpoSel.value); + }); +} +if (recTargetSel) { + recTargetSel.addEventListener('change', () => { + recorderState.target = CONFIG.RECORD_TARGET || ((recTargetSel.value === 'analyzer' || recTargetSel.value === 'volumio') ? recTargetSel.value : 'download'); + CONFIG.RECORD_TARGET = recorderState.target; + if (recorderState.target === 'analyzer' || recorderState.target === 'volumio') { + CONFIG.RECORD_PI_TARGET = recorderState.target; + } + try { saveConfig(); } catch (_) {} + updateRecorderHud(); + }); +} +if (recStartBtn) { + recStartBtn.addEventListener('click', () => { void startRecording(); }); +} +if (recStopBtn) { + recStopBtn.addEventListener('click', () => { stopRecording(); }); +} +if (recAutoBtn) { + recAutoBtn.addEventListener('click', () => { + setRecorderAutoEnabled(!recorderState.autoEnabled); + }); +} +updateRecorderHud(); + +function handleRealtimeGainSelection(value){ + let val = Number(value); + if (!Number.isFinite(val)) val = 0; + CONFIG.RTA_DISPLAY_GAIN_IIR_DB = val; + saveConfig(); + if (gainSel) { + const setVal = REALTIME_GAIN_CHOICES.includes(val) ? val : 0; + gainSel.value = String(setVal); + } + const slider = document.getElementById('opt_rtaDisplayGainIir'); + if (slider) slider.value = val; + const label = document.getElementById('val_rtaDisplayGainIir'); + if (label) label.textContent = `${val} dB`; + try { env.audio.updateRtaConfig?.(); } catch (_) {} +} + +function handleWaveWindowSelection(value){ + const nearest = getNearestWaveWindow(value); + CONFIG.WAVEFORM_WINDOW_SEC = nearest; + saveConfig(); + if (waveWindowSel) { + waveWindowSel.value = String(nearest); + } + const input = document.getElementById('opt_waveWindow'); + if (input) { + input.value = nearest; + } +} + +function handleWaveModeSelection(value){ + const mode = normalizeWaveMode(value); + CONFIG.WAVEFORM_MODE = mode; + saveConfig(); + if (waveModeSel) { + waveModeSel.value = mode; + } + const select = document.getElementById('opt_waveMode'); + if (select) { + select.value = mode; + } +} + +function handlePeakHistScrollSelection(value){ + const val = normalizePeakHistScroll(value); + CONFIG.PEAK_HISTORY_SCROLL_MODE = val; + saveConfig(); + if (peakHistScrollSel) { + peakHistScrollSel.value = String(val); + } + const optSel = document.getElementById('opt_peakHistScroll'); + if (optSel) optSel.value = String(val); +} + +function handleRtaBpoSelection(value){ + const mode = normalizeRtaBpo(value); + CONFIG.RTA_BPO_MODE = mode; + saveConfig(); + if (rtaBpoSel) { + rtaBpoSel.value = mode; + } + const select = document.getElementById('opt_rtaBpo'); + if (select) { + select.value = mode; + } + try { env.audio.updateRtaConfig?.(); } catch (_) {} +} + +function getNearestWaveWindow(value){ + const num = Number(value); + const target = Math.max(0.05, Math.min(15, Number.isFinite(num) ? num : (CONFIG.WAVEFORM_WINDOW_SEC || 1))); + return WAVE_WINDOW_CHOICES.reduce((prev, curr) => { + return Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev; + }, WAVE_WINDOW_CHOICES[0]); +} + +function normalizeWaveMode(value){ + if (value === 'overlay') return 'overlay'; + if (value === 'diff') return 'diff'; + return 'stacked'; +} + +function normalizeRtaBpo(value){ + const val = typeof value === 'string' ? value : ''; + return RTA_BPO_CHOICES.some(([key]) => key === val) ? val : '1_6'; +} + +function normalizePeakHistScroll(value){ + const num = Number(value); + const found = PEAK_HISTORY_SCROLL_CHOICES.find((v) => Math.abs(v - num) < 1e-9); + return found ?? 1; +} + +function formatTimerFull(ms){ + if (!Number.isFinite(ms)) return '00:00:00:00'; + const totalHund = Math.max(0, Math.floor(ms / 10)); + const hund = totalHund % 100; + const totalSec = Math.floor(totalHund / 100); + const sec = totalSec % 60; + const totalMin = Math.floor(totalSec / 60); + const min = totalMin % 60; + const hrs = Math.floor(totalMin / 60); + const two = (n) => String(n).padStart(2, '0'); + return `${two(hrs)}:${two(min)}:${two(sec)}:${two(hund)}`; +} + +function stopRecorderTimer() { + if (recorderState.timerRaf) { + cancelAnimationFrame(recorderState.timerRaf); + recorderState.timerRaf = null; + } +} + +function stopRecorderAutoMonitor() { + if (recorderState.autoMonitorRaf) { + cancelAnimationFrame(recorderState.autoMonitorRaf); + recorderState.autoMonitorRaf = null; + } +} + +function tickRecorderAuto() { + if (!recorderState.autoEnabled) { + stopRecorderAutoMonitor(); + return; + } + const now = performance.now(); + const level = Number.isFinite(env?.audio?.rmsDb?.mono) ? env.audio.rmsDb.mono : -120; + const hasSignal = level > getRecorderAutoThresholdDbfs(); + const busy = recorderState.status === 'starting'; + const recording = recorderState.status === 'recording' && !!recorderState.activeSession; + + if (recording) { + if (hasSignal) { + recorderState.autoSilenceSince = 0; + } else if (!recorderState.autoPendingStop) { + if (!recorderState.autoSilenceSince) recorderState.autoSilenceSince = now; + if ((now - recorderState.autoSilenceSince) >= getRecorderAutoSplitGapMs()) { + recorderState.autoPendingStop = true; + stopRecording({ keepAuto: true }); + } + } + } else if (!busy) { + recorderState.autoSilenceSince = 0; + if (hasSignal && !recorderState.autoPendingStart) { + if (canStartRecorderWithoutInputStream()) { + recorderState.autoPendingStart = true; + void startRecording({ keepAuto: true }); + } + } + } + + recorderState.autoMonitorRaf = requestAnimationFrame(tickRecorderAuto); +} + +function setRecorderAutoEnabled(enabled) { + const next = !!enabled; + recorderState.autoEnabled = next; + recorderState.autoSilenceSince = 0; + recorderState.autoPendingStart = false; + recorderState.autoPendingStop = false; + if (next) { + if (!recorderState.autoMonitorRaf) { + recorderState.autoMonitorRaf = requestAnimationFrame(tickRecorderAuto); + } + } else { + stopRecorderAutoMonitor(); + } + updateRecorderHud(); +} + +function tickRecorderTimer() { + if (!recorderState || recorderState.status !== 'recording') return; + const now = performance.now(); + const durationMs = now - (recorderState.startTs || now); + if (recTimer) recTimer.innerHTML = formatTimerFull(durationMs); + recorderState.timerRaf = requestAnimationFrame(tickRecorderTimer); +} + +function getRecordingExtension(mimeType = '') { + const type = String(mimeType || '').toLowerCase(); + if (type.includes('ogg')) return 'ogg'; + if (type.includes('mpeg')) return 'mp3'; + if (type.includes('wav')) return 'wav'; + return 'webm'; +} + +function canUseNativeWavRecorder() { + return typeof env?.audio?.startWavCapture === 'function' + && typeof env?.audio?.stopWavCapture === 'function'; +} + +function canStartRecorderWithoutInputStream() { + return canUseNativeWavRecorder(); +} + +async function finalizeRecordingSession(session, resultPromise) { + const fallbackType = session?.mimeType || 'audio/webm'; + let finalBlob = null; + let downloadName = ''; + let sessionError = ''; + try { + const result = await resultPromise; + finalBlob = result?.blob || null; + sessionError = result?.error || ''; + downloadName = result?.downloadName || (finalBlob ? buildRecordingFilename(getRecordingExtension(result?.mimeType || finalBlob.type || fallbackType)) : ''); + if (!sessionError && !finalBlob) sessionError = 'Aufnahme konnte nicht erstellt werden'; + if (!sessionError && finalBlob) { + addRecordingToHistory(finalBlob, downloadName); + } + } catch (e) { + sessionError = e?.message || 'Aufnahme konnte nicht erstellt werden'; + } + + recorderState.lastDurationMs = Math.max(0, performance.now() - session.startTs); + recorderState.processingRecorderSlots = recorderState.processingRecorderSlots.filter((slot) => slot !== session.slot); + recorderState.processingJobs = Math.max(0, recorderState.processingJobs - 1); + recorderState.error = sessionError || recorderState.error; + if (recorderState.activeSession?.id !== session.id) { + recorderState.status = recorderState.error + ? 'error' + : (recorderState.activeSession ? 'recording' : (recorderState.processingJobs > 0 ? 'processing' : 'stopped')); + updateRecorderHud(); + } + + if (!recorderState.activeSession) { + stopRecorderTimer(); + } + recorderState.autoPendingStart = false; + recorderState.autoPendingStop = false; + if (!recorderState.activeSession) { + recorderState.status = recorderState.error + ? 'error' + : (recorderState.processingJobs > 0 ? 'processing' : 'stopped'); + } + updateRecorderHud(); +} + +function updateRecorderHud(){ + const desiredTarget = (CONFIG.RECORD_TARGET === 'analyzer' || CONFIG.RECORD_TARGET === 'volumio') ? CONFIG.RECORD_TARGET : 'download'; + recorderState.target = desiredTarget; + if (recTargetSel && recTargetSel.value !== recorderState.target) { + recTargetSel.value = recorderState.target; + } + const recording = recorderState.status === 'recording' && !!recorderState.activeSession; + const processing = !recording && recorderState.processingJobs > 0; + const busy = recorderState.status === 'starting'; + if (recLabel) { + recLabel.classList.toggle('blink', recording); + } + if (recStartBtn) recStartBtn.disabled = recording || busy || recorderState.autoEnabled || (!recorderState.autoEnabled && processing); + if (recStopBtn) recStopBtn.disabled = !recording && !recorderState.autoEnabled; + if (recAutoBtn) { + recAutoBtn.disabled = busy; + recAutoBtn.classList.toggle('btn-active', recorderState.autoEnabled && !recording); + recAutoBtn.classList.toggle('rec-auto-armed', recorderState.autoEnabled && !recording); + recAutoBtn.classList.toggle('rec-auto-recording', recorderState.autoEnabled && recording); + recAutoBtn.textContent = recorderState.autoEnabled ? 'Auto AN' : 'Auto'; + } + if (recProcessing) recProcessing.style.display = (processing && !recorderState.autoEnabled) ? 'flex' : 'none'; + const durationMs = recording + ? (performance.now() - (recorderState.startTs || performance.now())) + : recorderState.lastDurationMs || 0; + if (recTimer) { + recTimer.innerHTML = formatTimerFull(durationMs); + } + renderRecordingList(); +} + +function buildRecordingFilename(ext = 'webm') { + const ts = new Date(); + const stamp = ts.toISOString().replace(/[:.]/g, '-'); + return `recording-${stamp}.${ext}`; +} + +async function startRecording(opts = {}){ + const allowParallelFinalize = !!opts.keepAuto; + if (recorderState.activeSession || recorderState.status === 'starting') return; + if (!allowParallelFinalize && recorderState.processingJobs > 0) return; + if (!opts.keepAuto && recorderState.autoEnabled) { + setRecorderAutoEnabled(false); + } + stopRecorderTimer(); + recorderState.status = 'starting'; + recorderState.error = ''; + const rawOutFormat = String(CONFIG.RECORD_OUTPUT_FORMAT || 'wav').toLowerCase(); + const outFormat = rawOutFormat === 'webm' ? 'webm' : (rawOutFormat === 'mp3' ? 'mp3' : 'wav'); + if (!canStartRecorderWithoutInputStream()) { + recorderState.error = 'Kein Audio-Stream verfügbar'; + recorderState.status = 'idle'; + recorderState.autoPendingStart = false; + updateRecorderHud(); + showErr('Recorder: Kein Audio-Stream verfügbar (Audio starten?)'); + return; + } + const session = { + id: recorderState.nextSessionId++, + kind: outFormat, + recorder: null, + mimeType: outFormat === 'mp3' ? 'audio/mpeg' : (outFormat === 'wav' ? 'audio/wav' : 'audio/webm'), + chunks: [], + startTs: performance.now(), + slot: allowParallelFinalize ? recorderState.nextRecorderSlot : 'A', + }; + if (allowParallelFinalize) { + recorderState.nextRecorderSlot = (session.slot === 'A') ? 'B' : 'A'; + } else { + recorderState.nextRecorderSlot = 'B'; + } + + recorderState.activeSession = session; + recorderState.activeRecorderSlot = session.slot; + recorderState.lastDurationMs = 0; + try { + await env.audio.startWavCapture(session.id); + } catch (e) { + if (recorderState.activeSession?.id === session.id) { + recorderState.activeSession = null; + recorderState.activeRecorderSlot = null; + } + recorderState.error = e?.message || 'Phoenix-Recorder nicht verfügbar'; + recorderState.status = 'error'; + recorderState.autoPendingStart = false; + updateRecorderHud(); + return; + } + recorderState.startTs = session.startTs; + recorderState.status = 'recording'; + recorderState.autoPendingStart = false; + recorderState.autoSilenceSince = 0; + updateRecorderHud(); + tickRecorderTimer(); +} + +function stopRecording(opts = {}){ + if (!opts.keepAuto && recorderState.autoEnabled) { + setRecorderAutoEnabled(false); + } + const session = recorderState.activeSession; + if ((session?.kind === 'wav' || session?.kind === 'mp3' || session?.kind === 'webm') && recorderState.status === 'recording') { + recorderState.processingJobs += 1; + if (!recorderState.processingRecorderSlots.includes(session.slot)) { + recorderState.processingRecorderSlots = [...recorderState.processingRecorderSlots, session.slot]; + } + recorderState.activeSession = null; + recorderState.activeRecorderSlot = null; + recorderState.status = recorderState.processingJobs > 0 ? 'processing' : 'stopped'; + updateRecorderHud(); + const finalizePromise = env.audio.stopWavCapture(session.id, session.kind, { + mp3BitrateKbps: Number(CONFIG.RECORD_MP3_BITRATE_KBPS) || 192, + }).then((capture) => ({ + blob: capture?.blob || null, + mimeType: capture?.mimeType || 'audio/wav', + downloadName: buildRecordingFilename(getRecordingExtension(capture?.mimeType || capture?.blob?.type || session.mimeType || 'audio/wav')), + })); + void finalizeRecordingSession(session, finalizePromise); + } + recorderState.autoPendingStart = false; + stopRecorderTimer(); +} + +function addRecordingToHistory(blob, name) { + if (!blob) return; + const url = URL.createObjectURL(blob); + recorderState.history = recorderState.history || []; + const entry = { name, url, downloadRequested: false, released: false, saved: false, saving: false, savedPath: '' }; + recorderState.history.unshift(entry); + while (recorderState.history.length > RECORDER_HISTORY_LIMIT) { + const old = recorderState.history.pop(); + try { if (old?.url) URL.revokeObjectURL(old.url); } catch (_) {} + } + if (CONFIG.RECORD_AUTO_DOWNLOAD === true) { + void saveRecordingEntry(entry, { rerender: false }).finally(() => { + renderRecordingList(); + }); + } + renderRecordingList(); +} + +function removeRecordingFromHistory(entry) { + if (!recorderState.history) return; + recorderState.history = recorderState.history.filter((e) => e !== entry); + try { if (entry?.url) URL.revokeObjectURL(entry.url); } catch (_) {} + if (recorderState.history.length === 0) { + recorderState.lastDurationMs = 0; + stopRecorderTimer(); + if (recTimer) recTimer.innerHTML = formatTimerFull(0); + } + renderRecordingList(); +} + +function sanitizeGoniGain(val){ + let num = Number(val); + if (!Number.isFinite(num)) num = 0; + if (num > GONIO_GAIN_MAX_DB) num = GONIO_GAIN_MAX_DB; + if (num < GONIO_GAIN_MIN_DB) num = GONIO_GAIN_MIN_DB; + return Math.round(num / 5) * 5; +} + + +function formatWaveWindowLabel(seconds){ + if (seconds >= 1) { + return `${seconds} s`; + } + return `${Math.round(seconds * 1000)} ms`; +} + +function updateRect(){ + const w = C.clientWidth; + const h = C.clientHeight; + if (w === prevCanvasWidth && h === prevCanvasHeight) return; + prevCanvasWidth = w; + prevCanvasHeight = h; + env.rect.w = w; + env.rect.h = h; + if (currentView && currentView.resize){ + try { currentView.resize({ rect: env.rect }, viewState); } + catch(e){ console.warn('View resize error:', e); } + } +} + +// Jetzt, wo env existiert: auf Window-Resize auch die View aktualisieren +addEventListener('resize', () => { + try { updateRect(); } + catch (e) { console.warn('Deferred resize update error:', e); } +}); + +// --- Audio ------------------------------------------------------------------- +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function bootAudio(force = false){ + const retryPlan = force ? [0, 1500, 3500, 7000] : [0, 1500, 3500, 7000]; + let ok = false; + for (let i = 0; i < retryPlan.length; i++) { + const waitMs = retryPlan[i]; + if (waitMs > 0) await delay(waitMs); + ok = force ? await reloadAudio(env) : await initAudio(env); + if (ok) break; + console.warn(`Audio init attempt ${i + 1}/${retryPlan.length} failed`); + } + if (!ok) showErr('Audio init fehlgeschlagen.'); else hideErr(); + return ok; +} + +// --- Hintergrund / Lost Overlay --------------------------------------------- +function drawBG(){ + const bg = getCachedBackgroundLayer(); + if (bg) { + g.drawImage(bg, 0, 0, C.clientWidth, C.clientHeight); + return; + } + g.fillStyle = 'rgba(5,5,11,0.75)'; + g.fillRect(0,0,C.clientWidth,C.clientHeight); +} + +let backgroundLayerCanvas = null; +let backgroundLayerKey = ''; + +function getCachedBackgroundLayer() { + const w = C.clientWidth | 0; + const h = C.clientHeight | 0; + if (w <= 0 || h <= 0) return null; + const key = `${w}x${h}`; + if (backgroundLayerCanvas && backgroundLayerKey === key) { + return backgroundLayerCanvas; + } + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d', { alpha: true }); + if (!ctx) return null; + ctx.fillStyle = 'rgba(5,5,11,0.75)'; + ctx.fillRect(0, 0, w, h); + backgroundLayerCanvas = canvas; + backgroundLayerKey = key; + return backgroundLayerCanvas; +} + +function drawAudioLostOverlay(){ + if (!audioLost(env)) return; + const backendLabel = String(env.audio?.backendLabel || 'Audio'); + g.save(); + g.fillStyle = 'rgba(0,0,0,0.55)'; + g.fillRect(0,0,C.clientWidth,C.clientHeight); + g.fillStyle = '#ffd6d6'; + g.textAlign = 'center'; + g.font = 'bold 18px ui-monospace, monospace'; + g.fillText(`${backendLabel} lost – Quelle prüfen`, C.clientWidth/2, C.clientHeight/2); + g.textAlign = 'start'; + g.restore(); +} + +function drawOptionsPanelBackdrop(){ + if (style !== 'options-panel') return; + g.save(); + g.fillStyle = 'rgba(8, 10, 18, 0.5)'; + g.fillRect(0,0,C.clientWidth,C.clientHeight); + g.restore(); +} + +// --- Loop (FPS-Kontrolle) ---------------------------------------------------- +function desiredFpsForStyle(renderStyle) { + return 60; +} + +let lastFrameTime = 0; +let prevCanvasWidth = 0; +let prevCanvasHeight = 0; +let prevAudioLost = null; +let prevSaverActive = false; +let audioRecoverInFlight = false; +let lastAudioRecoverAt = 0; +let renderDirty = true; +let lastRenderedAudioSeq = 0; + +function requestRender(reason = 'ui') { + renderDirty = true; + env.__lastRenderReason = reason; +} + +env.requestRender = requestRender; + +async function maybeRecoverAudio(reason) { + if (audioRecoverInFlight) return false; + if (document.hidden) return false; + if (!audioLost(env)) return false; + const now = performance.now(); + if (now - lastAudioRecoverAt < 5000) return false; + lastAudioRecoverAt = now; + audioRecoverInFlight = true; + try { + await new Promise((r) => setTimeout(r, 800)); + if (audioLost(env)) { + console.warn(`Audio lost after ${reason}; reloading audio…`); + await bootAudio(true); + } + return true; + } catch (e) { + console.warn('Audio recover failed:', e); + return false; + } finally { + audioRecoverInFlight = false; + } +} + +async function loop(now){ + const elapsed = now - lastFrameTime; + const audioOk = env.audio.alive && !audioLost(env); + const renderStyle = getRenderableStyle(); + try { env.audio?.updateProcessingConfig?.(buildProcessingProfile(renderStyle)); } catch (_) {} + const targetMs = audioOk ? (1000 / desiredFpsForStyle(renderStyle)) : (1000 / 20); + const target = targetMs; + const currentAudioSeq = Number.isFinite(env.audio?.xySeq) ? env.audio.xySeq : 0; + const audioDirty = currentAudioSeq > lastRenderedAudioSeq; + const shouldRenderFrame = + renderDirty || + audioDirty || + !audioOk || + style === 'options-panel' || + style === 'recorder' || + renderStyle === 'clock'; + if (elapsed >= target){ + lastFrameTime = now - (elapsed % targetMs); + try{ + updateRect(); + meterHitRects.length = 0; + const lostNow = audioLost(env); + if (lostNow) { + // Don't wait for an edge transition; after long idle the app may already be in a lost state. + void maybeRecoverAudio('lost'); + } + prevAudioLost = lostNow; + const saverActive = screensaver.updateAndRender(g, env.rect, now, elapsed); + if (prevSaverActive && !saverActive) { + env.screensaverWakeTs = now; + requestRender('screensaver-wake'); + void maybeRecoverAudio('screensaver-wake'); + } + prevSaverActive = saverActive; + if (!saverActive && shouldRenderFrame) { + meterFacade.setFrameStamp?.(++frameCounter); + drawBG(); + if (!currentView || currentViewId !== renderStyle) setView(renderStyle); + if (currentView.render) await currentView.render(env, viewState); + drawOptionsPanelBackdrop(); + drawAudioLostOverlay(); + renderDirty = false; + lastRenderedAudioSeq = currentAudioSeq; + } + } catch(e){ + showErr('Render error: ' + (e?.message || String(e))); + console.error('Render loop error:', e); + } + } + requestAnimationFrame(loop); +} + +// --- Start ------------------------------------------------------------------- +(async function boot(){ + try{ + const start = async () => { + await new Promise((resolve) => setTimeout(resolve, 2000)); + const layoutParam = (() => { + try { + return new URLSearchParams(globalThis?.location?.search || '').get('layout') || ''; + } catch (_) { + return ''; + } + })(); + if (layoutParam) { + try { + const result = await loadLayoutPreset(layoutParam); + if (result) { + applyPresetUiState(result?.uiState || result?.snapshot?.__PHOENIX_UI_STATE || null); + const rawStyle = String(result?.uiState?.style || CONFIG.UI_LAST_STYLE || ''); + const fallbackStyle = String(result?.uiState?.lastNonOptionStyle || CONFIG.UI_LAST_NON_OPTION_STYLE || DEFAULT_STYLE); + const targetStyle = (rawStyle === 'options-panel' || rawStyle === 'options') ? fallbackStyle : (rawStyle || fallbackStyle); + if (VALID_STYLES.has(targetStyle)) { + applyStyleSelection(targetStyle, { persistStorage: true, persistConfig: true }); + } + } + } catch (err) { + console.warn('Layout preset load error:', err); + } + } + await bootAudio(); + applyStyleSelection(style, { persistStorage: false, persistConfig: false }); + requestRender('boot'); + requestAnimationFrame(loop); + }; + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', start); + } else { + await start(); + } + saveSlotState(slotsState); + } catch(e){ + showErr('Boot error: ' + (e?.message || String(e))); + console.error('Boot error:', e); + } +})(); diff --git a/www/meters/hifi_peak.js b/www/meters/hifi_peak.js new file mode 100644 index 0000000..8f2b431 --- /dev/null +++ b/www/meters/hifi_peak.js @@ -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)); +} diff --git a/www/meters/lufs.js b/www/meters/lufs.js new file mode 100644 index 0000000..6bb003d --- /dev/null +++ b/www/meters/lufs.js @@ -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; +} diff --git a/www/meters/ppm_din.js b/www/meters/ppm_din.js new file mode 100644 index 0000000..c77ac87 --- /dev/null +++ b/www/meters/ppm_din.js @@ -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; +} diff --git a/www/meters/ppm_ebu.js b/www/meters/ppm_ebu.js new file mode 100644 index 0000000..369a83c --- /dev/null +++ b/www/meters/ppm_ebu.js @@ -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; +} diff --git a/www/meters/rms.js b/www/meters/rms.js new file mode 100644 index 0000000..9a880d2 --- /dev/null +++ b/www/meters/rms.js @@ -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; +} diff --git a/www/meters/scale_helpers.js b/www/meters/scale_helpers.js new file mode 100644 index 0000000..73bc8be --- /dev/null +++ b/www/meters/scale_helpers.js @@ -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(); +} diff --git a/www/meters/static_layer.js b/www/meters/static_layer.js new file mode 100644 index 0000000..cc0b561 --- /dev/null +++ b/www/meters/static_layer.js @@ -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; +} diff --git a/www/meters/stopwatch.js b/www/meters/stopwatch.js new file mode 100644 index 0000000..e79b10e --- /dev/null +++ b/www/meters/stopwatch.js @@ -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)); +} diff --git a/www/meters/tp.js b/www/meters/tp.js new file mode 100644 index 0000000..992914c --- /dev/null +++ b/www/meters/tp.js @@ -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; +} diff --git a/www/meters/vu.js b/www/meters/vu.js new file mode 100644 index 0000000..1edfdda --- /dev/null +++ b/www/meters/vu.js @@ -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 }; +} diff --git a/www/styles.css b/www/styles.css new file mode 100644 index 0000000..725a2f6 --- /dev/null +++ b/www/styles.css @@ -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;} diff --git a/www/ui/options.js b/www/ui/options.js new file mode 100644 index 0000000..4b068b2 --- /dev/null +++ b/www/ui/options.js @@ -0,0 +1,1683 @@ +// ui/options.js — bindet das Options-Panel an CONFIG (lesen/schreiben), Presets, Export/Import +// Erwartet vorhandenes DOM aus index.html. Nutzt localStorage-Key 'analyzer_config'. + +import { CONFIG, saveConfig, loadConfig, applyAlignmentProfile, applySoftwarePreset, loadSoftwarePreset, saveSoftwarePreset, loadLayoutPreset, saveLayoutPreset, exportSoftwarePreset, importSoftwarePreset, updateVuReference, resetConfigToDefaults, updateInputOffsetGain } from '../core/config.js'; +import { clamp, clampPow2 } from '../core/utils.js'; + +// DOM helper +const E = (id) => document.getElementById(id); +const PPM_LOUD_WARN_KEY = 'ppm_din_loudness_warn_ack_v1'; +const PPM_DIN_FAST_WARN_KEY = 'ppm_din_fast_attack_warn_ack_v1'; + +function getPpmDinVisibleBaseOffset() { + const baseMode = (CONFIG.PPM_DIN_MODE === 'al_minus6') ? -6 : -9; + const trim = Number(CONFIG.PPM_DIN_TRIM_DB) || 0; + return baseMode + trim; +} + +function dbfsToPpmDin(dbfs) { + const refDbfsFor0 = Number.isFinite(CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU) + ? CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU + : -15; + return Number(dbfs) - refDbfsFor0 + getPpmDinVisibleBaseOffset(); +} + +function ppmDinToDbfs(ppmDin) { + const refDbfsFor0 = Number.isFinite(CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU) + ? CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU + : -15; + return Number(ppmDin) + refDbfsFor0 - getPpmDinVisibleBaseOffset(); +} + +function normalizeNumericInput(value) { + return String(value ?? '') + .trim() + .replace(/[\u2212\u2012\u2013\u2014\u2015\uFE58\uFE63\uFF0D]/g, '-') + .replace(',', '.'); +} + +function formatThresholdValue(value) { + return String(Math.round(Number(value) * 10) / 10); +} + +function parseNumericInput(value, fallback) { + const normalized = normalizeNumericInput(value); + const match = normalized.match(/[-+]?(?:\d+\.?\d*|\.\d+)/); + const n = Number(match ? match[0] : normalized); + return Number.isFinite(n) ? n : fallback; +} + +function clampThresholdDbfs(value, fallback) { + const n = parseNumericInput(value, fallback); + return clamp(Number.isFinite(n) ? n : fallback, -120, 20); +} + +function syncLinkedThresholdFields(dbfsId, ppmId, dbfsValue) { + const dbfsEl = E(dbfsId); + const ppmEl = E(ppmId); + if (dbfsEl) dbfsEl.value = formatThresholdValue(dbfsValue); + if (ppmEl) ppmEl.value = formatThresholdValue(dbfsToPpmDin(dbfsValue)); +} + +function showPpmLoudnessWarning(onConfirm) { + const wrap = E('ppmLoudnessWarn'); + const ack = E('ppmLoudnessWarnAck'); + const btn = E('ppmLoudnessWarnBtn'); + if (!wrap || !ack || !btn) { + if (onConfirm) onConfirm(); + return; + } + if (!wrap.dataset.bound) { + btn.addEventListener('click', () => { + if (ack.checked) { + try { localStorage.setItem(PPM_LOUD_WARN_KEY, '1'); } catch (_) {} + } + wrap.style.display = 'none'; + if (onConfirm) onConfirm(); + }); + wrap.dataset.bound = '1'; + } + ack.checked = false; + wrap.style.display = 'flex'; +} + +function showPpmDinFastAttackWarning(onConfirm) { + const wrap = E('ppmDinFastWarn'); + const btn = E('ppmDinFastWarnBtn'); + if (!wrap || !btn) { + if (onConfirm) onConfirm(); + return; + } + if (!wrap.dataset.bound) { + btn.addEventListener('click', () => { + try { localStorage.setItem(PPM_DIN_FAST_WARN_KEY, '1'); } catch (_) {} + wrap.style.display = 'none'; + if (onConfirm) onConfirm(); + }); + wrap.addEventListener('click', (e) => { + if (e.target === wrap) wrap.style.display = 'none'; + }); + wrap.dataset.bound = '1'; + } + wrap.style.display = 'flex'; +} + +export function setupOptions(env) { + // Initial laden + loadConfig(); + enforcePhaseAmplitudeConstraints({ persist: true }); + syncUI(); + wireHandlers(env); + bindOptionsViewportAssist(); + bindAlignmentToggle(env); + bindVuRefToggle(env); + // Sichtbarkeit nach View steuern übernimmt main.js — hier nur Export der Sync-Funktion + return { syncUI }; +} + +let optionsViewportAssistBound = false; + +function bindOptionsViewportAssist() { + if (optionsViewportAssistBound) return; + optionsViewportAssistBound = true; + + const panel = E('optionsPanelNew'); + if (!panel) return; + + const setKeyboardInset = () => { + const vv = window.visualViewport; + if (!vv) { + panel.style.setProperty('--options-kb-inset', '0px'); + return; + } + const inset = Math.max(0, window.innerHeight - (vv.height + vv.offsetTop)); + panel.style.setProperty('--options-kb-inset', `${Math.round(inset)}px`); + }; + + const revealFocusedField = (target) => { + if (!panel || !target || !panel.contains(target)) return; + const vv = window.visualViewport; + const panelRect = panel.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + const keyboardInset = vv ? Math.max(0, window.innerHeight - (vv.height + vv.offsetTop)) : 0; + const visibleTop = panelRect.top + 16; + const visibleBottom = panelRect.bottom - keyboardInset - 16; + + if (targetRect.bottom > visibleBottom || targetRect.top < visibleTop) { + const targetCenter = targetRect.top + (targetRect.height / 2); + const visibleCenter = visibleTop + ((visibleBottom - visibleTop) / 2); + const delta = targetCenter - visibleCenter; + panel.scrollTop += delta; + } + }; + + const onFocus = (ev) => { + const el = ev.target; + if (!(el instanceof HTMLElement)) return; + const type = String(el.getAttribute('type') || '').toLowerCase(); + if (el.tagName !== 'INPUT' && el.tagName !== 'SELECT' && el.tagName !== 'TEXTAREA') return; + if (type === 'checkbox' || type === 'range' || type === 'color') return; + setKeyboardInset(); + requestAnimationFrame(() => revealFocusedField(el)); + setTimeout(() => revealFocusedField(el), 250); + }; + + const onBlur = () => { + setTimeout(() => { + const active = document.activeElement; + if (!(active instanceof HTMLElement) || !panel.contains(active)) { + panel.style.setProperty('--options-kb-inset', '0px'); + } + }, 50); + }; + + panel.addEventListener('focusin', onFocus); + panel.addEventListener('focusout', onBlur); + + if (window.visualViewport) { + window.visualViewport.addEventListener('resize', () => { + setKeyboardInset(); + const active = document.activeElement; + if (active instanceof HTMLElement && panel.contains(active)) { + requestAnimationFrame(() => revealFocusedField(active)); + } + }); + window.visualViewport.addEventListener('scroll', setKeyboardInset); + } + + setKeyboardInset(); +} + +function syncSoftwarePresetControls() { + const presetId = String(E('opt_softwarePreset')?.value || CONFIG.SOFTWARE_PRESET || 'default').toLowerCase(); + const isDefault = presetId === 'default'; + const btnSave = E('btnSoftwarePresetSave'); + const btnImport = E('btnSoftwarePresetImport'); + const inputImport = E('impSoftwarePreset'); + + if (btnSave) { + btnSave.disabled = isDefault; + btnSave.title = isDefault ? 'Für Default nicht verfügbar' : ''; + } + if (inputImport) inputImport.disabled = isDefault; + if (btnImport) { + btnImport.setAttribute('aria-disabled', isDefault ? 'true' : 'false'); + btnImport.style.opacity = isDefault ? '0.55' : ''; + btnImport.style.pointerEvents = isDefault ? 'none' : ''; + btnImport.style.cursor = isDefault ? 'not-allowed' : 'pointer'; + btnImport.title = isDefault ? 'Für Default nicht verfügbar' : ''; + } +} + +function syncUI() { + const map = [ + ['opt_softwarePreset', CONFIG.SOFTWARE_PRESET || 'default'], + ['opt_fft', String(CONFIG.FFT_SIZE)], + ['opt_phoenixBaseUrl', CONFIG.PHOENIX_BASE_URL || `http://${location.hostname || '127.0.0.1'}:8789`], + ['opt_alMarkers', CONFIG.AL_MARKERS_ENABLED, null, null, 'checkbox'], + ['opt_inputOffsetDbL', CONFIG.INPUT_OFFSET_DB_L, 'val_inputOffsetDbL', (v)=>`${Number(v).toFixed(1)} dB`], + ['opt_inputOffsetDbR', CONFIG.INPUT_OFFSET_DB_R, 'val_inputOffsetDbR', (v)=>`${Number(v).toFixed(1)} dB`], + ['opt_monoInput', CONFIG.MONO_INPUT, null, null, 'checkbox'], + ['opt_lrFracDelayEnabled', CONFIG.LR_FRACTIONAL_DELAY_ENABLED, null, null, 'checkbox'], + ['opt_lrFracDelaySamples', CONFIG.LR_FRACTIONAL_DELAY_SAMPLES ?? 0.05, 'val_lrFracDelaySamples', (v)=>`${Number(v).toFixed(3)} Samples`], + ['opt_screensaverEnabled', CONFIG.SCREENSAVER_ENABLED !== false, null, null, 'checkbox'], + ['opt_screensaverIdleMin', CONFIG.SCREENSAVER_IDLE_MIN ?? 5, 'val_screensaverIdleMin', (v)=>`${Number(v).toFixed(1)} min`], + ['opt_screensaverActivityDb', CONFIG.SCREENSAVER_ACTIVITY_DB ?? -50, 'val_screensaverActivityDb', (v)=>`${Number(v).toFixed(1)} dBFS`], + ['opt_screensaverMode', CONFIG.SCREENSAVER_MODE || 'clock'], + ['opt_screensaverGlow', CONFIG.SCREENSAVER_LED_GLOW !== false, null, null, 'checkbox'], + ['opt_screensaverColor', CONFIG.SCREENSAVER_LED_COLOR || '#ff0000'], + ['opt_clockGlow', CONFIG.CLOCK_LED_GLOW !== false, null, null, 'checkbox'], + ['opt_clockStyle', CONFIG.CLOCK_STYLE || 'analog'], + ['opt_clockColor', CONFIG.CLOCK_LED_COLOR || '#ff0000'], + ['opt_clockPpmLed', CONFIG.CLOCK_PPM_LED_MODE === true, null, null, 'checkbox'], + ['opt_vuRef', CONFIG.VU_DBFS_REF], + ['opt_vuOff', CONFIG.VU_OFFSET_DB], + ['opt_vuHeaderValue', CONFIG.VU_HEADER_SHOW_VALUE, null, null, 'checkbox'], + ['opt_ppmDinColNorm', CONFIG.PPM_DIN_COLOR_NORMAL], + ['opt_ppmDinColWarn', CONFIG.PPM_DIN_COLOR_WARN], + ['opt_ppmDinHeaderValue', CONFIG.PPM_DIN_HEADER_SHOW_VALUE, null, null, 'checkbox'], + ['opt_ppmDinLoudnessBox', CONFIG.PPM_DIN_LOUDNESS_BOXES, null, null, 'checkbox'], + ['opt_ppmDinFastAttack', CONFIG.PPM_DIN_FAST_ATTACK, null, null, 'checkbox'], + ['opt_ppmDinLoudOff', CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB ?? 0, 'val_ppmDinLoudOff', (v)=>`${Number(v).toFixed(1)} dB`], + ['opt_ppmEbuColNorm', CONFIG.PPM_EBU_COLOR_NORMAL], + ['opt_ppmEbuColWarn', CONFIG.PPM_EBU_COLOR_WARN], + ['opt_ppmEbuHeaderValue', CONFIG.PPM_EBU_HEADER_SHOW_VALUE, null, null, 'checkbox'], + + ['opt_hifiAlign', CONFIG.HIFI_PEAK_ALIGNMENT || 'ppm_din_minus5'], + ['opt_tpOff', CONFIG.TP_OFFSET_DB], + ['opt_tpHeaderValue', CONFIG.TP_HEADER_SHOW_VALUE, null, null, 'checkbox'], + ['opt_rmsOff', CONFIG.RMS_OFFSET_DB], + ['opt_rmsHeaderValue', CONFIG.RMS_HEADER_SHOW_VALUE, null, null, 'checkbox'], + ['opt_rmsTc', CONFIG.RMS_TC_MODE || 'fast'], + + // NEW: RMS Modus & Referenzen + ['opt_rmsMode', (CONFIG.RMS_MODE ?? 'dbu')], // 'dbu' | 'dbfs' + ['opt_rmsRefDbfs', CONFIG.RMS_REF_DBFS_FOR_REF_DBU ?? -18], + ['opt_rmsRefDbu', CONFIG.RMS_REF_DBU ?? +4], + + ['opt_vuRedThr', CONFIG.VU_RED_START], + ['opt_tpRedThr', CONFIG.TP_RED_START], + ['opt_rmsRedThr', CONFIG.RMS_RED_START], + + ['opt_vuRedBarOnly', CONFIG.VU_RED_BAR_ONLY, null, null, 'checkbox'], + ['opt_tpRedBarOnly', CONFIG.TP_RED_BAR_ONLY, null, null, 'checkbox'], + ['opt_rmsRedBarOnly', CONFIG.RMS_RED_BAR_ONLY, null, null, 'checkbox'], + + ['opt_vuColNorm', CONFIG.VU_COLOR_NORMAL], + ['opt_vuColWarn', CONFIG.VU_COLOR_WARN], + ['opt_tpColNorm', CONFIG.TP_COLOR_NORMAL], + ['opt_tpColWarn', CONFIG.TP_COLOR_WARN], + ['opt_rmsColNorm', CONFIG.RMS_COLOR_NORMAL], + ['opt_rmsColWarn', CONFIG.RMS_COLOR_WARN], + + ['opt_corrSmooth', CONFIG.CORR_SMOOTH, 'val_corrSmooth', (v)=>Number(v).toFixed(2)], + ['opt_corrZeroOnSilence', CONFIG.CORR_ZERO_ON_SILENCE, null, null, 'checkbox'], + ['opt_xyPoints', String(CONFIG.XY_POINTS)], + ['opt_xyStyle', CONFIG.XY_STYLE], + ['opt_xySilenceGate', CONFIG.XY_SILENCE_GATE_ENABLED, null, null, 'checkbox'], + ['opt_xySilenceThr', CONFIG.XY_SILENCE_THRESHOLD_RMS_DBFS ?? -75], + ['opt_barThin', CONFIG.METER_BAR_THIN, 'val_barThin', (v)=>Number(v).toFixed(2)], + ['opt_headerTextColor', CONFIG.HEADER_TEXT_COLOR || '#ffe066'], + ['opt_rtaEngine', CONFIG.RTA_ENGINE || 'iir'], + ['opt_rtaBpo', CONFIG.RTA_BPO_MODE || '1_6'], + ['opt_rtaOrder', CONFIG.RTA_IIR_ORDER ?? 4], + ['opt_rtaDisplayGainFft', CONFIG.RTA_DISPLAY_GAIN_FFT_DB ?? 12, 'val_rtaDisplayGainFft', (v)=>`${v} dB`], + ['opt_rtaDisplayGainIir', CONFIG.RTA_DISPLAY_GAIN_IIR_DB ?? -6, 'val_rtaDisplayGainIir', (v)=>`${v} dB`], + ['opt_rtaFreq', CONFIG.RTA_FREQ_RANGE || 'norm'], + ['opt_rtaLayout', CONFIG.RTA_BAR_LAYOUT || 'iec'], + ['opt_rtaBarBaseColor', CONFIG.RTA_BAR_BASE_COLOR || '#ffe066'], + ['opt_rtaWeighting', CONFIG.RTA_WEIGHTING || 'z'], + ['opt_rtaIntegration', CONFIG.RTA_INTEGRATION || 'fast'], + ['opt_rtaBallistics', CONFIG.RTA_BALLISTICS_MODE || 'average'], + ['opt_rtaHoldMode', CONFIG.RTA_PEAK_HOLD_MODE || 'auto'], + ['opt_rtaHoldTime', CONFIG.RTA_PEAK_HOLD_SEC ?? 2], + ['opt_rtaDecay', CONFIG.RTA_PEAK_DECAY_DB_PER_S ?? 20], + ['opt_rtaDisplayHold', CONFIG.RTA_DISPLAY_HOLD_SEC ?? 0], + ['opt_spectroGamma', CONFIG.SPECTRO_GAMMA ?? 0.9, 'val_spectroGamma', (v)=>Number(v).toFixed(2)], + ['opt_spectroScroll', CONFIG.SPECTRO_SCROLL_MODE ?? 1], + ['opt_peakHistScroll', CONFIG.PEAK_HISTORY_SCROLL_MODE ?? 1], + ['opt_peakHistFill', CONFIG.PEAK_HISTORY_FILL_ENABLED, null, null, 'checkbox'], + ['opt_peakHistFillInvert', CONFIG.PEAK_HISTORY_FILL_INVERT, null, null, 'checkbox'], + ['opt_waveMode', CONFIG.WAVEFORM_MODE || 'stacked'], + ['opt_waveWindow', CONFIG.WAVEFORM_WINDOW_SEC || 1], + ['opt_waveColorL', CONFIG.WAVEFORM_COLOR_LEFT || '#00e7ff'], + ['opt_waveColorR', CONFIG.WAVEFORM_COLOR_RIGHT || '#ff6b81'], + ['opt_waveColorDiff', CONFIG.WAVEFORM_COLOR_DIFF || '#00e7ff'], + ['opt_rtaTauFast', CONFIG.RTA_IIR_TAU_FAST ?? 0.12], + ['opt_rtaTauSlow', CONFIG.RTA_IIR_TAU_SLOW ?? 1.0], + ['opt_rtRenderMode', CONFIG.REALTIME_RENDER_STYLE || 'bars'], + ['opt_rtHold', CONFIG.REALTIME_BAR_HOLD_MS], + ['opt_rtDecay', CONFIG.REALTIME_BAR_DECAY_DB_PER_S], + ['opt_goniGain', CONFIG.GONIO_DISPLAY_GAIN_DB, 'val_goniGain', (v)=>`${Number(v).toFixed(0)} dB`], + ['opt_goniAgc', CONFIG.GONIO_AGC_ENABLED, null, null, 'checkbox'], + ['opt_phaseGain', CONFIG.PHASE_DISPLAY_GAIN_DB ?? 0, 'val_phaseGain', (v)=>`${Number(v).toFixed(0)} dB`], + ['opt_phaseAgc', CONFIG.PHASE_AGC_ENABLED, null, null, 'checkbox'], + ['opt_phaseAmpMode', CONFIG.PHASE_AMPLITUDE_MODE || 'bandpass'], + ['opt_phaseTrail', CONFIG.PHASE_TRAIL_ENABLED, null, null, 'checkbox'], + ['opt_goniGap', CONFIG.GONI_METER_GAP, 'val_goniGap', (v)=>`${v} px`], + ['opt_goniFade', CONFIG.GONIO_LINE_FADE_MS, 'val_goniFade', (v)=>`${Math.round(v)} ms`], + ['opt_panelDividers', CONFIG.PANEL_DIVIDERS_ENABLED, null, null, 'checkbox'], + ['opt_lufsRedThr', CONFIG.LUFS_RED_START], + ['opt_lufsYellowThr', CONFIG.LUFS_YELLOW_START], + ['opt_lufsGreenThr', CONFIG.LUFS_GREEN_START], + ['opt_lufsColI', CONFIG.LUFS_COLOR_I || CONFIG.LUFS_COLOR_I_GREEN || CONFIG.LUFS_COLOR_GREEN], + ['opt_lufsColM', CONFIG.LUFS_COLOR_M || CONFIG.LUFS_COLOR_M_GREEN || CONFIG.LUFS_COLOR_GREEN], + ['opt_lufsColS', CONFIG.LUFS_COLOR_S || CONFIG.LUFS_COLOR_S_GREEN || CONFIG.LUFS_COLOR_GREEN], + ['opt_lufsScaleCol', CONFIG.LUFS_SCALE_LABEL_COLOR || '#8fd3d4'], + ['opt_lufsIWindowMin', CONFIG.LUFS_I_WINDOW_MIN ?? 4, 'val_lufsIWindowMin', (v)=>`${Number(v).toFixed(0)} min`], + ['opt_lufsINorm', CONFIG.LUFS_I_NORM_ENABLED, null, null, 'checkbox'], + ['opt_stopwatchDisplay', CONFIG.STOPWATCH_DISPLAY_STYLE || 'mono'], + ['opt_recFormat', CONFIG.RECORD_OUTPUT_FORMAT || 'wav'], + ['opt_recMp3Bitrate', String(CONFIG.RECORD_MP3_BITRATE_KBPS || 192)], + ['recTargetSel', CONFIG.RECORD_TARGET || 'download'], + ['opt_recAutoDownload', CONFIG.RECORD_AUTO_DOWNLOAD === true, null, null, 'checkbox'], + ['opt_recShowAb', CONFIG.RECORD_SHOW_RECORDER_AB === true, null, null, 'checkbox'], + ['opt_recAutoGap', CONFIG.RECORD_AUTO_SPLIT_GAP_SEC ?? 1.5, 'val_recAutoGap', (v)=>`${Number(v).toFixed(1)} s`], + ['opt_splitTapAction', CONFIG.SPLIT_VIEW_TAP_ACTION || 'switch'], + ['opt_splitLeftPlot', CONFIG.SPLIT_VIEW_LEFT || 'phase-wheel'], + ['opt_splitRightPlot', CONFIG.SPLIT_VIEW_RIGHT || 'realtime'], + ['opt_quadTapAction', CONFIG.QUAD_VIEW_TAP_ACTION || 'switch'], + ['opt_quadPlotTL', CONFIG.QUAD_VIEW_TL || 'phase-wheel'], + ['opt_quadPlotTR', CONFIG.QUAD_VIEW_TR || 'realtime'], + ['opt_quadPlotBL', CONFIG.QUAD_VIEW_BL || 'goniometer-rtw'], + ['opt_quadPlotBR', CONFIG.QUAD_VIEW_BR || 'none'], + ['opt_quadMeterCount', CONFIG.QUAD_VIEW_METER_COUNT ?? 0], + ['opt_quadMeter1', CONFIG.QUAD_VIEW_METER_1 || 'vu'], + ['opt_quadMeter1Pos', CONFIG.QUAD_VIEW_METER_1_POS || 'right'], + ['opt_quadMeter2', CONFIG.QUAD_VIEW_METER_2 || 'ppm-din'], + ['opt_quadMeter2Pos', CONFIG.QUAD_VIEW_METER_2_POS || 'right'], + ['opt_quadMeter3', CONFIG.QUAD_VIEW_METER_3 || 'lufs'], + ['opt_quadMeter3Pos', CONFIG.QUAD_VIEW_METER_3_POS || 'right'], + ['opt_splitMeterCount', CONFIG.SPLIT_VIEW_METER_COUNT ?? 0], + ['opt_splitMeter1', CONFIG.SPLIT_VIEW_METER_1 || 'vu'], + ['opt_splitMeter1Pos', CONFIG.SPLIT_VIEW_METER_1_POS || 'right'], + ['opt_splitMeter2', CONFIG.SPLIT_VIEW_METER_2 || 'ppm-din'], + ['opt_splitMeter2Pos', CONFIG.SPLIT_VIEW_METER_2_POS || 'right'], + ['opt_splitMeter3', CONFIG.SPLIT_VIEW_METER_3 || 'lufs'], + ['opt_splitMeter3Pos', CONFIG.SPLIT_VIEW_METER_3_POS || 'right'], + + // Hold and decay settings for Bars and VU + ['opt_vuHold', CONFIG.VU_HOLD_MS], + ['opt_vuDecay', CONFIG.VU_DECAY_DB_PER_S], + ['opt_tpHold', CONFIG.TP_HOLD_MS], + ['opt_tpDecay', CONFIG.TP_DECAY_DB_PER_S], + ]; + + for (const [id,val,disp,fmt,typ] of map) { + const el = E(id); + if (!el) continue; + + if (typ === 'checkbox') { + el.checked = !!val; + } else { + el.value = val; + } + + if (disp) { + const d = E(disp); + if (d) d.textContent = fmt ? fmt(val) : val; + } + } + + syncLinkedThresholdFields('opt_stopwatchThreshDbfs', 'opt_stopwatchThreshPpmDin', clampThresholdDbfs(CONFIG.STOPWATCH_AUTO_THRESHOLD_DBFS, -70)); + + syncSoftwarePresetControls(); + syncLinkedThresholdFields('opt_recThreshDbfs', 'opt_recThreshPpmDin', clampThresholdDbfs(CONFIG.RECORD_AUTO_THRESHOLD_DBFS, -50)); + + const gainSliderInit = E('opt_goniGain'); + if (gainSliderInit) { + gainSliderInit.disabled = !!CONFIG.GONIO_AGC_ENABLED; + const initialManual = Number.isFinite(CONFIG.GONIO_MANUAL_GAIN_DB) + ? CONFIG.GONIO_MANUAL_GAIN_DB + : (Number.isFinite(CONFIG.GONIO_DISPLAY_GAIN_DB) ? CONFIG.GONIO_DISPLAY_GAIN_DB : 0); + gainSliderInit.dataset.lastManualGain = String(initialManual); + } + const phaseGainSliderInit = E('opt_phaseGain'); + if (phaseGainSliderInit) { + phaseGainSliderInit.disabled = !!CONFIG.PHASE_AGC_ENABLED; + } + + enforcePhaseAmplitudeConstraints({ persist: false }); + + const dinToggle = E('opt_din_al_minus6'); + if (dinToggle) { + dinToggle.checked = CONFIG.PPM_DIN_MODE !== 'al_minus9'; + } + + const vuToggle = E('opt_vu_plus4'); + if (vuToggle) { + vuToggle.checked = CONFIG.VU_REF_IS_PLUS4 !== false; + } + + applyScreensaverDisabled(); + applyClockPpmDisabled(); + + const rtwOff = E('opt_ppmDinLoudOff'); + if (rtwOff) rtwOff.disabled = !CONFIG.PPM_DIN_LOUDNESS_BOXES; + + const iWin = E('opt_lufsIWindowMin'); + if (iWin) iWin.disabled = !!CONFIG.LUFS_I_NORM_ENABLED; + const mp3Wrap = E('opt_recMp3BitrateWrap'); + if (mp3Wrap) mp3Wrap.style.display = (String(CONFIG.RECORD_OUTPUT_FORMAT || 'wav') === 'mp3') ? '' : 'none'; + const mp3Sel = E('opt_recMp3Bitrate'); + if (mp3Sel) mp3Sel.disabled = String(CONFIG.RECORD_OUTPUT_FORMAT || 'wav') !== 'mp3'; + + applySplitViewOptionsDisabled(); + applyQuadViewOptionsDisabled(); +} + +function applyScreensaverDisabled() { + const enabled = CONFIG.SCREENSAVER_ENABLED !== false; + const idle = E('opt_screensaverIdleMin'); + const idleLabel = E('val_screensaverIdleMin'); + const thr = E('opt_screensaverActivityDb'); + const thrLabel = E('val_screensaverActivityDb'); + const btn = E('opt_screensaverTest'); + const modeSel = E('opt_screensaverMode'); + const glow = E('opt_screensaverGlow'); + const color = E('opt_screensaverColor'); + [idle, thr, btn, modeSel, glow, color].forEach((el) => { + if (el) el.disabled = !enabled; + }); + if (!enabled) { + if (idleLabel) idleLabel.textContent = ''; + if (thrLabel) thrLabel.textContent = ''; + } +} + +function applyClockPpmDisabled() { + const ledOpt = E('opt_clockPpmLed'); + if (!ledOpt) return; + const isPpmStyle = CONFIG.CLOCK_STYLE === 'analog-ppm' || CONFIG.CLOCK_STYLE === 'digital-ppm'; + ledOpt.disabled = !isPpmStyle; + ledOpt.parentElement?.classList?.toggle('disabled', !isPpmStyle); +} + +function applySplitViewOptionsDisabled() { + const clampCount = (v) => { + const n = Number(v); + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.min(3, n | 0)); + }; + const count = clampCount(CONFIG.SPLIT_VIEW_METER_COUNT); + const m1 = E('opt_splitMeter1'); + const m1p = E('opt_splitMeter1Pos'); + const m2 = E('opt_splitMeter2'); + const m2p = E('opt_splitMeter2Pos'); + const m3 = E('opt_splitMeter3'); + const m3p = E('opt_splitMeter3Pos'); + if (m1) m1.disabled = count < 1; + if (m1p) m1p.disabled = count < 1; + if (m2) m2.disabled = count < 2; + if (m2p) m2p.disabled = count < 2; + if (m3) m3.disabled = count < 3; + if (m3p) m3p.disabled = count < 3; +} + +function applyQuadViewOptionsDisabled() { + const clampCount = (v) => { + const n = Number(v); + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.min(3, n | 0)); + }; + const count = clampCount(CONFIG.QUAD_VIEW_METER_COUNT); + const m1 = E('opt_quadMeter1'); + const m1p = E('opt_quadMeter1Pos'); + const m2 = E('opt_quadMeter2'); + const m2p = E('opt_quadMeter2Pos'); + const m3 = E('opt_quadMeter3'); + const m3p = E('opt_quadMeter3Pos'); + if (m1) m1.disabled = count < 1; + if (m1p) m1p.disabled = count < 1; + if (m2) m2.disabled = count < 2; + if (m2p) m2p.disabled = count < 2; + if (m3) m3.disabled = count < 3; + if (m3p) m3p.disabled = count < 3; +} + +function enforcePhaseAmplitudeConstraints({ persist = false } = {}) { + const isPpmDin = CONFIG.PHASE_AMPLITUDE_MODE === 'ppm-din'; + const prevAgc = !!CONFIG.PHASE_AGC_ENABLED; + + if (isPpmDin && prevAgc) { + CONFIG.PHASE_AGC_ENABLED = false; + } + + const agcEl = E('opt_phaseAgc'); + if (agcEl) { + agcEl.disabled = isPpmDin; + agcEl.checked = isPpmDin ? false : !!CONFIG.PHASE_AGC_ENABLED; + agcEl.title = isPpmDin ? 'Nicht verfügbar bei PPM DIN Amplitude' : ''; + } + + const gainSliderEl = E('opt_phaseGain'); + if (gainSliderEl) { + gainSliderEl.disabled = !!CONFIG.PHASE_AGC_ENABLED; + } + + if (persist && prevAgc !== !!CONFIG.PHASE_AGC_ENABLED) { + saveConfig(); + } +} + +function wireHandlers(env) { + const reloadAudioIfNeeded = (force = false) => { + if (force || env?.audioReload) { + try { env?.audioReload?.(); } catch (_) {} + } + }; + const notifyPhoenixGlobalConfig = () => { + try { env?.notifyPhoenixGlobalConfig?.(); } catch (_) {} + }; + const h = (id, update, dispId, fmt, isCheckbox=false) => { + const el = E(id); + if (!el) return; + const notifyRta = /^opt_rta/i.test(id) + || id === 'opt_lufsIWindowMin' + || id === 'opt_lufsINorm'; + const notifyPpm = (id === 'opt_ppmDinFastAttack'); + const inputType = String(el.type || '').toLowerCase(); + const commitOnInput = isCheckbox || inputType === 'range' || inputType === 'color'; + + const commit = () => { + const raw = isCheckbox ? el.checked : el.value; + const ret = update(raw, el); + if (!isCheckbox && ret !== undefined) { + el.value = ret; + } + saveConfig(); + if (notifyRta) { + try { env?.notifyRtaConfig?.(); } catch (_) {} + } + if (notifyPpm) { + try { env?.notifyPpmConfig?.(); } catch (_) {} + } + try { env?.syncRealtimeGain?.(); } catch (_) {} + try { env?.syncWaveformWindow?.(); } catch (_) {} + try { env?.syncWaveformMode?.(); } catch (_) {} + try { env?.syncRtaBpo?.(); } catch (_) {} + try { env?.syncSplitView?.(); } catch (_) {} + if (dispId) { + const d = E(dispId); + if (d) { + const showVal = (ret !== undefined) ? ret : raw; + d.textContent = fmt ? fmt(showVal) : showVal; + } + } + }; + + el.oninput = commitOnInput ? commit : null; + el.onchange = commit; + if (!isCheckbox && !commitOnInput) { + el.onkeydown = (ev) => { + if (ev.key === 'Enter') { + ev.preventDefault(); + el.blur(); + } + }; + } + }; + + const wireLinkedThresholdPair = (dbfsId, ppmId, getValue, setValue, fallback, afterCommit = null) => { + const dbfsEl = E(dbfsId); + const ppmEl = E(ppmId); + if (!dbfsEl || !ppmEl) return; + + const applyDbfs = (dbfs) => { + const next = clampThresholdDbfs(dbfs, fallback); + setValue(next); + saveConfig(); + try { afterCommit?.(next); } catch (_) {} + syncLinkedThresholdFields(dbfsId, ppmId, next); + }; + + const commitDbfs = () => applyDbfs(dbfsEl.value); + const commitPpm = () => applyDbfs(ppmDinToDbfs(ppmEl.value)); + + dbfsEl.onchange = commitDbfs; + ppmEl.onchange = commitPpm; + dbfsEl.onkeydown = (ev) => { + if (ev.key === 'Enter') { + ev.preventDefault(); + dbfsEl.blur(); + } + }; + ppmEl.onkeydown = (ev) => { + if (ev.key === 'Enter') { + ev.preventDefault(); + ppmEl.blur(); + } + }; + dbfsEl.onblur = commitDbfs; + ppmEl.onblur = commitPpm; + + syncLinkedThresholdFields(dbfsId, ppmId, clampThresholdDbfs(getValue(), fallback)); + }; + + h('opt_fft', v => { + CONFIG.FFT_SIZE = clampPow2(+v, 2048, 16384); + notifyPhoenixGlobalConfig(); + return CONFIG.FFT_SIZE; + }); + h('opt_phoenixBaseUrl', v => { + CONFIG.PHOENIX_BASE_URL = String(v || '').trim() || `http://${location.hostname || '127.0.0.1'}:8789`; + reloadAudioIfNeeded(false); + return CONFIG.PHOENIX_BASE_URL; + }); + h('opt_alMarkers', v => { + CONFIG.AL_MARKERS_ENABLED = !!v; + }, null, null, true); + const setInputOffsetDb = (side, rawValue) => { + const raw = Number(rawValue); + const snapped = Math.round(raw * 10) / 10; // 0.1 dB Schritte + let val = Number.isFinite(snapped) ? snapped : -4; + val = clamp(val, -10, 10); + if (side === 'L') { + CONFIG.INPUT_OFFSET_DB_L = val; + } else { + CONFIG.INPUT_OFFSET_DB_R = val; + } + updateInputOffsetGain({ + L: CONFIG.INPUT_OFFSET_DB_L, + R: CONFIG.INPUT_OFFSET_DB_R, + }); + try { env?.updateInputOffset?.(); } catch (_) {} + return val; + }; + h('opt_inputOffsetDbL', v => setInputOffsetDb('L', v), 'val_inputOffsetDbL', (v)=>`${Number(v).toFixed(1)} dB`); + h('opt_inputOffsetDbR', v => setInputOffsetDb('R', v), 'val_inputOffsetDbR', (v)=>`${Number(v).toFixed(1)} dB`); + h('opt_monoInput', v => { + CONFIG.MONO_INPUT = !!v; + try { env?.updateMonoInput?.(); } catch (_) {} + }, null, null, true); + h('opt_lrFracDelayEnabled', v => { + CONFIG.LR_FRACTIONAL_DELAY_ENABLED = !!v; + try { env?.updateLrDelay?.(); } catch (_) {} + }, null, null, true); + h('opt_screensaverEnabled', v => { + const enabled = !!v; + CONFIG.SCREENSAVER_ENABLED = enabled; + try { env?.screensaver?.setEnabled?.(enabled); } catch (_) {} + notifyPhoenixGlobalConfig(); + applyScreensaverDisabled(); + }, null, null, true); + h('opt_screensaverMode', v => { + const mode = v === 'starfield' + ? 'starfield' + : (v === 'clock' + ? 'clock' + : (v === 'black' + ? 'black' + : 'dvd')); + CONFIG.SCREENSAVER_MODE = mode; + try { env?.screensaver?.setMode?.(mode); } catch (_) {} + return mode; + }); + h('opt_screensaverGlow', v => { + const enabled = !!v; + CONFIG.SCREENSAVER_LED_GLOW = enabled; + notifyPhoenixGlobalConfig(); + return enabled; + }, null, null, true); + h('opt_screensaverColor', v => { + const val = String(v || '#ff0000').trim() || '#ff0000'; + CONFIG.SCREENSAVER_LED_COLOR = val; + try { env?.screensaver?.setColor?.(val); } catch (_) {} + notifyPhoenixGlobalConfig(); + return val; + }); + h('opt_clockGlow', v => { + const enabled = !!v; + CONFIG.CLOCK_LED_GLOW = enabled; + return enabled; + }, null, null, true); + h('opt_clockStyle', v => { + const style = (v === 'digital') + ? 'digital' + : (v === 'analog-ppm') + ? 'analog-ppm' + : (v === 'digital-ppm') + ? 'digital-ppm' + : 'analog'; + CONFIG.CLOCK_STYLE = style; + applyClockPpmDisabled(); + return style; + }); + h('opt_clockPpmLed', v => { + CONFIG.CLOCK_PPM_LED_MODE = !!v; + return CONFIG.CLOCK_PPM_LED_MODE; + }, null, null, true); + h('opt_clockColor', v => { + const val = v || '#ff0000'; + CONFIG.CLOCK_LED_COLOR = val; + notifyPhoenixGlobalConfig(); + return val; + }); + h('opt_lrFracDelaySamples', v => { + let val = parseNumericInput(v, Number(CONFIG.LR_FRACTIONAL_DELAY_SAMPLES)); + if (!Number.isFinite(val)) val = Number(CONFIG.LR_FRACTIONAL_DELAY_SAMPLES); + if (!Number.isFinite(val)) val = 0; + val = clamp(val, -1.5, 1.5); + CONFIG.LR_FRACTIONAL_DELAY_SAMPLES = val; + try { env?.updateLrDelay?.(); } catch (_) {} + return val; + }, 'val_lrFracDelaySamples', (v)=>`${Number(v).toFixed(3)} Samples`); + h('opt_screensaverIdleMin', v => { + if (CONFIG.SCREENSAVER_ENABLED === false) return CONFIG.SCREENSAVER_IDLE_MIN; + let val = Number(v); + if (!Number.isFinite(val)) val = CONFIG.SCREENSAVER_IDLE_MIN ?? 5; + val = clamp(val, 0, 120); + CONFIG.SCREENSAVER_IDLE_MIN = val; + try { env?.screensaver?.setIdleMinutes?.(val); } catch (_) {} + notifyPhoenixGlobalConfig(); + return val; + }, 'val_screensaverIdleMin', (v)=>`${Number(v).toFixed(1)} min`); + h('opt_screensaverActivityDb', v => { + if (CONFIG.SCREENSAVER_ENABLED === false) return CONFIG.SCREENSAVER_ACTIVITY_DB; + let val = Number(v); + if (!Number.isFinite(val)) val = CONFIG.SCREENSAVER_ACTIVITY_DB ?? -50; + val = clamp(val, -120, 0); + CONFIG.SCREENSAVER_ACTIVITY_DB = val; + try { env?.screensaver?.setActivityThreshold?.(val); } catch (_) {} + notifyPhoenixGlobalConfig(); + return val; + }, 'val_screensaverActivityDb', (v)=>`${Number(v).toFixed(1)} dBFS`); + const ssTest = E('opt_screensaverTest'); + if (ssTest) { + ssTest.onclick = (e) => { + if (e && typeof e.stopPropagation === 'function') e.stopPropagation(); + if (e && typeof e.preventDefault === 'function') e.preventDefault(); + try { env?.screensaver?.triggerTest?.(); } catch (_) {} + }; + } + + const resetSlotsBtn = E('opt_resetSlots'); + if (resetSlotsBtn && !resetSlotsBtn.dataset.bound) { + resetSlotsBtn.addEventListener('click', (e) => { + if (e && typeof e.stopPropagation === 'function') e.stopPropagation(); + if (e && typeof e.preventDefault === 'function') e.preventDefault(); + try { env?.resetSlotsToDefaults?.(); } catch (_) {} + }); + resetSlotsBtn.dataset.bound = '1'; + } + h('opt_vuRef', v => CONFIG.VU_DBFS_REF = clamp(+v, -30, -6)); + h('opt_vuOff', v => CONFIG.VU_OFFSET_DB = clamp(+v, -20, +20)); + h('opt_vuHeaderValue', v => { + CONFIG.VU_HEADER_SHOW_VALUE = !!v; + return CONFIG.VU_HEADER_SHOW_VALUE; + }, null, null, true); + h('opt_ppmDinColNorm', v => { + CONFIG.PPM_DIN_COLOR_NORMAL = v; + notifyPhoenixGlobalConfig(); + return v; + }); + h('opt_ppmDinColWarn', v => { + CONFIG.PPM_DIN_COLOR_WARN = v; + notifyPhoenixGlobalConfig(); + return v; + }); + h('opt_ppmDinHeaderValue', v => { + CONFIG.PPM_DIN_HEADER_SHOW_VALUE = !!v; + return CONFIG.PPM_DIN_HEADER_SHOW_VALUE; + }, null, null, true); + h('opt_ppmDinFastAttack', v => { + const desired = !!v; + const cb = E('opt_ppmDinFastAttack'); + const acked = (() => { + try { return localStorage.getItem(PPM_DIN_FAST_WARN_KEY) === '1'; } + catch (_) { return false; } + })(); + if (desired && !acked) { + if (cb) cb.checked = false; + CONFIG.PPM_DIN_FAST_ATTACK = false; + showPpmDinFastAttackWarning(() => { + CONFIG.PPM_DIN_FAST_ATTACK = true; + if (cb) cb.checked = true; + try { saveConfig(); } catch (_) {} + try { env?.notifyPpmConfig?.(); } catch (_) {} + notifyPhoenixGlobalConfig(); + }); + return false; + } + CONFIG.PPM_DIN_FAST_ATTACK = desired; + notifyPhoenixGlobalConfig(); + return desired; + }, null, null, true); + h('opt_ppmDinLoudnessBox', v => { + const desired = !!v; + const cb = E('opt_ppmDinLoudnessBox'); + const offEl = E('opt_ppmDinLoudOff'); + const acked = (() => { + try { return localStorage.getItem(PPM_LOUD_WARN_KEY) === '1'; } + catch (_) { return false; } + })(); + if (desired && !acked) { + if (cb) cb.checked = false; + CONFIG.PPM_DIN_LOUDNESS_BOXES = false; + if (offEl) offEl.disabled = true; + showPpmLoudnessWarning(() => { + CONFIG.PPM_DIN_LOUDNESS_BOXES = true; + if (cb) cb.checked = true; + if (offEl) offEl.disabled = false; + notifyPhoenixGlobalConfig(); + }); + return false; + } + CONFIG.PPM_DIN_LOUDNESS_BOXES = desired; + if (offEl) offEl.disabled = !desired; + notifyPhoenixGlobalConfig(); + return desired; + }, null, null, true); + h('opt_ppmDinLoudOff', v => { + CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB = clamp(+v, -7, +7); + notifyPhoenixGlobalConfig(); + return CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB; + }, 'val_ppmDinLoudOff', (v)=>`${Number(v).toFixed(1)} dB`); + h('opt_ppmEbuColNorm', v => { + CONFIG.PPM_EBU_COLOR_NORMAL = v; + notifyPhoenixGlobalConfig(); + return v; + }); + h('opt_ppmEbuColWarn', v => { + CONFIG.PPM_EBU_COLOR_WARN = v; + notifyPhoenixGlobalConfig(); + return v; + }); + h('opt_ppmEbuHeaderValue', v => { + CONFIG.PPM_EBU_HEADER_SHOW_VALUE = !!v; + return CONFIG.PPM_EBU_HEADER_SHOW_VALUE; + }, null, null, true); + h('opt_hifiAlign', v => { + const mode = (String(v) === 'ppm_din_zero') ? 'ppm_din_zero' : 'ppm_din_minus5'; + CONFIG.HIFI_PEAK_ALIGNMENT = mode; + invalidateMeters(env); + return mode; + }); + + h('opt_tpOff', v => CONFIG.TP_OFFSET_DB = clamp(+v, -20, +20)); + h('opt_tpHeaderValue', v => { + CONFIG.TP_HEADER_SHOW_VALUE = !!v; + return CONFIG.TP_HEADER_SHOW_VALUE; + }, null, null, true); + h('opt_rmsOff', v => CONFIG.RMS_OFFSET_DB = clamp(+v, -20, +20)); + h('opt_rmsHeaderValue', v => { + CONFIG.RMS_HEADER_SHOW_VALUE = !!v; + return CONFIG.RMS_HEADER_SHOW_VALUE; + }, null, null, true); + h('opt_rmsTc', v => { + const mode = String(v).toLowerCase(); + if (mode === 'impulse' || mode === 'fast' || mode === 'slow' || mode === 'none') { + CONFIG.RMS_TC_MODE = mode; + CONFIG.RMS_TC_MS = null; + } + return CONFIG.RMS_TC_MODE; + }); + + // NEW: RMS-Modus + Referenzwerte + h('opt_rmsMode', v => { + const mode = String(v).toLowerCase() === 'dbfs' ? 'dbfs' : 'dbu'; + CONFIG.RMS_MODE = mode; + }); + h('opt_rmsRefDbfs', v => { CONFIG.RMS_REF_DBFS_FOR_REF_DBU = clamp(+v, -60, 0); }); + h('opt_rmsRefDbu', v => { CONFIG.RMS_REF_DBU = clamp(+v, -20, +24); }); + + h('opt_vuRedThr', v => CONFIG.VU_RED_START = clamp(+v, -20, +12)); + h('opt_tpRedThr', v => CONFIG.TP_RED_START = clamp(+v, -20, +6)); + + // RMS: Range jetzt breit genug für analog (+24 dBu) und digital (0 dBFS) + h('opt_rmsRedThr', v => CONFIG.RMS_RED_START = clamp(+v, -60, +24)); + + h('opt_vuRedBarOnly', v => CONFIG.VU_RED_BAR_ONLY = !!v, null, null, true); + h('opt_tpRedBarOnly', v => CONFIG.TP_RED_BAR_ONLY = !!v, null, null, true); + h('opt_rmsRedBarOnly', v => CONFIG.RMS_RED_BAR_ONLY = !!v, null, null, true); + + h('opt_vuColNorm', v => { + CONFIG.VU_COLOR_NORMAL = v; + notifyPhoenixGlobalConfig(); + }); + h('opt_vuColWarn', v => { + CONFIG.VU_COLOR_WARN = v; + notifyPhoenixGlobalConfig(); + }); + h('opt_tpColNorm', v => { + CONFIG.TP_COLOR_NORMAL = v; + notifyPhoenixGlobalConfig(); + }); + h('opt_tpColWarn', v => { + CONFIG.TP_COLOR_WARN = v; + notifyPhoenixGlobalConfig(); + }); + h('opt_rmsColNorm', v => { + CONFIG.RMS_COLOR_NORMAL = v; + notifyPhoenixGlobalConfig(); + }); + h('opt_rmsColWarn', v => { + CONFIG.RMS_COLOR_WARN = v; + notifyPhoenixGlobalConfig(); + }); + + h('opt_corrSmooth', v => CONFIG.CORR_SMOOTH = clamp(+v, 0.5, 0.98), 'val_corrSmooth', v=>Number(v).toFixed(2)); + h('opt_corrZeroOnSilence', v => { + CONFIG.CORR_ZERO_ON_SILENCE = !!v; + return CONFIG.CORR_ZERO_ON_SILENCE; + }, null, null, true); + h('opt_xyPoints', v => { + const raw = Number(v); + CONFIG.XY_POINTS = [128, 256, 512, 1024, 2048].includes(raw) + ? raw + : (raw < 192 ? 128 : (raw < 384 ? 256 : (raw < 768 ? 512 : (raw < 1536 ? 1024 : 2048)))); + notifyPhoenixGlobalConfig(); + return CONFIG.XY_POINTS; + }); + h('opt_xyStyle', v => CONFIG.XY_STYLE = (v === 'lines' ? 'lines' : 'points')); + h('opt_xySilenceGate', v => { + CONFIG.XY_SILENCE_GATE_ENABLED = !!v; + return CONFIG.XY_SILENCE_GATE_ENABLED; + }, null, null, true); + h('opt_xySilenceThr', v => { + let val = Number(v); + if (!Number.isFinite(val)) val = -75; + val = Math.max(-90, Math.min(-40, val)); + CONFIG.XY_SILENCE_THRESHOLD_RMS_DBFS = val; + CONFIG.CORR_SILENCE_THRESHOLD_RMS_DBFS = val; + return val; + }); + h('opt_barThin', v => { + CONFIG.METER_BAR_THIN = clamp(+v, 0.35, 0.9); + return CONFIG.METER_BAR_THIN; + }, 'val_barThin', v=>Number(v).toFixed(2)); + h('opt_headerTextColor', v => { + CONFIG.HEADER_TEXT_COLOR = v || '#ffe066'; + notifyPhoenixGlobalConfig(); + return CONFIG.HEADER_TEXT_COLOR; + }); + h('opt_goniGain', (v, el) => { + let val = clamp(+v, -35, 35); + if (!Number.isFinite(val)) val = 0; + val = Math.round(val / 5) * 5; + CONFIG.GONIO_MANUAL_GAIN_DB = val; + if (!CONFIG.GONIO_AGC_ENABLED) { + CONFIG.GONIO_DISPLAY_GAIN_DB = val; + notifyPhoenixGlobalConfig(); + } + if (el && el instanceof HTMLElement) { + el.dataset.lastManualGain = String(val); + } + return val; + }, 'val_goniGain', v => `${Number(v).toFixed(0)} dB`); + h('opt_goniAgc', v => { + const targetState = !!v; + const gainSliderEl = E('opt_goniGain'); + const gainLabel = E('val_goniGain'); + const hudToggle = document.getElementById('goniAgcChk'); + const sanitizeGain = (val) => { + let num = Number(val); + if (!Number.isFinite(num)) num = 0; + num = Math.max(-35, Math.min(35, Math.round(num / 5) * 5)); + return num; + }; + const wasEnabled = !!CONFIG.GONIO_AGC_ENABLED; + const sliderStored = gainSliderEl && gainSliderEl.dataset && typeof gainSliderEl.dataset.lastManualGain === 'string' + ? sanitizeGain(gainSliderEl.dataset.lastManualGain) + : null; + const sliderVal = (!wasEnabled && gainSliderEl) ? sanitizeGain(gainSliderEl.value) : null; + if (targetState) { + const manual = sliderVal ?? sliderStored ?? sanitizeGain(CONFIG.GONIO_DISPLAY_GAIN_DB ?? CONFIG.GONIO_MANUAL_GAIN_DB); + CONFIG.GONIO_MANUAL_GAIN_DB = manual; + CONFIG.GONIO_DISPLAY_GAIN_DB = 0; + CONFIG.GONIO_AGC_ENABLED = true; + if (gainSliderEl) { + gainSliderEl.disabled = true; + gainSliderEl.value = '0'; + gainSliderEl.dataset.lastManualGain = String(manual); + } + if (gainLabel) gainLabel.textContent = '0 dB'; + } else { + const manual = sanitizeGain(CONFIG.GONIO_MANUAL_GAIN_DB ?? sliderStored ?? sliderVal ?? CONFIG.GONIO_DISPLAY_GAIN_DB); + CONFIG.GONIO_MANUAL_GAIN_DB = manual; + CONFIG.GONIO_DISPLAY_GAIN_DB = manual; + CONFIG.GONIO_AGC_ENABLED = false; + notifyPhoenixGlobalConfig(); + if (gainSliderEl) { + gainSliderEl.disabled = false; + gainSliderEl.value = String(manual); + gainSliderEl.dataset.lastManualGain = String(manual); + } + if (gainLabel) gainLabel.textContent = `${manual} dB`; + } + if (gainSliderEl) gainSliderEl.disabled = !!CONFIG.GONIO_AGC_ENABLED; + if (hudToggle) hudToggle.checked = !!CONFIG.GONIO_AGC_ENABLED; + }, null, null, true); + h('opt_phaseGain', v => { + let val = clamp(+v, -35, 35); + if (!Number.isFinite(val)) val = 0; + val = Math.round(val / 5) * 5; + CONFIG.PHASE_DISPLAY_GAIN_DB = val; + notifyPhoenixGlobalConfig(); + return val; + }, 'val_phaseGain', v => `${Number(v).toFixed(0)} dB`); + h('opt_phaseAgc', v => { + if (CONFIG.PHASE_AMPLITUDE_MODE === 'ppm-din') { + CONFIG.PHASE_AGC_ENABLED = false; + enforcePhaseAmplitudeConstraints({ persist: false }); + return false; + } + CONFIG.PHASE_AGC_ENABLED = !!v; + const gainSliderEl = E('opt_phaseGain'); + if (CONFIG.PHASE_AGC_ENABLED) { + CONFIG.PHASE_DISPLAY_GAIN_DB = 0; + if (gainSliderEl) gainSliderEl.value = 0; + const gainLabel = E('val_phaseGain'); + if (gainLabel) gainLabel.textContent = '0 dB'; + } + if (gainSliderEl) gainSliderEl.disabled = CONFIG.PHASE_AGC_ENABLED; + notifyPhoenixGlobalConfig(); + }, null, null, true); + h('opt_phaseAmpMode', v => { + const mode = (v === 'ppm-din') ? 'ppm-din' : 'bandpass'; + CONFIG.PHASE_AMPLITUDE_MODE = mode; + enforcePhaseAmplitudeConstraints({ persist: false }); + notifyPhoenixGlobalConfig(); + return mode; + }); + h('opt_phaseTrail', v => { + CONFIG.PHASE_TRAIL_ENABLED = !!v; + const hudToggle = document.getElementById('phaseTrailChk'); + if (hudToggle) hudToggle.checked = CONFIG.PHASE_TRAIL_ENABLED; + notifyPhoenixGlobalConfig(); + }, null, null, true); + h('opt_goniGap', v => CONFIG.GONI_METER_GAP = clamp(+v, 2, 24), 'val_goniGap', v=>`${v} px`); + h('opt_goniFade', v => { CONFIG.GONIO_LINE_FADE_MS = clamp(+v, 0, 600); }, 'val_goniFade', v=>`${Math.round(v)} ms`); + h('opt_panelDividers', v => { + CONFIG.PANEL_DIVIDERS_ENABLED = !!v; + return CONFIG.PANEL_DIVIDERS_ENABLED; + }, null, null, true); + + // Hold-/Decay-Parameter + h('opt_rtaEngine', v => { CONFIG.RTA_ENGINE = (v === 'iir') ? 'iir' : 'fft'; }); + h('opt_rtaBpo', v => { + const allowed = new Set(['1_3', '1_6', '1_12']); + CONFIG.RTA_BPO_MODE = allowed.has(v) ? v : '1_6'; + notifyPhoenixGlobalConfig(); + }); + h('opt_rtaOrder', v => { + let val = Math.round(+v); + if (!Number.isFinite(val)) val = 4; + val = Math.max(2, Math.min(8, val)); + if (val % 2 !== 0) val += 1; + CONFIG.RTA_IIR_ORDER = val; + return val; + }); + h('opt_rtaFreq', v => { CONFIG.RTA_FREQ_RANGE = (v === 'lf') ? 'lf' : 'norm'; }); + h('opt_rtaDisplayGainFft', v => { CONFIG.RTA_DISPLAY_GAIN_FFT_DB = clamp(+v, -25, 25); }, 'val_rtaDisplayGainFft', v=>`${v} dB`); + h('opt_rtaDisplayGainIir', v => { CONFIG.RTA_DISPLAY_GAIN_IIR_DB = clamp(+v, -25, 25); }, 'val_rtaDisplayGainIir', v=>`${v} dB`); + h('opt_rtaLayout', v => { + const val = (v === 'rtw') ? 'rtw' : 'iec'; + CONFIG.RTA_BAR_LAYOUT = val; + }); + h('opt_rtaBarBaseColor', v => { + CONFIG.RTA_BAR_BASE_COLOR = v || '#ffe066'; + notifyPhoenixGlobalConfig(); + return CONFIG.RTA_BAR_BASE_COLOR; + }); + h('opt_rtaWeighting', v => { + const allowed = new Set(['z','a','c']); + CONFIG.RTA_WEIGHTING = allowed.has(v) ? v : 'z'; + }); + h('opt_rtaIntegration', v => { + const allowed = new Set(['impulse','fast','slow','peak']); + CONFIG.RTA_INTEGRATION = allowed.has(v) ? v : 'fast'; + }); + h('opt_rtaBallistics', v => { + const val = (v === 'peak' || v === 'both' || v === 'average') ? v : 'average'; + CONFIG.RTA_BALLISTICS_MODE = val; + return val; + }); + h('opt_rtaHoldMode', v => { + const allowed = new Set(['off','auto','manual']); + CONFIG.RTA_PEAK_HOLD_MODE = allowed.has(v) ? v : 'auto'; + }); + h('opt_rtaHoldTime', v => { CONFIG.RTA_PEAK_HOLD_SEC = clamp(+v, 0, 30); }); + h('opt_rtaDecay', v => { CONFIG.RTA_PEAK_DECAY_DB_PER_S = clamp(+v, 1, 60); }); + h('opt_rtaDisplayHold', v => { CONFIG.RTA_DISPLAY_HOLD_SEC = clamp(+v, 0, 5); }); + h('opt_spectroGamma', v => { + const num = clamp(parseFloat(v), 0.3, 1.2); + CONFIG.SPECTRO_GAMMA = num; + return num; + }, 'val_spectroGamma', (v)=>Number(v).toFixed(2)); + h('opt_spectroScroll', v => { + const allowed = new Set([0.5,1,2,4,6]); + const num = Number(v); + const val = allowed.has(num) ? num : 1; + CONFIG.SPECTRO_SCROLL_MODE = val; + return val; + }); + h('opt_peakHistScroll', v => { + const allowed = new Set([0.5,1,2,4,6]); + const num = Number(v); + const val = allowed.has(num) ? num : 1; + CONFIG.PEAK_HISTORY_SCROLL_MODE = val; + notifyPhoenixGlobalConfig(); + return val; + }); + h('opt_peakHistFill', v => { + CONFIG.PEAK_HISTORY_FILL_ENABLED = !!v; + notifyPhoenixGlobalConfig(); + return CONFIG.PEAK_HISTORY_FILL_ENABLED; + }, null, null, true); + h('opt_peakHistFillInvert', v => { + CONFIG.PEAK_HISTORY_FILL_INVERT = !!v; + notifyPhoenixGlobalConfig(); + return CONFIG.PEAK_HISTORY_FILL_INVERT; + }, null, null, true); + h('opt_waveMode', v => { + const mode = (v === 'overlay') ? 'overlay' : (v === 'diff' ? 'diff' : 'stacked'); + CONFIG.WAVEFORM_MODE = mode; + return mode; + }); + h('opt_waveWindow', v => { + const options = [0.05, 0.1, 0.5, 1, 2, 5, 10, 15]; + const num = clamp(parseFloat(v), 0.05, 15); + const closest = options.reduce((prev, curr) => Math.abs(curr - num) < Math.abs(prev - num) ? curr : prev, options[0]); + CONFIG.WAVEFORM_WINDOW_SEC = closest; + return closest; + }); + h('opt_waveColorL', v => { + CONFIG.WAVEFORM_COLOR_LEFT = v; + notifyPhoenixGlobalConfig(); + }); + h('opt_waveColorR', v => { + CONFIG.WAVEFORM_COLOR_RIGHT = v; + notifyPhoenixGlobalConfig(); + }); + h('opt_waveColorDiff', v => { + CONFIG.WAVEFORM_COLOR_DIFF = v; + notifyPhoenixGlobalConfig(); + }); + h('opt_rtaTauFast', v => { CONFIG.RTA_IIR_TAU_FAST = clamp(+v, 0.01, 1.0); }); + h('opt_rtaTauSlow', v => { CONFIG.RTA_IIR_TAU_SLOW = clamp(+v, 0.1, 5.0); }); + h('opt_rtRenderMode', v => { CONFIG.REALTIME_RENDER_STYLE = (v === 'line') ? 'line' : 'bars'; }); + h('opt_rtHold', v => CONFIG.REALTIME_BAR_HOLD_MS = clamp(+v, 100, 5000)); + h('opt_rtDecay', v => CONFIG.REALTIME_BAR_DECAY_DB_PER_S = clamp(+v, 1, 60)); + h('opt_vuHold', v => CONFIG.VU_HOLD_MS = clamp(+v, 100, 5000)); + h('opt_vuDecay', v => CONFIG.VU_DECAY_DB_PER_S = clamp(+v, 5, 60)); + h('opt_tpHold', v => CONFIG.TP_HOLD_MS = clamp(+v, 0, 5000)); + h('opt_tpDecay', v => CONFIG.TP_DECAY_DB_PER_S = clamp(+v, 1, 60)); + + // LUFS + h('opt_lufsRedThr', v => CONFIG.LUFS_RED_START = clamp(+v, -10, 0)); + h('opt_lufsYellowThr', v => CONFIG.LUFS_YELLOW_START = clamp(+v, -10, 0)); + h('opt_lufsGreenThr', v => CONFIG.LUFS_GREEN_START = clamp(+v, -10, 0)); + h('opt_lufsColI', v => { + CONFIG.LUFS_COLOR_I = v; + notifyPhoenixGlobalConfig(); + }); + h('opt_lufsColM', v => { + CONFIG.LUFS_COLOR_M = v; + notifyPhoenixGlobalConfig(); + }); + h('opt_lufsColS', v => { + CONFIG.LUFS_COLOR_S = v; + notifyPhoenixGlobalConfig(); + }); + h('opt_lufsScaleCol', v => { + CONFIG.LUFS_SCALE_LABEL_COLOR = v; + notifyPhoenixGlobalConfig(); + }); + h('opt_lufsIWindowMin', v => { + const snapped = Math.round(Number(v)); + CONFIG.LUFS_I_WINDOW_MIN = clamp(snapped, 1, 10); + notifyPhoenixGlobalConfig(); + return CONFIG.LUFS_I_WINDOW_MIN; + }, 'val_lufsIWindowMin', (v)=>`${Number(v).toFixed(0)} min`); + h('opt_lufsINorm', v => { + CONFIG.LUFS_I_NORM_ENABLED = !!v; + const win = E('opt_lufsIWindowMin'); + if (win) win.disabled = !!CONFIG.LUFS_I_NORM_ENABLED; + notifyPhoenixGlobalConfig(); + return CONFIG.LUFS_I_NORM_ENABLED; + }, null, null, true); + h('opt_stopwatchDisplay', v => { + CONFIG.STOPWATCH_DISPLAY_STYLE = (v === 'seven') ? 'seven' : 'mono'; + return CONFIG.STOPWATCH_DISPLAY_STYLE; + }); + wireLinkedThresholdPair( + 'opt_stopwatchThreshDbfs', + 'opt_stopwatchThreshPpmDin', + () => CONFIG.STOPWATCH_AUTO_THRESHOLD_DBFS, + (v) => { CONFIG.STOPWATCH_AUTO_THRESHOLD_DBFS = v; }, + -70 + ); + h('opt_recFormat', v => { + CONFIG.RECORD_OUTPUT_FORMAT = (v === 'webm') ? 'webm' : ((v === 'mp3') ? 'mp3' : 'wav'); + notifyPhoenixGlobalConfig(); + syncUI(); + return CONFIG.RECORD_OUTPUT_FORMAT; + }); + h('opt_recMp3Bitrate', v => { + const n = Number(v); + CONFIG.RECORD_MP3_BITRATE_KBPS = [64, 128, 192, 256, 320].includes(n) ? n : 192; + notifyPhoenixGlobalConfig(); + return String(CONFIG.RECORD_MP3_BITRATE_KBPS); + }); + h('recTargetSel', v => { + CONFIG.RECORD_TARGET = (v === 'analyzer' || v === 'volumio') ? v : 'download'; + if (CONFIG.RECORD_TARGET === 'analyzer' || CONFIG.RECORD_TARGET === 'volumio') { + CONFIG.RECORD_PI_TARGET = CONFIG.RECORD_TARGET; + notifyPhoenixGlobalConfig(); + } + return CONFIG.RECORD_TARGET; + }); + h('opt_recAutoDownload', v => { + CONFIG.RECORD_AUTO_DOWNLOAD = !!v; + return CONFIG.RECORD_AUTO_DOWNLOAD; + }, null, null, true); + h('opt_recShowAb', v => { + CONFIG.RECORD_SHOW_RECORDER_AB = !!v; + return CONFIG.RECORD_SHOW_RECORDER_AB; + }, null, null, true); + h('opt_recAutoGap', v => { + const n = Number(v); + const clamped = Math.max(0.5, Math.min(3, Number.isFinite(n) ? n : 1.5)); + const stepped = Math.round(clamped * 2) / 2; + CONFIG.RECORD_AUTO_SPLIT_GAP_SEC = stepped; + notifyPhoenixGlobalConfig(); + return stepped; + }, 'val_recAutoGap', (v)=>`${Number(v).toFixed(1)} s`); + wireLinkedThresholdPair( + 'opt_recThreshDbfs', + 'opt_recThreshPpmDin', + () => CONFIG.RECORD_AUTO_THRESHOLD_DBFS, + (v) => { CONFIG.RECORD_AUTO_THRESHOLD_DBFS = v; }, + -50, + () => { notifyPhoenixGlobalConfig(); } + ); + + const normSplitPlot = (v, fallback) => { + const id = String(v || ''); + const ok = + id === 'none' || + id === 'phase-wheel' || + id === 'realtime' || + id === 'goniometer-rtw' || + id === 'peak-history' || + id === 'classic-needles' || + id === 'panel' || + id === 'clock' || + id === 'waveform' || + id === 'spectrogram'; + return ok ? id : fallback; + }; + const normSplitMeter = (v, fallback) => { + const id = String(v || ''); + const ok = (id === 'none' || id === 'vu' || id === 'ppm-ebu' || id === 'ppm-din' || id === 'tp' || id === 'hifi-peak' || id === 'rms' || id === 'lufs' || id === 'stopwatch'); + return ok ? id : fallback; + }; + const normSplitPos = (v, fallback) => { + const id = String(v || ''); + return (id === 'left' || id === 'center' || id === 'right') ? id : fallback; + }; + h('opt_splitTapAction', v => { CONFIG.SPLIT_VIEW_TAP_ACTION = (String(v) === 'popup') ? 'popup' : 'switch'; }); + h('opt_splitLeftPlot', v => { CONFIG.SPLIT_VIEW_LEFT = normSplitPlot(v, 'phase-wheel'); }); + h('opt_splitRightPlot', v => { CONFIG.SPLIT_VIEW_RIGHT = normSplitPlot(v, 'realtime'); }); + h('opt_quadTapAction', v => { CONFIG.QUAD_VIEW_TAP_ACTION = (String(v) === 'popup') ? 'popup' : 'switch'; }); + h('opt_quadPlotTL', v => { CONFIG.QUAD_VIEW_TL = normSplitPlot(v, 'phase-wheel'); }); + h('opt_quadPlotTR', v => { CONFIG.QUAD_VIEW_TR = normSplitPlot(v, 'realtime'); }); + h('opt_quadPlotBL', v => { CONFIG.QUAD_VIEW_BL = normSplitPlot(v, 'goniometer-rtw'); }); + h('opt_quadPlotBR', v => { CONFIG.QUAD_VIEW_BR = normSplitPlot(v, 'none'); }); + h('opt_quadMeterCount', v => { + const raw = Number(v); + const n = Number.isFinite(raw) ? raw : 0; + CONFIG.QUAD_VIEW_METER_COUNT = clamp(n | 0, 0, 3); + applyQuadViewOptionsDisabled(); + try { env?.syncSplitView?.(); } catch (_) {} + return CONFIG.QUAD_VIEW_METER_COUNT; + }); + h('opt_quadMeter1', v => { CONFIG.QUAD_VIEW_METER_1 = normSplitMeter(v, 'vu'); }); + h('opt_quadMeter1Pos', v => { CONFIG.QUAD_VIEW_METER_1_POS = normSplitPos(v, 'right'); }); + h('opt_quadMeter2', v => { CONFIG.QUAD_VIEW_METER_2 = normSplitMeter(v, 'ppm-din'); }); + h('opt_quadMeter2Pos', v => { CONFIG.QUAD_VIEW_METER_2_POS = normSplitPos(v, 'right'); }); + h('opt_quadMeter3', v => { CONFIG.QUAD_VIEW_METER_3 = normSplitMeter(v, 'lufs'); }); + h('opt_quadMeter3Pos', v => { CONFIG.QUAD_VIEW_METER_3_POS = normSplitPos(v, 'right'); }); + h('opt_splitMeterCount', v => { + const raw = Number(v); + const n = Number.isFinite(raw) ? raw : 0; + CONFIG.SPLIT_VIEW_METER_COUNT = clamp(n | 0, 0, 3); + applySplitViewOptionsDisabled(); + return CONFIG.SPLIT_VIEW_METER_COUNT; + }); + h('opt_splitMeter1', v => { CONFIG.SPLIT_VIEW_METER_1 = normSplitMeter(v, 'vu'); }); + h('opt_splitMeter1Pos', v => { CONFIG.SPLIT_VIEW_METER_1_POS = normSplitPos(v, 'right'); }); + h('opt_splitMeter2', v => { CONFIG.SPLIT_VIEW_METER_2 = normSplitMeter(v, 'ppm-din'); }); + h('opt_splitMeter2Pos', v => { CONFIG.SPLIT_VIEW_METER_2_POS = normSplitPos(v, 'right'); }); + h('opt_splitMeter3', v => { CONFIG.SPLIT_VIEW_METER_3 = normSplitMeter(v, 'lufs'); }); + h('opt_splitMeter3Pos', v => { CONFIG.SPLIT_VIEW_METER_3_POS = normSplitPos(v, 'right'); }); + + const applyPresetRefresh = (uiState = null) => { + try { env?.applyPresetUiState?.(uiState); } catch (_) {} + syncUI(); + try { env?.updateInputOffset?.(); } catch (_) {} + try { env?.updateMonoInput?.(); } catch (_) {} + try { env?.updateLrDelay?.(); } catch (_) {} + try { env?.notifyPhoenixGlobalConfig?.(); } catch (_) {} + try { env?.notifyRtaConfig?.(); } catch (_) {} + try { env?.notifyPpmConfig?.(); } catch (_) {} + try { env?.syncRealtimeGain?.(); } catch (_) {} + try { env?.syncWaveformWindow?.(); } catch (_) {} + try { env?.syncWaveformMode?.(); } catch (_) {} + try { env?.syncRtaBpo?.(); } catch (_) {} + try { env?.syncSplitView?.(); } catch (_) {} + const rawStyle = String(uiState?.style || CONFIG.UI_LAST_STYLE || ''); + const fallbackStyle = String(uiState?.lastNonOptionStyle || CONFIG.UI_LAST_NON_OPTION_STYLE || 'realtime'); + const targetStyle = (rawStyle === 'options-panel' || rawStyle === 'options') ? fallbackStyle : (rawStyle || fallbackStyle); + try { env?.switchView?.(targetStyle); } catch (_) {} + invalidateMeters(env); + try { env?.requestRender?.(); } catch (_) {} + }; + + E('opt_softwarePreset')?.addEventListener('change', (e) => { + const next = String(e.target?.value || 'default').toLowerCase(); + CONFIG.SOFTWARE_PRESET = next; + saveConfig(); + syncUI(); + }); + + E('btnSoftwarePresetLoad')?.addEventListener('click', async () => { + const presetId = String(E('opt_softwarePreset')?.value || CONFIG.SOFTWARE_PRESET || 'default'); + try { + const result = await loadSoftwarePreset(presetId); + applyPresetRefresh(result?.uiState || result?.snapshot?.__PHOENIX_UI_STATE || null); + setTimeout(() => { + try { globalThis?.location?.reload?.(); } catch (_) {} + }, 30); + } catch (err) { + console.error('Preset load error:', err); + } + }); + + E('btnSoftwarePresetSave')?.addEventListener('click', async () => { + const presetId = String(E('opt_softwarePreset')?.value || CONFIG.SOFTWARE_PRESET || 'default'); + if (presetId === 'default') return; + if (!isPresetSaveLocalhostContext()) { + showPresetSaveRemoteNotice(); + return; + } + try { + const snapshot = env?.buildPresetSnapshot?.() || null; + await saveSoftwarePreset(presetId, snapshot); + syncUI(); + } catch (err) { + console.error('Preset save error:', err); + } + }); + + E('btnLayoutPresetSave')?.addEventListener('click', async () => { + const layoutId = String(E('opt_layoutPreset')?.value || '1'); + if (!isPresetSaveLocalhostContext()) { + showPresetSaveRemoteNotice(); + return; + } + try { + const uiState = env?.buildPresetUiState?.() || null; + await saveLayoutPreset(layoutId, uiState); + } catch (err) { + console.error('Layout preset save error:', err); + } + }); + + const activateLayoutPreset = async (layoutId) => { + try { + const result = await loadLayoutPreset(layoutId); + if (!result) return; + const sel = E('opt_layoutPreset'); + if (sel) sel.value = String(layoutId); + applyPresetRefresh(result?.uiState || result?.snapshot?.__PHOENIX_UI_STATE || null); + setTimeout(() => { + try { globalThis?.location?.reload?.(); } catch (_) {} + }, 30); + } catch (err) { + console.error('Layout preset load error:', err); + } + }; + + E('btnLayoutPresetLoad1')?.addEventListener('click', async () => { await activateLayoutPreset('1'); }); + E('btnLayoutPresetLoad2')?.addEventListener('click', async () => { await activateLayoutPreset('2'); }); + E('btnLayoutPresetLoad3')?.addEventListener('click', async () => { await activateLayoutPreset('3'); }); + E('btnLayoutPresetLoad4')?.addEventListener('click', async () => { await activateLayoutPreset('4'); }); + + E('btnSoftwarePresetExport')?.addEventListener('click', async () => { + const presetId = String(E('opt_softwarePreset')?.value || CONFIG.SOFTWARE_PRESET || 'default'); + try { + const payload = await exportSoftwarePreset(presetId); + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + const a = Object.assign(document.createElement('a'), { + href: URL.createObjectURL(blob), + download: `analyzer-preset-${payload.presetId}.json`, + }); + a.click(); + URL.revokeObjectURL(a.href); + } catch (err) { + console.error('Preset export error:', err); + } + }); + + E('impSoftwarePreset')?.addEventListener('change', async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + const presetId = String(E('opt_softwarePreset')?.value || CONFIG.SOFTWARE_PRESET || 'default'); + if (presetId === 'default') { + e.target.value = ''; + return; + } + try { + const payload = JSON.parse(await file.text()); + const result = await importSoftwarePreset(presetId, payload); + applyPresetRefresh(result?.uiState || result?.snapshot?.__PHOENIX_UI_STATE || null); + setTimeout(() => { + try { globalThis?.location?.reload?.(); } catch (_) {} + }, 30); + } catch (err) { + console.error('Preset import error:', err); + } finally { + e.target.value = ''; + } + }); + + E('btnReloadAudio')?.addEventListener('click', () => env.audioReload?.()); + E('btnOnlineUpdate')?.addEventListener('click', async () => { + const btn = E('btnOnlineUpdate'); + const original = btn?.textContent || 'Online Update'; + if (btn) { + btn.disabled = true; + btn.textContent = 'Lade...'; + } + try { + const response = await fetch(`${resolvePhoenixApiBaseUrl()}/api/v1/update/download`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + }); + const payload = await response.json().catch(() => null); + if (!response.ok || payload?.ok === false) { + throw new Error(payload?.error || `HTTP ${response.status}`); + } + showOnlineUpdateReadyNotice(); + } catch (err) { + console.error('Online update error:', err); + try { globalThis?.alert?.(`Online Update fehlgeschlagen: ${err?.message || String(err)}`); } catch (_) {} + } finally { + if (btn) { + btn.disabled = false; + btn.textContent = original; + } + } + }); + E('btnReset')?.addEventListener('click', () => { + showFactoryResetConfirm(); + }); +} + +// ---- Reset ----------------------------------------------------- +function resetDefaults() { + resetConfigToDefaults(); +} + +function isPresetSaveLocalhostContext() { + const host = String(globalThis?.location?.hostname || '').trim().toLowerCase(); + return host === 'localhost' || host === '127.0.0.1' || host === '::1'; +} + +function resolvePhoenixApiBaseUrl() { + const host = String(globalThis?.location?.hostname || '').trim() || '127.0.0.1'; + const fallback = `http://${host}:8789`; + const input = String(CONFIG?.PHOENIX_BASE_URL || '').trim() || fallback; + try { + const url = new URL(input, fallback); + const currentHost = host.toLowerCase(); + const targetHost = String(url.hostname || '').trim().toLowerCase(); + const isLoopback = (value) => value === 'localhost' || value === '127.0.0.1' || value === '::1' || value === '[::1]'; + if (!isLoopback(currentHost) && isLoopback(targetHost)) { + url.hostname = host; + } + return url.origin; + } catch (_) { + return fallback; + } +} + +function showPresetSaveRemoteNotice() { + const wrap = E('presetSaveRemoteNotice'); + const btnOk = E('presetSaveRemoteOkBtn'); + if (!wrap || !btnOk) return; + + const close = () => { + wrap.style.display = 'none'; + btnOk.removeEventListener('click', onOk); + wrap.removeEventListener('click', onBackdrop); + }; + const onOk = (e) => { + if (e && typeof e.preventDefault === 'function') e.preventDefault(); + if (e && typeof e.stopPropagation === 'function') e.stopPropagation(); + close(); + }; + const onBackdrop = (e) => { + if (e.target === wrap) close(); + }; + + btnOk.addEventListener('click', onOk); + wrap.addEventListener('click', onBackdrop); + wrap.style.display = 'flex'; +} + +function showOnlineUpdateReadyNotice() { + const wrap = E('onlineUpdateReadyNotice'); + const btnRestart = E('onlineUpdateRestartBtn'); + if (!wrap || !btnRestart) return; + + const close = () => { + wrap.style.display = 'none'; + btnRestart.removeEventListener('click', onRestart); + wrap.removeEventListener('click', onBackdrop); + }; + const onRestart = async (e) => { + if (e && typeof e.preventDefault === 'function') e.preventDefault(); + if (e && typeof e.stopPropagation === 'function') e.stopPropagation(); + btnRestart.disabled = true; + try { + await fetch(`${resolvePhoenixApiBaseUrl()}/api/v1/update/restart`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + }); + } catch (err) { + console.error('Online restart error:', err); + } + close(); + }; + const onBackdrop = (e) => { + if (e.target === wrap) close(); + }; + + btnRestart.disabled = false; + btnRestart.addEventListener('click', onRestart); + wrap.addEventListener('click', onBackdrop); + wrap.style.display = 'flex'; +} + +function showFactoryResetConfirm() { + const wrap = E('factoryResetConfirm'); + const ack = E('factoryResetAck'); + const btnYes = E('factoryResetConfirmBtn'); + const btnCancel = E('factoryResetCancelBtn'); + if (!wrap || !ack || !btnYes || !btnCancel) { + // Fallback: still allow reset if modal is missing + factoryResetStorage(); + try { location.reload(); } catch (_) {} + return; + } + + const close = () => { + wrap.style.display = 'none'; + }; + const sync = () => { + btnYes.disabled = !ack.checked; + }; + + if (!wrap.dataset.bound) { + ack.addEventListener('change', sync); + btnCancel.addEventListener('click', (e) => { + if (e && typeof e.preventDefault === 'function') e.preventDefault(); + if (e && typeof e.stopPropagation === 'function') e.stopPropagation(); + close(); + }); + btnYes.addEventListener('click', (e) => { + if (e && typeof e.preventDefault === 'function') e.preventDefault(); + if (e && typeof e.stopPropagation === 'function') e.stopPropagation(); + if (!ack.checked) return; + factoryResetStorage(); + try { location.reload(); } catch (_) {} + }); + wrap.addEventListener('click', (e) => { + if (e.target === wrap) close(); + }); + wrap.dataset.bound = '1'; + } + + ack.checked = false; + sync(); + wrap.style.display = 'flex'; +} + +function factoryResetStorage() { + const exactKeys = new Set([ + 'calibration_notice_seen_v1', + 'recorder_warning_ack_v1', + 'ppm_din_loudness_warn_ack_v1', + 'ppm_din_mode', // legacy alignment storage + ]); + const prefixes = ['analyzer_', 'MIGRATE_']; + + try { + const keys = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k) keys.push(k); + } + for (const k of keys) { + if (exactKeys.has(k) || prefixes.some((p) => k.startsWith(p))) { + try { localStorage.removeItem(k); } catch (_) {} + } + } + } catch (_) {} + + try { sessionStorage.removeItem('calibration_notice_seen_v1'); } catch (_) {} +} + +function bindAlignmentToggle(env) { + const dinToggle = E('opt_din_al_minus6'); + if (!dinToggle) return; + + const syncState = () => { + dinToggle.checked = CONFIG.PPM_DIN_MODE !== 'al_minus9'; + }; + syncState(); + + dinToggle.addEventListener('change', () => { + const mode = dinToggle.checked ? 'ebu' : 'ard'; + applyAlignmentProfile(mode); + saveConfig(); + syncUI(); + invalidateMeters(env); + }); +} + +function bindVuRefToggle(env) { + const vuToggle = E('opt_vu_plus4'); + if (!vuToggle) return; + + const syncState = () => { + vuToggle.checked = CONFIG.VU_REF_IS_PLUS4 !== false; + }; + syncState(); + + vuToggle.addEventListener('change', () => { + CONFIG.VU_REF_IS_PLUS4 = !!vuToggle.checked; + updateVuReference(); + saveConfig(); + invalidateMeters(env); + }); +} + +function invalidateMeters(env) { + try { + if (typeof env?.invalidateMeters === 'function') { + env.invalidateMeters(); + return; + } + if (typeof window !== 'undefined' && + window.__AN_REGISTRY__ && + typeof window.__AN_REGISTRY__.invalidateAll === 'function') { + window.__AN_REGISTRY__.invalidateAll(); + } + } catch (e) { + console.warn('Meter invalidate failed:', e); + } +} diff --git a/www/views/classic_needles.js b/www/views/classic_needles.js new file mode 100644 index 0000000..68cc54e --- /dev/null +++ b/www/views/classic_needles.js @@ -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; +} diff --git a/www/views/clock.js b/www/views/clock.js new file mode 100644 index 0000000..8eb2d72 --- /dev/null +++ b/www/views/clock.js @@ -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})`; +} diff --git a/www/views/goniometer_rtw.js b/www/views/goniometer_rtw.js new file mode 100644 index 0000000..98b6b5d --- /dev/null +++ b/www/views/goniometer_rtw.js @@ -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(); +} diff --git a/www/views/panel.js b/www/views/panel.js new file mode 100644 index 0000000..e2edf6f --- /dev/null +++ b/www/views/panel.js @@ -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); +} diff --git a/www/views/peak_history.js b/www/views/peak_history.js new file mode 100644 index 0000000..6bca7c8 --- /dev/null +++ b/www/views/peak_history.js @@ -0,0 +1,1010 @@ +// views/peak_history.js - Meter-Verlauf (rechter Slot) mit RTA-Layout + +import { FRAME_COLOR, GRID_MAJOR_COLOR, GRID_MINOR_COLOR, LABEL_COLOR, MID_COLOR, PANEL_BG, WARN_COLOR } from '../core/theme.js'; + +const PLOT = { left: 43, top: 70, right: 0, bottom: 18 }; +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 = 5; + +const SAMPLE_PERIOD = 0.025; // 25 ms -> ~40 Hz; bei 800 px ~20 s +const MIN_HISTORY_SEC = 10; +const MAX_HISTORY_SEC = 25; +const BELOW_BOTTOM_DB_SPAN = 20; + +const GRID_COLOR_MAJOR = GRID_MAJOR_COLOR; +const GRID_COLOR_MINOR = GRID_MINOR_COLOR; +const STATIC_LABEL_PAD_LEFT = 40; +const STATIC_LABEL_PAD_BOTTOM = 28; +const STATIC_LABEL_PAD_RIGHT = 18; + +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 VU_TICKS = [-20, -10, -7, -5, -3, 0, 1, 2, 3]; +const PPM_DIN_TICKS = [-50, -40, -30, -20, -10, -5, 0, 5]; +const PPM_EBU_TICKS = [-12, -8, -4, 0, 4, 8, 12]; +const TP_TICKS = [-60, -50, -40, -30, -20, -10, 0]; +const RMS_DBFS_TICKS = [0, -6, -12, -18, -24, -30, -40, -60]; +const RMS_DBU_TICKS = [24, 20, 10, 0, -10, -20, -30, -40, -50, -60]; +const LUFS_TICKS = [-50, -40, -30, -23, -18, -10, -5]; + +const METER_LABELS = { + 'vu': 'VU', + 'ppm-ebu': 'PPM (EBU)', + 'ppm-din': 'PPM (DIN)', + 'tp': 'True Peak', + 'rms': 'RMS', + 'lufs': 'LUFS', +}; + +export const id = 'peak-history'; + +export function init() { + return { + history: new Float32Array(0), + samplePeriod: SAMPLE_PERIOD, + write: 0, + accumDt: 0, + lastTs: 0, + meterSig: '', + meterLabel: '', + neutralNorm: 0, + neutralValue: 0, + rmsSmooth: null, + staticLayer: null, + }; +} + +export function resize() {} +export function destroy(state) { + state.staticLayer = null; +} + +export async function render(env, state) { + const { ctx: g, rect, config: CONFIG } = env; + const plotX = PLOT.left; + const plotY = Number.isFinite(env?.topInset) ? Number(env.topInset) : PLOT.top; + const slotList = env.slots ? env.slots(id) : null; + const selected = (slotList && slotList[0]) || 'vu'; + const showMeter = selected !== 'none'; // Meter als Datenquelle (History) + const embedded = !!env?.embedded; + const showMeterPanel = showMeter && !embedded; // im Split/Quad keinen extra Meter rechts zeichnen + const plotW = Math.max(0, (rect.w - PLOT.right) - (showMeterPanel ? (METER_WIDTH + METER_GAP) : 0) - plotX); + const plotH = rect.h - plotY - PLOT.bottom; + const meterRects = showMeterPanel ? computeMeterRects(plotX, plotY, plotW, plotH) : null; + + if (!showMeter) { + g.save(); + g.fillStyle = PANEL_BG; + g.fillRect(rect.x, rect.y, rect.w, rect.h); + g.font = '14px ui-monospace, monospace'; + g.fillStyle = '#9aa'; + g.textAlign = 'left'; + const y = plotY >= 40 ? (plotY - 20) : (plotY + 22); + g.fillText('Benötigt einen belegten Slot — bitte wähle ein Meter im HUD.', plotX + 10, y); + redrawPlotEdges(g, plotX, plotY, plotW, plotH, CONFIG, { y: plotY, h: plotH }); + g.restore(); + return; + } + + const meterId = selected || 'vu'; + const meterInfo = resolveMeterInfo(env, state, meterId); + const samplePeriod = resolveHistorySamplePeriod(CONFIG); + state.samplePeriod = samplePeriod; + const desiredLen = clampHistorySamples(plotW); + + if (!state.history || state.history.length !== desiredLen) { + resizeHistory(state, desiredLen, meterInfo.neutralValue); + } + if (state.meterSig !== meterInfo.signature) { + resetHistory(state, meterInfo.neutralValue); + state.meterSig = meterInfo.signature; + } + state.neutralNorm = meterInfo.neutralNorm; + state.neutralValue = meterInfo.neutralValue; + state.meterLabel = meterInfo.label; + + const now = performance.now(); + + const wakeTs = Number.isFinite(env?.screensaverWakeTs) ? Number(env.screensaverWakeTs) : 0; + if (wakeTs && state?._lastWakeTs !== wakeTs) { + // Nach Screensaver-Wake: keine "Backfill"-Samples über die gesamte History. + state._lastWakeTs = wakeTs; + state.lastTs = now; + state.accumDt = 0; + } + + const rawDt = state.lastTs ? Math.max(0, (now - state.lastTs) / 1000) : 0; + // Wenn Render eine Weile pausiert war (z.B. Screensaver), nicht die komplette Zeit in einem Frame nachsamplen. + const dt = rawDt > 0.5 ? 0 : rawDt; + state.lastTs = now; + state.accumDt += dt; + const lastSampleTs = Number.isFinite(env?.audio?.lastSampleTs) ? Number(env.audio.lastSampleTs) : 0; + const audioAgeMs = lastSampleTs ? (now - lastSampleTs) : Infinity; + const audioFresh = Number.isFinite(audioAgeMs) && audioAgeMs >= 0 && audioAgeMs <= 250; + const sampleValue = (env.audio?.alive && audioFresh && Number.isFinite(meterInfo.value)) + ? meterInfo.value + : meterInfo.neutralValue; + while (state.accumDt >= samplePeriod) { + pushSample(state, sampleValue); + state.accumDt -= samplePeriod; + } + + const valueRect = meterRects?.innerRect || computeValueRect(plotX, plotY, plotW, plotH); + const frameRect = { y: plotY, h: plotH }; + drawCachedHistoryStaticLayer( + g, + state, + plotX, + plotY, + plotW, + plotH, + CONFIG, + meterInfo, + samplePeriod, + state.history.length, + valueRect, + frameRect, + ); + + drawHistoryLine( + g, + state.history, + meterInfo, + meterInfo.warnValue, + CONFIG, + plotX, + plotY, + plotW, + plotH, + valueRect, + frameRect, + !!CONFIG?.PEAK_HISTORY_FILL_ENABLED, + !!CONFIG?.PEAK_HISTORY_FILL_INVERT, + state.write | 0, + ); + + if (!env.audio?.alive || !meterInfo.hasValue) { + drawWaiting(g, plotX, plotY, !env.audio?.alive ? 'Warte auf Audio ...' : 'Slot ohne Daten ...'); + } + + if (showMeterPanel) await drawMeter(env, plotX, plotY, plotW, plotH, meterRects); +} + +function drawCachedHistoryStaticLayer( + g, + state, + x0, + y0, + w, + h, + CONFIG, + meterInfo, + samplePeriod, + historyLen, + valueRect = null, + frameRect = null, +) { + const gutterL = Number.isFinite(CONFIG?.AXIS_GUTTER_LEFT) + ? Math.max(8, Number(CONFIG.AXIS_GUTTER_LEFT)) + : 14; + const padLeft = gutterL + STATIC_LABEL_PAD_LEFT; + const padRight = STATIC_LABEL_PAD_RIGHT; + const padBottom = STATIC_LABEL_PAD_BOTTOM; + const key = [ + Math.round(w), + Math.round(h), + Math.round(x0), + Math.round(y0), + padLeft, + padRight, + padBottom, + meterInfo?.signature || '', + samplePeriod, + historyLen, + valueRect?.y ?? y0, + valueRect?.h ?? h, + frameRect?.y ?? y0, + frameRect?.h ?? h, + ].join('|'); + + const needsRebuild = !state.staticLayer + || state.staticLayer.key !== key + || state.staticLayer.width !== Math.round(w + padLeft + padRight) + || state.staticLayer.height !== Math.round((frameRect?.h ?? h) + padBottom); + + if (needsRebuild) { + const canvas = document.createElement('canvas'); + const cw = Math.max(1, Math.round(w + padLeft + padRight)); + const ch = Math.max(1, Math.round((frameRect?.h ?? h) + padBottom)); + canvas.width = cw; + canvas.height = ch; + const cg = canvas.getContext('2d'); + if (cg) { + drawHistoryGrid( + cg, + padLeft, + 0, + w, + h, + CONFIG, + meterInfo, + samplePeriod, + historyLen, + valueRect ? { ...valueRect, x: padLeft, y: (valueRect.y ?? y0) - y0 } : null, + frameRect ? { ...frameRect, y: (frameRect.y ?? y0) - y0 } : null, + ); + redrawPlotEdges( + cg, + padLeft, + 0, + w, + h, + CONFIG, + frameRect ? { ...frameRect, y: (frameRect.y ?? y0) - y0 } : null, + ); + } + state.staticLayer = { key, canvas, width: cw, height: ch }; + } + + g.drawImage(state.staticLayer.canvas, x0 - padLeft, y0); +} + +function drawWaiting(g, plotX, plotY, msg = 'Warte auf Audio ...') { + g.save(); + g.fillStyle = '#9aa'; + g.textAlign = 'left'; + const y = plotY >= 40 ? (plotY - 26) : (plotY + 22); + g.fillText(msg, plotX + 10, y); + g.restore(); +} + +function drawHistoryGrid( + g, + x0, + y0, + w, + h, + CONFIG, + meterInfo, + samplePeriod, + historyLen, + valueRect = null, + frameRect = null, +) { + const gutterL = Number.isFinite(CONFIG?.AXIS_GUTTER_LEFT) + ? Math.max(8, Number(CONFIG.AXIS_GUTTER_LEFT)) + : 14; + const gridY = valueRect?.y ?? y0; + const gridH = valueRect?.h ?? h; + const boxY = frameRect?.y ?? y0; + const boxH = frameRect?.h ?? h; + const totalSec = Math.max(samplePeriod * Math.max(1, historyLen - 1), samplePeriod); + const timeStep = pickTimeStep(totalSec); + const toNorm = meterInfo.toNorm || ((v) => { + const bottom = meterInfo.range?.bottom ?? -60; + const top = meterInfo.range?.top ?? 0; + return clamp01((clamp(v, bottom, top) - bottom) / (top - bottom || 1)); + }); + const yTicks = (Array.isArray(meterInfo.ticks) && meterInfo.ticks.length) + ? meterInfo.ticks + : buildDefaultTicks(meterInfo.range); + + g.save(); + g.strokeStyle = FRAME_COLOR; + g.lineWidth = 2; + g.strokeRect(x0 - gutterL, boxY, w + gutterL, boxH); + g.font = 'bold 14px ui-monospace, monospace'; + g.fillStyle = LABEL_COLOR; + g.textAlign = 'right'; + let lastLabelY = Number.NaN; + const minLabelGap = 14; + + for (const v of yTicks) { + const y = gridY + (1 - toNorm(v)) * gridH; + if (!Number.isFinite(y)) continue; + g.strokeStyle = GRID_COLOR_MAJOR; + g.lineWidth = 1.2; + g.beginPath(); + g.moveTo(x0, y); + g.lineTo(x0 + w, y); + g.stroke(); + const labelY = y + 4; + if (!Number.isFinite(lastLabelY) || Math.abs(labelY - lastLabelY) >= minLabelGap) { + g.fillText(String(v), (x0 - gutterL) - 6, labelY); + lastLabelY = labelY; + } + } + + g.textAlign = 'center'; + g.fillStyle = LABEL_COLOR; + const labelY = boxY + boxH + 18; + const drawnTimeLabels = new Set(); + const drawTimeLabel = (t, forceEdge = false) => { + const key = Math.round(t * 1000); + if (drawnTimeLabels.has(key)) return; + drawnTimeLabels.add(key); + const x = x0 + w - (t / totalSec) * w; + if (x > x0 + 0.75 && x < x0 + w - 0.75) { + g.strokeStyle = GRID_COLOR_MINOR; + g.lineWidth = 1; + g.beginPath(); + g.moveTo(x, boxY); + g.lineTo(x, boxY + boxH); + g.stroke(); + } + const isNow = t < 1e-3; + let label = isNow ? 'now' : `-${formatSeconds(t)}`; + if (forceEdge && w <= 320 && !isNow) { + label = label.replace(/s$/, ''); + } + if (isNow) { + g.textAlign = 'right'; + g.fillText(label, x - 2, labelY); + } else if (forceEdge) { + g.textAlign = 'left'; + g.fillText(label, x + 2, labelY); + } else { + g.textAlign = 'center'; + g.fillText(label, x, labelY); + } + }; + for (let t = 0; t <= totalSec + 1e-6; t += timeStep) { + drawTimeLabel(t, false); + } + drawTimeLabel(totalSec, true); + g.restore(); +} + +function drawHistoryLine( + g, + history, + meterInfo, + warnValue, + CONFIG, + x0, + y0, + w, + h, + valueRect = null, + frameRect = null, + fillEnabled = false, + fillInvert = false, + writeIndex = 0, +) { + if (!history || history.length < 2) return; + const gutterL = Number.isFinite(CONFIG?.AXIS_GUTTER_LEFT) + ? Math.max(8, Number(CONFIG.AXIS_GUTTER_LEFT)) + : 14; + const leftEdgeX = x0 - gutterL; + const totalWidth = w + gutterL; + const step = totalWidth / Math.max(1, history.length - 1); + const lineY = valueRect?.y ?? y0; + const lineH = valueRect?.h ?? h; + const frameY = frameRect?.y ?? y0; + const frameH = frameRect?.h ?? h; + const frameBottomY = frameY + frameH; + const baseY = fillInvert ? frameY : frameBottomY; + + const warnRaw = Number.isFinite(warnValue) ? warnValue : null; + const rangeBottom = Number.isFinite(meterInfo?.range?.bottom) ? meterInfo.range.bottom : -60; + const rangeTop = Number.isFinite(meterInfo?.range?.top) ? meterInfo.range.top : 0; + const tickVals = Array.isArray(meterInfo?.ticks) && meterInfo.ticks.length ? meterInfo.ticks.slice() : buildDefaultTicks(meterInfo?.range); + const belowBottomSpan = Math.max(1, BELOW_BOTTOM_DB_SPAN); + const normFn = typeof meterInfo?.toNorm === 'function' + ? (v) => clamp01(meterInfo.toNorm(v)) + : (v) => clamp01((v - rangeBottom) / ((rangeTop - rangeBottom) || 1)); + const valueToY = (v) => { + if (!Number.isFinite(v)) return lineY + lineH; + if (v >= rangeBottom) { + return lineY + (1 - normFn(v)) * lineH; + } + const t = clamp01((rangeBottom - v) / belowBottomSpan); + return (lineY + lineH) + t * Math.max(0, frameBottomY - (lineY + lineH)); + }; + const warnNorm = warnRaw !== null ? clamp01(normFn(warnRaw)) : null; + const warnY = warnNorm !== null ? lineY + (1 - warnNorm) * lineH : null; + + const len = history.length | 0; + const wi = ((writeIndex | 0) % len + len) % len; + const valueAt = (i) => history[(wi + (i | 0)) % len]; + + const iterateSegments = (cb) => { + for (let i = 0; i < len - 1; i++) { + const v1 = valueAt(i); + const v2 = valueAt(i + 1); + const x1 = leftEdgeX + i * step; + const x2 = leftEdgeX + (i + 1) * step; + const y1 = valueToY(v1); + const y2 = valueToY(v2); + + const over1 = warnRaw !== null && v1 > warnRaw; + const over2 = warnRaw !== null && v2 > warnRaw; + if (warnRaw === null || warnY === null || over1 === over2) { + cb(x1, y1, x2, y2, over1); + continue; + } + + const denom = v2 - v1; + if (!Number.isFinite(denom) || Math.abs(denom) < 1e-9) { + cb(x1, y1, x2, y2, over1); + continue; + } + + const t = (warnRaw - v1) / denom; + const clampedT = Math.min(1, Math.max(0, t)); + const xCross = x1 + clampedT * (x2 - x1); + cb(x1, y1, xCross, warnY, over1); + cb(xCross, warnY, x2, y2, over2); + } + }; + + const strokeSegments = (lineWidth, colorNorm, colorWarn) => { + g.lineWidth = lineWidth; + let lastColor = null; + let pathOpen = false; + iterateSegments((x1, y1, x2, y2, red) => { + const color = red ? colorWarn : colorNorm; + if (color !== lastColor) { + if (pathOpen && lastColor !== null) { + g.strokeStyle = lastColor; + g.stroke(); + } + g.beginPath(); + g.moveTo(x1, y1); + pathOpen = true; + } + g.lineTo(x2, y2); + lastColor = color; + }); + if (pathOpen && lastColor !== null) { + g.strokeStyle = lastColor; + g.stroke(); + } + }; + + g.save(); + if (fillEnabled) { + const fillNorm = 'rgba(255,224,102,0.18)'; + const fillWarn = 'rgba(255,59,59,0.18)'; + iterateSegments((x1, y1, x2, y2, red) => { + g.beginPath(); + g.moveTo(x1, y1); + g.lineTo(x2, y2); + g.lineTo(x2, baseY); + g.lineTo(x1, baseY); + g.closePath(); + g.fillStyle = red ? fillWarn : fillNorm; + g.fill(); + }); + } + strokeSegments(3.4, 'rgba(255,224,102,0.35)', 'rgba(255,59,59,0.35)'); + strokeSegments(1.6, MID_COLOR, WARN_COLOR); + g.restore(); +} + +function redrawPlotEdges(g, x, y, w, h, CONFIG, frameRect = null) { + const edgeY = frameRect?.y ?? y; + const edgeH = frameRect?.h ?? h; + 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(x - gutterL, edgeY); + g.lineTo(x + w, edgeY); + g.stroke(); + g.beginPath(); + g.moveTo(x - gutterL, edgeY + edgeH); + g.lineTo(x + w, edgeY + edgeH); + g.stroke(); + g.restore(); +} + +function computeMeterRects(plotX, plotY, plotW, plotH) { + const meterRect = { x: plotX + plotW + METER_GAP, y: plotY, w: METER_WIDTH, h: plotH }; + 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, + }; + return { meterRect, innerRect }; +} + +function computeValueRect(plotX, plotY, plotW, plotH) { + const shrink = Math.max(0, METER_SLOT_SHRINK); + const innerHeight = Math.max( + 40, + plotH - METER_PAD_TOP - METER_PAD_BOTTOM - shrink - METER_EXTRA_BOTTOM_PAD, + ); + const innerOffset = shrink / 2; + return { + x: plotX, + y: plotY + METER_PAD_TOP + innerOffset, + w: plotW, + h: innerHeight, + }; +} + +async function drawMeter(env, plotX, plotY, plotW, plotH, meterRects = null) { + const { meterRect, innerRect } = meterRects || computeMeterRects(plotX, plotY, plotW, plotH); + const { ctx: g, config: CONFIG, meters } = env; + 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 slotList = env.slots ? env.slots(id) : null; + const active = (slotList && slotList[0]) || 'vu'; + if (active === 'none') return; + 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 resolveMeterInfo(env, state, meterId) { + const cfg = env.config || {}; + const shared = env.meters?.getState?.(meterId) || null; + if (meterId !== 'rms') state.rmsSmooth = null; + + switch (meterId) { + case 'ppm-din': + return samplePpmDin(cfg, shared); + case 'ppm-ebu': + return samplePpmEbu(cfg, shared); + case 'tp': + return sampleTp(cfg, shared); + case 'rms': + return sampleRms(cfg, shared, state); + case 'lufs': + return sampleLufs(cfg, shared); + case 'vu': + default: + return sampleVu(cfg, shared, meterId); + } +} + +function sampleVu(cfg, shared) { + const bottom = -20; + const top = 3; + const effOff = Number(cfg?.VU_OFFSET_DB) || 0; + const offCorr = effOff - (shared?.offset || 0); + const warnVal = Number.isFinite(cfg?.VU_RED_START) ? cfg.VU_RED_START : 0; + const vL = Number.isFinite(shared?.values?.L) ? shared.values.L : bottom; + const vR = Number.isFinite(shared?.values?.R) ? shared.values.R : bottom; + const val = clamp(Math.max(vL, vR) + offCorr, bottom, top); + const toNorm = (db) => deflectionFrac(clamp(db, bottom, top)); + const norm = toNorm(val); + return finalizeMeterInfo({ + id: 'vu', + label: METER_LABELS['vu'], + value: val, + unit: 'VU', + norm, + toNorm, + range: { bottom, top }, + ticks: VU_TICKS, + signature: ['vu', bottom, top, effOff, shared?.offset || 0].join('|'), + hasValue: !!shared, + warnValue: warnVal, + }); +} + +function samplePpmDin(cfg, shared) { + const bottom = Number.isFinite(cfg?.PPM_DIN_BOTTOM) ? cfg.PPM_DIN_BOTTOM : -50; + const top = Number.isFinite(cfg?.PPM_DIN_TOP) ? cfg.PPM_DIN_TOP : 5; + const neutralValue = bottom - 12; + const baseMode = (cfg?.PPM_DIN_MODE === 'al_minus6') ? -6 : -9; + const effOff = baseMode + (Number(cfg?.PPM_DIN_TRIM_DB) || 0); + const offCorr = effOff - (shared?.offset || 0); + const warnVal = Number.isFinite(cfg?.PPM_DIN_RED_START) ? cfg.PPM_DIN_RED_START : 0; + const vL = Number.isFinite(shared?.values?.L) ? shared.values.L : bottom; + const vR = Number.isFinite(shared?.values?.R) ? shared.values.R : bottom; + const val = Math.min(Math.max(vL, vR) + offCorr, top); + const toNorm = (db) => { + const clamped = clamp(db, bottom, top); + const pos = interpolateScale(DIN_SCALE, clamped); + const normBottom = interpolateScale(DIN_SCALE, bottom); + const span = Math.max(1e-6, 1 - normBottom); + return clamp01((pos - normBottom) / span); + }; + const norm = toNorm(val); + return finalizeMeterInfo({ + id: 'ppm-din', + label: METER_LABELS['ppm-din'], + value: val, + unit: 'dB', + norm, + toNorm, + range: { bottom, top }, + ticks: PPM_DIN_TICKS, + signature: ['ppm-din', bottom, top, effOff, shared?.offset || 0].join('|'), + hasValue: !!shared, + warnValue: warnVal, + neutralValue, + }); +} + +function samplePpmEbu(cfg, shared) { + const bottom = Number.isFinite(cfg?.PPM_EBU_BOTTOM) ? cfg.PPM_EBU_BOTTOM : -12; + const top = Number.isFinite(cfg?.PPM_EBU_TOP) ? cfg.PPM_EBU_TOP : +14; + const effOff = Number(cfg?.PPM_EBU_OFFSET) || 0; + const offCorr = effOff - (shared?.offset || 0); + const warnVal = Number.isFinite(cfg?.PPM_EBU_RED_START) ? cfg.PPM_EBU_RED_START : 9; + const vL = Number.isFinite(shared?.values?.L) ? shared.values.L : bottom; + const vR = Number.isFinite(shared?.values?.R) ? shared.values.R : bottom; + const val = clamp(Math.max(vL, vR) + offCorr, bottom, top); + const toNorm = (db) => { + const c = clamp(db, bottom, top); + return clamp01((c - bottom) / (top - bottom || 1)); + }; + const norm = toNorm(val); + return finalizeMeterInfo({ + id: 'ppm-ebu', + label: METER_LABELS['ppm-ebu'], + value: val, + unit: 'dB', + norm, + toNorm, + range: { bottom, top }, + ticks: PPM_EBU_TICKS, + signature: ['ppm-ebu', bottom, top, effOff, shared?.offset || 0].join('|'), + hasValue: !!shared, + warnValue: warnVal, + }); +} + +function sampleTp(cfg, shared) { + const bottom = TP_PERCENT_SCALE[0].db; + const top = TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].db; + const effOff = Number(cfg?.TP_OFFSET_DB) || 0; + const offCorr = effOff - (shared?.offset || 0); + const warnVal = Number.isFinite(cfg?.TP_RED_START) ? cfg.TP_RED_START : -1; + const vL = Number.isFinite(shared?.values?.L) ? shared.values.L : bottom; + const vR = Number.isFinite(shared?.values?.R) ? shared.values.R : bottom; + const val = clamp(Math.max(vL, vR) + offCorr, bottom, top); + const toNorm = (db) => interpolateTp(clamp(db, bottom, top)); + const norm = toNorm(val); + return finalizeMeterInfo({ + id: 'tp', + label: METER_LABELS['tp'], + value: val, + unit: 'dBTP', + norm, + toNorm, + range: { bottom, top }, + ticks: TP_TICKS, + signature: ['tp', bottom, top, effOff, shared?.offset || 0].join('|'), + hasValue: !!shared, + warnValue: warnVal, + }); +} + +function sampleRms(cfg, shared, state) { + const mode = String(cfg?.RMS_MODE || 'dbfs').toLowerCase(); + const isDBU = mode === 'dbu'; + const bottom = -60; + const top = isDBU ? 24 : 0; + const refDbfs = Number.isFinite(cfg?.RMS_REF_DBFS_FOR_REF_DBU) ? cfg.RMS_REF_DBFS_FOR_REF_DBU : -18; + const refDbu = Number.isFinite(cfg?.RMS_REF_DBU) ? cfg.RMS_REF_DBU : 0; + const warnVal = Number.isFinite(cfg?.RMS_RED_START) + ? cfg.RMS_RED_START + : (isDBU ? 20 : 0); + const vL = Number.isFinite(shared?.values?.L) ? shared.values.L : bottom; + const vR = Number.isFinite(shared?.values?.R) ? shared.values.R : bottom; + const base = Math.max(vL, vR); + const smoothed = applyRmsSmoothing(state, base, cfg); + const display = isDBU ? (smoothed - refDbfs + refDbu) : smoothed; + const val = clamp(display, bottom, top); + const toNorm = (db) => { + const c = clamp(db, bottom, top); + if (isDBU) { + if (c <= bottom) return 0; + if (c <= -20) { const t = (c + 60) / 40; return Math.pow(t, 1.6) * 0.45; } + if (c <= 0) { const t = (c + 20) / 20; return 0.45 + t * 0.25; } + if (c <= 20) { const t = (c) / 20; return 0.70 + t * 0.25; } + if (c <= 24) { const t = (c - 20) / 4; return 0.95 + t * 0.05; } + return 1; + } + return clamp01((c - bottom) / (top - bottom || 1)); + }; + const norm = toNorm(val); + return finalizeMeterInfo({ + id: 'rms', + label: METER_LABELS['rms'], + value: val, + unit: isDBU ? 'dBu' : 'dBFS', + norm, + toNorm, + range: { bottom, top }, + ticks: isDBU ? RMS_DBU_TICKS : RMS_DBFS_TICKS, + signature: ['rms', mode, refDbfs, refDbu, cfg?.RMS_TC_MODE, cfg?.RMS_TC_MS].join('|'), + hasValue: !!shared, + warnValue: warnVal, + }); +} + +function applyRmsSmoothing(state, value, cfg) { + const tauMs = resolveRmsTau(cfg); + if (!Number.isFinite(tauMs) || tauMs <= 0) { + state.rmsSmooth = null; + return value; + } + const now = performance.now(); + if (!state.rmsSmooth || !Number.isFinite(state.rmsSmooth.value)) { + state.rmsSmooth = { value, lastTs: now }; + return value; + } + const dt = Math.max(1 / 240, (now - (state.rmsSmooth.lastTs || now)) / 1000); + state.rmsSmooth.lastTs = now; + const alpha = 1 - Math.exp(-dt / (tauMs / 1000)); + state.rmsSmooth.value += alpha * (value - state.rmsSmooth.value); + return state.rmsSmooth.value; +} + +function resolveRmsTau(cfg = {}) { + const mode = String(cfg.RMS_TC_MODE || 'fast').toLowerCase(); + const custom = Number(cfg.RMS_TC_MS); + if (Number.isFinite(custom) && custom > 0) return custom; + switch (mode) { + case 'impulse': return 35; + case 'slow': return 1000; + case 'none': return 0; + case 'fast': + default: return 125; + } +} + +function sampleLufs(cfg, shared) { + const bottom = -50; + const top = -5; + const raw = Number.isFinite(shared?.shortTerm) + ? shared.shortTerm + : Number.isFinite(shared?.momentary) + ? shared.momentary + : Number.isFinite(shared?.integ) + ? shared.integ + : bottom; + const warnVal = Number.isFinite(cfg?.LUFS_RED_START) ? cfg.LUFS_RED_START : -1; + const val = clamp(raw, bottom, top); + const toNorm = (v) => { + const c = clamp(v, bottom, top); + return clamp01((c - bottom) / (top - bottom || 1)); + }; + const norm = toNorm(val); + return finalizeMeterInfo({ + id: 'lufs', + label: METER_LABELS['lufs'], + value: val, + unit: 'LUFS', + norm, + toNorm, + range: { bottom, top }, + ticks: LUFS_TICKS, + signature: ['lufs', bottom, top].join('|'), + hasValue: !!shared, + warnValue: warnVal, + }); +} + +function finalizeMeterInfo(info) { + const neutral = clamp01(info.toNorm ? info.toNorm(info.range?.bottom ?? 0) : 0); + const warnNorm = (info.toNorm && Number.isFinite(info.warnValue)) + ? clamp01(info.toNorm(info.warnValue)) + : null; + return Object.assign( + { + hasValue: typeof info.hasValue === 'boolean' ? info.hasValue : Number.isFinite(info?.norm), + neutralNorm: neutral, + neutralValue: Number.isFinite(info.neutralValue) ? info.neutralValue : (info.range?.bottom ?? 0), + signature: info.signature || info.id || 'meter', + label: info.label || 'Meter', + unit: info.unit || 'dB', + ticks: info.ticks || [], + range: info.range || { bottom: 0, top: 1 }, + warnValue: Number.isFinite(info.warnValue) ? info.warnValue : null, + warnNorm, + }, + info, + ); +} + +function interpolateScale(table, db) { + if (!Array.isArray(table) || !table.length) return 0; + if (db <= table[0].db) return table[0].pos ?? table[0].frac ?? 0; + if (db >= table[table.length - 1].db) return table[table.length - 1].pos ?? table[table.length - 1].frac ?? 1; + 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; + const start = prev.pos ?? prev.frac ?? 0; + const end = curr.pos ?? curr.frac ?? 1; + return start + t * (end - start); + } + } + return table[table.length - 1].pos ?? table[table.length - 1].frac ?? 1; +} + +function interpolateTp(db) { + return interpolateScale(TP_PERCENT_SCALE, db); +} + +function deflectionFrac(db) { + return interpolateScale(VU_DEFLECTION, db); +} + +function resolveHistoryScrollFactor(CONFIG) { + const val = Number(CONFIG?.PEAK_HISTORY_SCROLL_MODE ?? 1); + const allowed = [0.5, 1, 2, 4, 6]; + return allowed.includes(val) ? val : 1; +} + +function resolveHistorySamplePeriod(CONFIG) { + const factor = resolveHistoryScrollFactor(CONFIG); + const safeFactor = Number.isFinite(factor) && factor > 0 ? factor : 1; + return SAMPLE_PERIOD / safeFactor; +} + +function clampHistorySamples(plotW) { + // Halte den sichtbaren Zeitbereich konsistent: MIN/MAX Sekunden bleiben + // an der Basis-Sample-Periode ausgerichtet, während die Scroll-Geschwindigkeit + // über die effektive Sample-Periode (SAMPLE_PERIOD / factor) die Zeitachse + // streckt oder staucht. So differenziert sich die Zeitskala bei langsam/normal/schnell. + const basePeriod = SAMPLE_PERIOD; + const minSamples = Math.round(MIN_HISTORY_SEC / basePeriod); + const maxSamples = Math.round(MAX_HISTORY_SEC / basePeriod); + const target = Math.max(minSamples, Math.round(plotW)); + return Math.max(minSamples, Math.min(maxSamples, target)); +} + +function resizeHistory(state, length, fillValue) { + const safeFill = Number.isFinite(fillValue) ? fillValue : 0; + if (!state.history || state.history.length === 0) { + state.history = new Float32Array(length); + state.history.fill(safeFill); + state.write = 0; + return; + } + if (state.history.length === length) return; + const old = state.history; + const oldLen = old.length | 0; + const copyCount = Math.min(oldLen, length); + const newArr = new Float32Array(length); + newArr.fill(safeFill); + // Keep newest values (old array is a ring; state.write points to oldest/next overwrite). + const oldWrite = ((state.write | 0) % oldLen + oldLen) % oldLen; + const valueAtChrono = (i) => old[(oldWrite + (i | 0)) % oldLen]; + const start = Math.max(0, oldLen - copyCount); + for (let i = 0; i < copyCount; i++) { + newArr[length - copyCount + i] = valueAtChrono(start + i); + } + state.history = newArr; + state.write = 0; +} + +function resetHistory(state, fillValue) { + const safeFill = Number.isFinite(fillValue) ? fillValue : 0; + if (!state.history || state.history.length === 0) return; + state.history.fill(safeFill); + state.accumDt = 0; + state.write = 0; +} + +function pushSample(state, value) { + if (!state.history || state.history.length === 0) return; + const v = Number.isFinite(value) ? value : state.neutralValue || 0; + const len = state.history.length | 0; + const wi = ((state.write | 0) % len + len) % len; + state.history[wi] = v; + state.write = (wi + 1) % len; +} + +function buildDefaultTicks(range = { bottom: -60, top: 0 }) { + const top = Number.isFinite(range.top) ? range.top : 0; + const bottom = Number.isFinite(range.bottom) ? range.bottom : -60; + const span = top - bottom; + const step = span > 40 ? 10 : 6; + const ticks = []; + for (let v = bottom; v <= top + 1e-6; v += step) ticks.push(Math.round(v)); + if (!ticks.includes(top)) ticks.push(top); + return ticks; +} + +function pickTimeStep(totalSec) { + const preferred = 6; + const rough = totalSec / preferred; + const pow = Math.pow(10, Math.floor(Math.log10(Math.max(rough, 0.001)))); + const candidates = [1, 2, 5, 10, 20].map((c) => c * pow); + let best = candidates[0]; + let bestDiff = Infinity; + for (const step of candidates) { + if (step <= 0) continue; + const lines = totalSec / step; + const diff = Math.abs(lines - preferred); + if (diff < bestDiff && lines >= 2) { + best = step; + bestDiff = diff; + } + } + return best || rough || totalSec || 1; +} + +function formatSeconds(sec) { + if (!Number.isFinite(sec)) return '0s'; + if (sec >= 9.95) return `${Math.round(sec)}s`; + if (Math.abs(sec - Math.round(sec)) < 1e-3) return `${Math.round(sec)}s`; + if (sec >= 10) return `${Math.round(sec)}s`; + if (sec >= 1) return `${sec.toFixed(1)}s`; + return `${Math.round(sec * 1000)}ms`; +} + +function clamp(v, lo, hi) { + return Math.min(hi, Math.max(lo, v)); +} + +function clamp01(v) { + if (!Number.isFinite(v)) return 0; + return Math.min(1, Math.max(0, v)); +} diff --git a/www/views/phase_wheel.js b/www/views/phase_wheel.js new file mode 100644 index 0000000..ea1c252 --- /dev/null +++ b/www/views/phase_wheel.js @@ -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; +} diff --git a/www/views/quad_view.js b/www/views/quad_view.js new file mode 100644 index 0000000..9b28c88 --- /dev/null +++ b/www/views/quad_view.js @@ -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); +} diff --git a/www/views/realtime.js b/www/views/realtime.js new file mode 100644 index 0000000..3e7a7c1 --- /dev/null +++ b/www/views/realtime.js @@ -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)); +} diff --git a/www/views/recorder.js b/www/views/recorder.js new file mode 100644 index 0000000..5a96a23 --- /dev/null +++ b/www/views/recorder.js @@ -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(); +} diff --git a/www/views/spectrogram.js b/www/views/spectrogram.js new file mode 100644 index 0000000..1283199 --- /dev/null +++ b/www/views/spectrogram.js @@ -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; +} diff --git a/www/views/split_view.js b/www/views/split_view.js new file mode 100644 index 0000000..a5d0461 --- /dev/null +++ b/www/views/split_view.js @@ -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); +} diff --git a/www/views/static_layer.js b/www/views/static_layer.js new file mode 100644 index 0000000..5ce7c7d --- /dev/null +++ b/www/views/static_layer.js @@ -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); +} diff --git a/www/views/waveform.js b/www/views/waveform.js new file mode 100644 index 0000000..22e3ee8 --- /dev/null +++ b/www/views/waveform.js @@ -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; +} diff --git a/www/workers/spectrogram.worker.js b/www/workers/spectrogram.worker.js new file mode 100644 index 0000000..74be5e4 --- /dev/null +++ b/www/workers/spectrogram.worker.js @@ -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 (_) {} + } +} diff --git a/www/workers/waveform.worker.js b/www/workers/waveform.worker.js new file mode 100644 index 0000000..db05c81 --- /dev/null +++ b/www/workers/waveform.worker.js @@ -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; +}