From 55bd37cd9997a58cf0a44226b4a4e64407cc2a50 Mon Sep 17 00:00:00 2001 From: Yan Minagawa Date: Wed, 10 Jun 2026 01:29:03 +0200 Subject: [PATCH] add lightcontrol rust tool Three binaries against the c-base mainhall rig: - lightcontrol: TUI with 15 animated presets, live matelight-sync via the /monitor WebSocket, and a microphone beat detector that drives the BeatPulse preset across RGB PARs, CB-100 colour-wheels, and TSL-250 scanners. - panels: one-shot RGB wash for the Showtec LED Par 56 wall panels via dmxbackend. - bars: ArtNet direct driver for the Stairville SonicPulse LED bars, with ArtPoll discovery and a universe sweeper. Shared lib code (fixture model, render pipeline, matelight + beat modules) lives in src/lib.rs. Mic feature behind the default `mic` cargo feature so builds without ALSA dev libs work via --no-default-features. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 17 + Cargo.lock | 2781 +++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 23 + src/beat.rs | 470 ++++++++ src/bin/bars.rs | 453 ++++++++ src/bin/panels.rs | 232 ++++ src/lib.rs | 64 ++ src/main.rs | 665 +++++++++++ src/matelight.rs | 238 ++++ src/presets.rs | 1458 ++++++++++++++++++++++++ 10 files changed, 6401 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/beat.rs create mode 100644 src/bin/bars.rs create mode 100644 src/bin/panels.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/matelight.rs create mode 100644 src/presets.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9a154c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Rust build artefacts +/target/ + +# Editor / OS noise +*.swp +*.swo +*~ +.DS_Store +.idea/ +.vscode/ + +# QLC+ autosaves (the canonical .qxw is committed separately if desired) +*.qxw.autosave +*.autosave.qxw + +# Local distribution packages — don't drag installers into the repo +*.deb diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..bbb128c --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2781 @@ +# 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 = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.11.1", + "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 = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.11.1", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[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 = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[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 = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953" +dependencies = [ + "bindgen", +] + +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.11.1", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[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 = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[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 = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[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-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-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 = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[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 = "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", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "lightcontrol" +version = "0.1.0" +dependencies = [ + "anyhow", + "cpal", + "crossterm", + "futures-util", + "ratatui", + "reqwest", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[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 = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[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 = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.11.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "thiserror", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[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 = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[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 = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[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 = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +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 0.2.17", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.11.1", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[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.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[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 = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[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.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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 = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[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 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[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" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[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 = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[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-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[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 = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[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", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[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 = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[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.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[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 = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[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 = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +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..ed14496 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "lightcontrol" +version = "0.1.0" +edition = "2021" + +[features] +default = ["mic"] +mic = ["dep:cpal"] + +[dependencies] +anyhow = "1" +cpal = { version = "0.15", optional = true } +crossterm = "0.28" +futures-util = { version = "0.3", default-features = false, features = ["sink"] } +ratatui = "0.29" +reqwest = { version = "0.12", features = ["json"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] } +tokio-tungstenite = "0.24" + +[profile.release] +lto = "thin" diff --git a/src/beat.rs b/src/beat.rs new file mode 100644 index 0000000..47b4018 --- /dev/null +++ b/src/beat.rs @@ -0,0 +1,470 @@ +//! Microphone-driven beat detection. +//! +//! Captures audio from the default input device via `cpal`, runs an +//! energy-onset detector against the live samples, and exposes the latest +//! beat state through a shared `Arc>`. Beat +//! state is consumed by the `BeatPulse` preset to drive the hall lights in +//! sync with the room's audio. +//! +//! Detector design — single-band energy onset, robust enough for a club +//! environment: +//! +//! 1. Downmix incoming samples to mono. +//! 2. Compute RMS energy per ~23 ms chunk (1024 samples @ 44.1 kHz). +//! 3. Track two single-pole envelopes: a fast one (~50 ms) following the +//! instantaneous level, and a slow one (~1.5 s) acting as the running +//! baseline. +//! 4. Trigger a beat when the short envelope crosses `threshold × +//! long envelope`, gated by a refractory period (≥200 ms ⇒ ≤300 BPM). +//! 5. Median of the last 8 inter-beat intervals → BPM estimate. +//! +//! When the `mic` cargo feature is off, this module still compiles but the +//! feeder is a no-op that just reports "mic disabled". The `BeatPulse` +//! preset still works as a "no-mic" placeholder pulse. + +use std::sync::Arc; +use std::time::Instant; + +/// Snapshot passed to renderers per frame. Computed in `main.rs` from the +/// latest `BeatState` at render time so it has the freshest `time_since_beat`. +#[derive(Clone, Copy, Debug, Default)] +pub struct BeatInput { + /// Seconds since the most recent beat. Large (e.g. 99.0) when no beat + /// has happened yet. + pub time_since_beat: f32, + /// Monotonic counter — increments on every detected beat. Renderers use + /// this to pick a new colour per beat without needing wall-clock time. + pub beat_index: u64, + /// Estimated BPM (median of the last few inter-beat intervals). + pub bpm: f32, + /// Current short-window energy envelope, roughly 0..1. + pub energy: f32, +} + +/// Authoritative beat state — written by the mic thread, read by the +/// engine and TUI. Uses `std::sync::Mutex` (not tokio's) because the cpal +/// callback runs on a non-tokio audio thread. +#[derive(Debug, Default)] +pub struct BeatState { + pub last_beat: Option, + pub beat_count: u64, + pub bpm: f32, + pub energy: f32, + /// Device label set when capture starts. + pub device_name: Option, + /// Last error from cpal init or stream errors. Surfaced in the TUI. + pub last_error: Option, +} + +pub type SharedBeat = Arc>; + +pub fn shared() -> SharedBeat { + Arc::new(std::sync::Mutex::new(BeatState::default())) +} + +/// Snapshot the live state into a per-frame `BeatInput`. Returns `None` +/// when nothing useful has been observed yet (mic not up and no beats). +pub fn snapshot(shared: &SharedBeat, now: Instant) -> Option { + let s = shared.lock().ok()?; + if s.device_name.is_none() && s.last_beat.is_none() { + return None; + } + let time_since_beat = s + .last_beat + .map(|t| now.duration_since(t).as_secs_f32()) + .unwrap_or(99.0); + Some(BeatInput { + time_since_beat, + beat_index: s.beat_count, + bpm: s.bpm, + energy: s.energy, + }) +} + +/// Status string derived from the live state for the TUI mic line. +pub fn status_line(shared: &SharedBeat) -> String { + let s = match shared.lock() { + Ok(g) => g, + Err(_) => return "mic: lock poisoned".to_string(), + }; + if let Some(err) = &s.last_error { + return format!("mic: {err}"); + } + match &s.device_name { + Some(name) => format!( + "mic: {name} beats={} bpm={:.0} level={:.2}", + s.beat_count, s.bpm, s.energy + ), + None => "mic: starting…".to_string(), + } +} + +// --------------------------------------------------------------------------- +// Detector — feature-independent, takes raw mono samples +// --------------------------------------------------------------------------- + +/// Energy-onset beat detector. Sample-format and capture-backend agnostic — +/// callers downmix to mono f32 and call `feed`. +pub struct EnergyDetector { + pub sample_rate: u32, + chunk_size: usize, + chunk_accum: Vec, + short_env: f32, + long_env: f32, + last_beat: Option, + beat_count: u64, + intervals: std::collections::VecDeque, +} + +#[derive(Debug, Clone, Copy)] +pub struct BeatEvent { + pub at: Instant, + pub index: u64, + pub energy: f32, + pub bpm: f32, +} + +impl EnergyDetector { + pub fn new(sample_rate: u32) -> Self { + // ~23ms chunks at 44.1k. Same wall-clock at higher rates so envelope + // time-constants stay roughly correct. + let chunk_size = ((sample_rate as f32) * 0.023).round() as usize; + Self { + sample_rate, + chunk_size: chunk_size.max(64), + chunk_accum: Vec::with_capacity(8192), + short_env: 0.0, + long_env: 0.0, + last_beat: None, + beat_count: 0, + intervals: std::collections::VecDeque::with_capacity(8), + } + } + + /// Push a chunk of mono f32 samples (range roughly -1..=1). Returns one + /// `BeatEvent` per processed chunk that crossed the onset condition; + /// callers should drain by calling repeatedly with empty slices is *not* + /// needed — process_chunk is invoked internally. + pub fn feed(&mut self, samples: &[f32], now: Instant) -> Option { + let mut event: Option = None; + for &s in samples { + self.chunk_accum.push(s); + if self.chunk_accum.len() >= self.chunk_size { + if let Some(b) = self.process_chunk(now) { + event = Some(b); + } + self.chunk_accum.clear(); + } + } + event + } + + fn process_chunk(&mut self, now: Instant) -> Option { + let n = self.chunk_accum.len(); + if n == 0 { + return None; + } + // RMS + let sum_sq: f32 = self.chunk_accum.iter().map(|&s| s * s).sum(); + let rms = (sum_sq / n as f32).sqrt(); + + // Single-pole IIR envelopes. dt is the chunk duration in seconds; + // tau is the envelope time-constant. + let dt = n as f32 / self.sample_rate as f32; + let alpha_short = 1.0 - (-dt / 0.050).exp(); // 50ms + let alpha_long = 1.0 - (-dt / 1.500).exp(); // 1.5s + self.short_env += alpha_short * (rms - self.short_env); + self.long_env += alpha_long * (rms - self.long_env); + + // Onset condition. Three gates that must all pass: + // 1. Ratio: instantaneous level is meaningfully above the running + // baseline (35% bump). + // 2. Absolute delta: the level *rose* by at least MIN_DELTA in + // absolute terms. This prevents false beats during the warmup + // period when the long-envelope is still climbing — the ratio + // can read huge while both envelopes are tiny. + // 3. Baseline above noise floor: don't trigger on dead silence. + const THRESHOLD: f32 = 1.35; + const MIN_BASELINE: f32 = 0.005; // noise floor + const MIN_DELTA: f32 = 0.020; // absolute rise required (~-34 dBFS) + const MIN_INTERVAL_MS: u64 = 200; // ≤300 BPM + let above = self.short_env > self.long_env * THRESHOLD + && self.short_env - self.long_env > MIN_DELTA + && self.long_env > MIN_BASELINE; + let cooled = self + .last_beat + .map(|t| now.duration_since(t).as_millis() as u64 >= MIN_INTERVAL_MS) + .unwrap_or(true); + + if above && cooled { + if let Some(prev) = self.last_beat { + let dur = now.duration_since(prev).as_secs_f32(); + self.intervals.push_back(dur); + if self.intervals.len() > 8 { + self.intervals.pop_front(); + } + } + self.last_beat = Some(now); + self.beat_count += 1; + return Some(BeatEvent { + at: now, + index: self.beat_count, + energy: self.short_env, + bpm: self.estimate_bpm(), + }); + } + None + } + + fn estimate_bpm(&self) -> f32 { + if self.intervals.is_empty() { + return 0.0; + } + let mut v: Vec = self.intervals.iter().copied().collect(); + v.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let median = v[v.len() / 2]; + if median > 1e-3 { + 60.0 / median + } else { + 0.0 + } + } + + /// Current short-window envelope (for live "level" display). + pub fn energy(&self) -> f32 { + self.short_env + } +} + +// --------------------------------------------------------------------------- +// Mic capture — feature-gated cpal integration +// --------------------------------------------------------------------------- + +#[cfg(feature = "mic")] +mod capture { + use super::*; + use anyhow::{anyhow, Context, Result}; + use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + + /// Build the cpal input stream and drive the detector. The returned + /// `Stream` value must be kept alive — capture stops when it drops. + pub fn build(shared: SharedBeat) -> Result { + let host = cpal::default_host(); + let device = host + .default_input_device() + .ok_or_else(|| anyhow!("no default audio input device"))?; + let device_name = device.name().unwrap_or_else(|_| "unknown".to_string()); + + let supported = device + .default_input_config() + .context("default_input_config")?; + let sample_format = supported.sample_format(); + let sample_rate = supported.sample_rate().0; + let channels = supported.channels() as usize; + let stream_config: cpal::StreamConfig = supported.into(); + + { + let mut s = shared.lock().unwrap(); + s.device_name = Some(format!( + "{device_name} ({sample_rate}Hz, {channels}ch, {sample_format:?})" + )); + s.last_error = None; + } + + let mut detector = EnergyDetector::new(sample_rate); + let shared_for_data = shared.clone(); + let shared_for_err = shared.clone(); + let mut mono = Vec::with_capacity(4096); + + let err_fn = move |err| { + if let Ok(mut s) = shared_for_err.lock() { + s.last_error = Some(format!("stream error: {err}")); + } + }; + + let stream = match sample_format { + cpal::SampleFormat::F32 => device.build_input_stream( + &stream_config, + move |data: &[f32], _: &_| { + process(&mut detector, &shared_for_data, &mut mono, data, channels); + }, + err_fn, + None, + )?, + cpal::SampleFormat::I16 => device.build_input_stream( + &stream_config, + move |data: &[i16], _: &_| { + mono.clear(); + mono.extend(data.iter().map(|&s| s as f32 / i16::MAX as f32)); + process_mono(&mut detector, &shared_for_data, &mono, channels); + }, + err_fn, + None, + )?, + cpal::SampleFormat::U16 => device.build_input_stream( + &stream_config, + move |data: &[u16], _: &_| { + mono.clear(); + mono.extend( + data.iter() + .map(|&s| (s as f32 - i16::MAX as f32) / i16::MAX as f32), + ); + process_mono(&mut detector, &shared_for_data, &mono, channels); + }, + err_fn, + None, + )?, + other => return Err(anyhow!("unsupported sample format: {other:?}")), + }; + + stream.play().context("stream.play")?; + Ok(stream) + } + + fn process( + detector: &mut EnergyDetector, + shared: &SharedBeat, + mono: &mut Vec, + data: &[f32], + channels: usize, + ) { + mono.clear(); + if channels <= 1 { + mono.extend_from_slice(data); + } else { + for frame in data.chunks(channels) { + let s: f32 = frame.iter().copied().sum::() / channels as f32; + mono.push(s); + } + } + process_mono(detector, shared, mono, channels); + } + + fn process_mono( + detector: &mut EnergyDetector, + shared: &SharedBeat, + mono: &[f32], + _channels: usize, + ) { + let now = Instant::now(); + let event = detector.feed(mono, now); + // Update live envelope on every callback; bump beat fields on event. + if let Ok(mut s) = shared.lock() { + s.energy = detector.energy(); + if let Some(e) = event { + s.last_beat = Some(e.at); + s.beat_count = e.index; + s.bpm = e.bpm; + } + } + } +} + +#[cfg(feature = "mic")] +pub use capture::build as build_stream; + +/// Spawn the mic capture thread. The thread owns the cpal `Stream` so it +/// stays alive for the program's lifetime. On error, the failure is +/// surfaced through `shared.last_error` and the thread exits. +pub fn spawn(shared: SharedBeat) { + #[cfg(feature = "mic")] + { + std::thread::Builder::new() + .name("beat-mic".to_string()) + .spawn(move || match build_stream(shared.clone()) { + Ok(stream) => { + // Keep stream alive. The cpal callback owns the detector + // and writes to shared state directly. + std::mem::forget(stream); + loop { + std::thread::park(); + } + } + Err(e) => { + if let Ok(mut s) = shared.lock() { + s.last_error = Some(format!("init: {e}")); + } + } + }) + .expect("spawn beat-mic thread"); + } + #[cfg(not(feature = "mic"))] + { + if let Ok(mut s) = shared.lock() { + s.last_error = + Some("disabled at build time — rebuild with --features mic".to_string()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn detector_fires_on_loud_pulse_against_quiet_baseline() { + let mut det = EnergyDetector::new(44_100); + let start = Instant::now(); + let chunk_n = det.chunk_size; + + // Seed a few seconds of quiet so long_env settles low. + let quiet: Vec = (0..(chunk_n * 50)).map(|_| 0.01).collect(); + det.feed(&quiet, start); + + // Then a loud burst — should fire one beat. + let burst: Vec = (0..chunk_n).map(|_| 0.6).collect(); + let later = start + Duration::from_millis(2000); + let beat = det.feed(&burst, later); + assert!(beat.is_some(), "loud burst over quiet baseline must trigger"); + assert_eq!(beat.unwrap().index, 1); + } + + #[test] + fn detector_respects_refractory_period() { + let mut det = EnergyDetector::new(44_100); + let now = Instant::now(); + let chunk_n = det.chunk_size; + + // Baseline. + let quiet: Vec = (0..(chunk_n * 30)).map(|_| 0.005).collect(); + det.feed(&quiet, now); + + // Two loud bursts within 50 ms — only the first should fire. + let burst: Vec = (0..chunk_n).map(|_| 0.7).collect(); + let t0 = now + Duration::from_millis(1500); + let b1 = det.feed(&burst, t0); + assert!(b1.is_some()); + let t1 = t0 + Duration::from_millis(50); + let b2 = det.feed(&burst, t1); + assert!(b2.is_none(), "second burst within refractory window must be suppressed"); + + // After 250ms, another burst can fire. + let t2 = t0 + Duration::from_millis(250); + let b3 = det.feed(&burst, t2); + assert!(b3.is_some(), "burst past refractory must fire"); + } + + #[test] + fn detector_estimates_bpm_from_periodic_beats() { + let mut det = EnergyDetector::new(44_100); + let now = Instant::now(); + let chunk_n = det.chunk_size; + + // Quiet baseline. + let quiet: Vec = (0..(chunk_n * 40)).map(|_| 0.003).collect(); + det.feed(&quiet, now); + + // Hit four beats at 120 BPM = 500ms apart. + let burst: Vec = (0..chunk_n).map(|_| 0.8).collect(); + for i in 0..6 { + let t = now + Duration::from_millis(2000 + 500 * i); + det.feed(&burst, t); + // Drop a quiet chunk between bursts so envelope can fall. + let between: Vec = (0..(chunk_n * 8)).map(|_| 0.003).collect(); + det.feed(&between, t + Duration::from_millis(50)); + } + let bpm = det.estimate_bpm(); + assert!(bpm > 100.0 && bpm < 140.0, "expected ~120 BPM, got {bpm}"); + } +} diff --git a/src/bin/bars.rs b/src/bin/bars.rs new file mode 100644 index 0000000..0bee48a --- /dev/null +++ b/src/bin/bars.rs @@ -0,0 +1,453 @@ +//! Direct-ArtNet tool for the four Stairville SonicPulse LED Bar 10s. +//! +//! Sends a single ArtDMX packet to the ArtNet node, addressing only the 156 +//! channels the four bars occupy in U3 (4 × 39ch starting at DMX 1, 40, 79, +//! 118). Channels beyond that aren't included in the packet, so per the +//! ArtNet spec the receiver retains their prior values — "without changing +//! anything else". +//! +//! Channel layout per bar (Stairville 39-channel mode): +//! ch1 master dimmer +//! ch2 shutter / strobe +//! ch3 mode selection (0 = direct pixel control) +//! ch4 colour preset +//! ch5 effect select +//! ch6 sound mode +//! ch7 speed / sensitivity +//! ch8..39 8 segments × RGBW (4 channels each) + +use anyhow::{Context, Result}; +use std::net::UdpSocket; +use std::time::{Duration, Instant}; + +const DEFAULT_NODE: &str = "10.0.0.146:6454"; +const DEFAULT_UNIVERSE: u16 = 2; // QLC+ "Universe 3" = 0-indexed ArtNet universe 2 + +// 0-indexed DMX offsets for each bar within the universe (DMX address minus 1). +const BAR_OFFSETS: &[usize] = &[0, 39, 78, 117]; +const BAR_CHANNELS: usize = 39; +const SEGMENTS: usize = 8; + +struct Args { + node: String, + universe: u16, + color: (u8, u8, u8), + white: u8, + dim: u8, + mode: u8, + repeats: u32, + interval_ms: u64, + dry_run: bool, + poll: bool, + broadcast: bool, + sweep: Option<(u16, u16)>, +} + +fn main() -> Result<()> { + let args = parse_args()?; + + if args.poll { + return run_poll(&args.node); + } + + if let Some((lo, hi)) = args.sweep { + return run_sweep(&args, lo, hi); + } + + let dmx = build_dmx(args.color, args.white, args.dim, args.mode); + let pkt = build_artdmx(args.universe, 0, &dmx); + + let (r, g, b) = args.color; + println!("node: {}", args.node); + println!( + "universe: {} (Net={}, SubUni={})", + args.universe, + (args.universe >> 8) & 0x7f, + args.universe & 0xff + ); + println!( + "colour: rgb=({r},{g},{b}) W={} dim={} mode={}", + args.white, args.dim, args.mode + ); + println!( + "bars: {} × 39ch at DMX {} = {}-byte packet (header 18 + data {})", + BAR_OFFSETS.len(), + BAR_OFFSETS + .iter() + .map(|o| (o + 1).to_string()) + .collect::>() + .join(","), + pkt.len(), + dmx.len() + ); + + if args.dry_run { + println!("(dry run — not sending)"); + print_dmx_summary(&dmx); + return Ok(()); + } + + let sock = UdpSocket::bind("0.0.0.0:0").context("bind udp")?; + if args.broadcast { + sock.set_broadcast(true).context("enable broadcast")?; + } + sock.connect(&args.node) + .with_context(|| format!("connect {}", args.node))?; + for i in 0..args.repeats { + sock.send(&pkt).context("send")?; + if i + 1 < args.repeats { + std::thread::sleep(Duration::from_millis(args.interval_ms)); + } + } + println!("sent {} packet(s).", args.repeats); + Ok(()) +} + +// --- ArtPoll diagnostic ---------------------------------------------------- + +fn run_poll(target: &str) -> Result<()> { + let sock = UdpSocket::bind("0.0.0.0:6454") + .context("bind UDP 6454 (close QLC+/other ArtNet listeners and retry)")?; + sock.set_broadcast(true)?; + sock.set_read_timeout(Some(Duration::from_millis(300)))?; + + let mut pkt = Vec::with_capacity(14); + pkt.extend_from_slice(b"Art-Net\0"); + pkt.extend_from_slice(&0x2000u16.to_le_bytes()); // OpPoll + pkt.extend_from_slice(&14u16.to_be_bytes()); // ProtVer + pkt.push(0x02); // TalkToMe: send ArtPollReply on changes too + pkt.push(0); // Priority + + // Send to limited broadcast, subnet broadcast, and the supplied target. + let targets = ["255.255.255.255:6454", "10.0.0.255:6454", target]; + for t in targets { + if let Err(e) = sock.send_to(&pkt, t) { + eprintln!("warn: send ArtPoll to {t}: {e}"); + } + } + + println!("listening for ArtPollReply for 2.5s…\n"); + let start = Instant::now(); + let mut seen = std::collections::BTreeSet::new(); + while start.elapsed() < Duration::from_millis(2500) { + let mut buf = [0u8; 1024]; + match sock.recv_from(&mut buf) { + Ok((n, addr)) => { + if n >= 14 && &buf[0..8] == b"Art-Net\0" { + let opcode = u16::from_le_bytes([buf[8], buf[9]]); + if opcode == 0x2100 { + if !seen.insert(addr) { + continue; + } + print_poll_reply(addr, &buf[..n]); + } + } + } + Err(_) => continue, + } + } + + if seen.is_empty() { + println!("no ArtPollReply received."); + println!("→ check: is the node powered + on this LAN? are you on the same subnet as 10.0.0.0/24?"); + } + Ok(()) +} + +fn print_poll_reply(addr: std::net::SocketAddr, data: &[u8]) { + if data.len() < 200 { + println!("{} — short ArtPollReply ({} bytes), can't decode", addr, data.len()); + return; + } + let ip = format!("{}.{}.{}.{}", data[10], data[11], data[12], data[13]); + let net_switch = data[18]; + let sub_switch = data[19]; + let short_name = cstr(&data[26..44]); + let long_name = cstr(&data[44..108]); + let num_ports = u16::from_be_bytes([data[172], data[173]]).min(4) as usize; + let port_types = &data[174..178]; + let sw_out = &data[190..194]; + + println!("=== ArtPollReply from {} ===", addr); + println!(" ip: {}", ip); + println!(" short name: {:?}", short_name); + println!(" long name: {:?}", long_name); + println!(" net/subnet: net={net_switch} sub={sub_switch}"); + println!(" num ports: {num_ports}"); + for i in 0..num_ports { + let ptype = port_types[i]; + let direction = if ptype & 0x80 != 0 { + "output" + } else if ptype & 0x40 != 0 { + "input" + } else { + "?" + }; + let universe = ((net_switch as u16 & 0x7f) << 8) + | ((sub_switch as u16 & 0x0f) << 4) + | (sw_out[i] as u16 & 0x0f); + println!( + " port {} ({}): SwOut=0x{:02x} → ArtNet universe {}", + i, direction, sw_out[i], universe + ); + } + println!(); +} + +fn cstr(b: &[u8]) -> String { + let end = b.iter().position(|&c| c == 0).unwrap_or(b.len()); + String::from_utf8_lossy(&b[..end]).into_owned() +} + +// --- universe sweep -------------------------------------------------------- + +fn run_sweep(args: &Args, lo: u16, hi: u16) -> Result<()> { + let sock = UdpSocket::bind("0.0.0.0:0").context("bind udp")?; + if args.broadcast { + sock.set_broadcast(true)?; + } + let dmx = build_dmx(args.color, args.white, args.dim, args.mode); + println!( + "sweep: sending colour={:?} dim={} mode={} to {} universes {}..={}, {} ms each", + args.color, args.dim, args.mode, args.node, lo, hi, args.interval_ms + ); + for u in lo..=hi { + let pkt = build_artdmx(u, 0, &dmx); + sock.send_to(&pkt, &args.node) + .with_context(|| format!("send universe {u}"))?; + println!(" universe {u} sent — watch the bars for ~{}ms", args.interval_ms); + std::thread::sleep(Duration::from_millis(args.interval_ms)); + } + // After sweep, blank everything we touched + let dark = build_dmx((0, 0, 0), 0, 0, args.mode); + for u in lo..=hi { + let pkt = build_artdmx(u, 1, &dark); + let _ = sock.send_to(&pkt, &args.node); + } + println!("sweep done (all universes blanked)."); + Ok(()) +} + +fn build_dmx(color: (u8, u8, u8), white: u8, dim: u8, mode: u8) -> Vec { + let last = BAR_OFFSETS.iter().max().copied().unwrap_or(0) + BAR_CHANNELS; + let mut dmx = vec![0u8; last]; + for &off in BAR_OFFSETS { + dmx[off] = dim; // ch1 master dimmer + dmx[off + 1] = 0; // ch2 strobe + dmx[off + 2] = mode; // ch3 mode + dmx[off + 3] = 0; // ch4 preset + dmx[off + 4] = 0; // ch5 effect + dmx[off + 5] = 0; // ch6 sound mode + dmx[off + 6] = 0; // ch7 speed + for seg in 0..SEGMENTS { + let base = off + 7 + seg * 4; + dmx[base] = color.0; // R + dmx[base + 1] = color.1; // G + dmx[base + 2] = color.2; // B + dmx[base + 3] = white; // W + } + } + dmx +} + +fn build_artdmx(universe: u16, sequence: u8, dmx: &[u8]) -> Vec { + // ArtNet ArtDMX header is 18 bytes. + let length = dmx.len() as u16; + let mut pkt = Vec::with_capacity(18 + dmx.len()); + pkt.extend_from_slice(b"Art-Net\0"); // 8 byte ID + pkt.extend_from_slice(&0x5000u16.to_le_bytes()); // OpDmx (little-endian) + pkt.extend_from_slice(&14u16.to_be_bytes()); // ProtVer Hi/Lo (big-endian) + pkt.push(sequence); // Sequence + pkt.push(0); // Physical + pkt.push((universe & 0xff) as u8); // SubUni + pkt.push(((universe >> 8) & 0x7f) as u8); // Net + pkt.extend_from_slice(&length.to_be_bytes()); // Length (big-endian) + pkt.extend_from_slice(dmx); + pkt +} + +fn print_dmx_summary(dmx: &[u8]) { + println!("\nDMX channels (1-indexed):"); + for (i, &off) in BAR_OFFSETS.iter().enumerate() { + println!( + " bar {} @ ch{}..{}: master={} strobe={} mode={} preset={} effect={} sound={} speed={}", + i + 1, + off + 1, + off + BAR_CHANNELS, + dmx[off], + dmx[off + 1], + dmx[off + 2], + dmx[off + 3], + dmx[off + 4], + dmx[off + 5], + dmx[off + 6] + ); + for seg in 0..SEGMENTS { + let base = off + 7 + seg * 4; + println!( + " seg {} @ ch{}: r={} g={} b={} w={}", + seg + 1, + base + 1, + dmx[base], + dmx[base + 1], + dmx[base + 2], + dmx[base + 3] + ); + } + } +} + +// --- CLI --------------------------------------------------------------- + +fn parse_args() -> Result { + let mut node: Option = None; + let mut universe: Option = None; + let mut color_arg: Option = None; + let mut white: u8 = 0; + let mut dim: u8 = 255; + let mut mode: u8 = 0; + let mut repeats: u32 = 3; + let mut interval_ms: u64 = 50; + let mut dry_run = false; + let mut poll = false; + let mut broadcast = false; + let mut sweep: Option<(u16, u16)> = None; + + let mut it = std::env::args().skip(1); + while let Some(a) = it.next() { + match a.as_str() { + "-h" | "--help" => { + print_help(); + std::process::exit(0); + } + "--node" => node = it.next(), + "--universe" => { + universe = it.next().map(|s| s.parse()).transpose()?; + } + "--color" => color_arg = it.next(), + "--white" => white = it.next().context("--white needs value")?.parse()?, + "--dim" => dim = it.next().context("--dim needs value")?.parse()?, + "--mode" => mode = it.next().context("--mode needs value")?.parse()?, + "--repeats" => repeats = it.next().context("--repeats needs value")?.parse()?, + "--interval" => interval_ms = it.next().context("--interval needs value")?.parse()?, + "--dry-run" => dry_run = true, + "--poll" => poll = true, + "--broadcast" => broadcast = true, + "--sweep" => { + let v = it.next().context("--sweep needs LOW-HIGH")?; + sweep = Some(parse_sweep(&v)?); + if interval_ms < 250 { + interval_ms = 600; + } + } + other if let Some(v) = other.strip_prefix("--sweep=") => { + sweep = Some(parse_sweep(v)?); + if interval_ms < 250 { + interval_ms = 600; + } + } + other if let Some(v) = other.strip_prefix("--node=") => node = Some(v.to_string()), + other if let Some(v) = other.strip_prefix("--universe=") => universe = Some(v.parse()?), + other if let Some(v) = other.strip_prefix("--color=") => color_arg = Some(v.to_string()), + other if let Some(v) = other.strip_prefix("--white=") => white = v.parse()?, + other if let Some(v) = other.strip_prefix("--dim=") => dim = v.parse()?, + other if let Some(v) = other.strip_prefix("--mode=") => mode = v.parse()?, + other if let Some(v) = other.strip_prefix("--repeats=") => repeats = v.parse()?, + other if let Some(v) = other.strip_prefix("--interval=") => interval_ms = v.parse()?, + _ => { + eprintln!("unknown argument: {a}"); + print_help(); + std::process::exit(2); + } + } + } + + Ok(Args { + node: node.unwrap_or_else(|| DEFAULT_NODE.to_string()), + universe: universe.unwrap_or(DEFAULT_UNIVERSE), + color: parse_color(color_arg.as_deref().unwrap_or("red"))?, + white, + dim, + mode, + repeats, + interval_ms, + dry_run, + poll, + broadcast, + sweep, + }) +} + +fn parse_sweep(s: &str) -> Result<(u16, u16)> { + let (a, b) = s + .split_once('-') + .or_else(|| s.split_once(':')) + .context("--sweep wants LOW-HIGH, e.g. 0-15")?; + let lo: u16 = a.parse()?; + let hi: u16 = b.parse()?; + if lo > hi { + anyhow::bail!("--sweep low > high"); + } + Ok((lo, hi)) +} + +fn parse_color(s: &str) -> Result<(u8, u8, u8)> { + let s = s.trim(); + if let Some(hex) = s.strip_prefix('#') { + if hex.len() != 6 { + anyhow::bail!("hex colour must be #RRGGBB"); + } + return Ok(( + u8::from_str_radix(&hex[0..2], 16)?, + u8::from_str_radix(&hex[2..4], 16)?, + u8::from_str_radix(&hex[4..6], 16)?, + )); + } + Ok(match s.to_lowercase().as_str() { + "red" => (255, 0, 0), + "green" => (0, 255, 0), + "blue" => (0, 0, 255), + "white" => (255, 255, 255), + "yellow" => (255, 200, 0), + "orange" => (255, 80, 0), + "magenta" | "pink" => (255, 0, 120), + "cyan" => (0, 255, 255), + "off" | "black" => (0, 0, 0), + other => anyhow::bail!("unknown colour {other:?} (use a name or #RRGGBB)"), + }) +} + +fn print_help() { + println!( + "bars — colour the four SonicPulse LED Bar 10s via direct ArtNet (one-shot) + +USAGE: + bars [OPTIONS] + +OPTIONS: + --node HOST:PORT ArtNet node (default {DEFAULT_NODE}) + --universe N 0-indexed ArtNet universe (default {DEFAULT_UNIVERSE} = QLC+ \"Universe 3\") + --color C red | green | blue | white | yellow | orange | magenta | cyan | + off | #RRGGBB (default red) + --white N W (0-255) — added to each segment alongside RGB (default 0) + --dim N master dimmer 0-255 (default 255) + --mode N ch3 mode value (default 0 — direct DMX pixel control) + --repeats N send the packet N times (default 3, mitigates UDP drops) + --interval MS ms between repeats (default 50) + --dry-run print without sending + --broadcast send to the address as a broadcast (e.g. 10.0.0.255) + --poll ArtPoll the LAN, list ArtNet nodes + their universes + --sweep LOW-HIGH sweep through ArtNet universes LOW..=HIGH (e.g. 0-15) + sending the colour to each in turn, then blanking + -h, --help this help + +NOTES: + Packet length is set to exactly 156 channels (4 bars × 39ch). Per the + ArtNet spec, channels 157-512 in this universe are NOT included in the + packet and the receiver must retain their previous values. + + --poll binds UDP port 6454 to receive ArtPollReply. If another ArtNet + app (QLC+, OLA, dmxbackend) holds that port, close it first. +" + ); +} diff --git a/src/bin/panels.rs b/src/bin/panels.rs new file mode 100644 index 0000000..3373d95 --- /dev/null +++ b/src/bin/panels.rs @@ -0,0 +1,232 @@ +//! One-shot tool to colour the wall-mounted panel fixtures. +//! +//! Defaults to the "Columns" group (Showtec LED Par 56) and sets them solid +//! red. Only sends updates for the matched fixtures' RGB + dimmer (+ zeroing +//! amber/white/uv on those fixtures so the colour reads pure). Nothing else +//! in the room is touched. + +use anyhow::{Context, Result}; +use futures_util::SinkExt; +use lightcontrol::{fetch_fixtures, http_client, Fixture, Update}; +use std::time::Duration; +use tokio_tungstenite::tungstenite::Message; + +const DEFAULT_HOST: &str = "dmx.cbrp3.c-base.org:8000"; +const DEFAULT_MODEL: &str = "LED Par 56"; + +struct Args { + host: String, + model: String, + color: (u8, u8, u8), + dim: u8, + dry_run: bool, +} + +#[tokio::main] +async fn main() -> Result<()> { + let args = parse_args()?; + let base = format!("http://{}", args.host); + let ws_url = format!("ws://{}/api/v1/websocket_state/", args.host); + + let client = http_client()?; + let fixtures = fetch_fixtures(&client, &base) + .await + .context("fetching fixtures")?; + + let matched: Vec<&Fixture> = fixtures + .iter() + .filter(|f| f.model.eq_ignore_ascii_case(&args.model)) + .collect(); + + if matched.is_empty() { + eprintln!("no fixtures match model {:?}", args.model); + eprintln!("available models:"); + let mut models: std::collections::BTreeMap<&str, usize> = + std::collections::BTreeMap::new(); + for f in &fixtures { + *models.entry(f.model.as_str()).or_default() += 1; + } + for (m, n) in models { + eprintln!(" {n:3} x {m}"); + } + std::process::exit(2); + } + + let updates = build_updates(&matched, args.color, args.dim); + + println!( + "host: {} model: {:?} color: ({}, {}, {}) dim: {}", + args.host, args.model, args.color.0, args.color.1, args.color.2, args.dim + ); + println!("matched {} fixture(s):", matched.len()); + for f in &matched { + println!(" {} ({})", f.fixture_id, f.model); + } + println!("→ sending {} channel update(s)", updates.len()); + + if args.dry_run { + for u in &updates { + println!(" {} = {}", u.channel_id, u.value); + } + return Ok(()); + } + + let payload = serde_json::to_string(&updates)?; + let (ws, _) = tokio_tungstenite::connect_async(&ws_url) + .await + .with_context(|| format!("ws connect {ws_url}"))?; + let (mut write, _read) = futures_util::StreamExt::split(ws); + write + .send(Message::Text(payload)) + .await + .context("send frame")?; + // Give the server a beat to apply before we close — the dmxbackend + // pushes state to all listeners on receipt, but a graceful close avoids + // a stray ConnectionReset on its side. + tokio::time::sleep(Duration::from_millis(150)).await; + let _ = write.close().await; + + println!("done."); + Ok(()) +} + +fn build_updates(fixtures: &[&Fixture], (r, g, b): (u8, u8, u8), dim: u8) -> Vec { + let mut out = Vec::new(); + for f in fixtures { + for el in &f.elements { + match el.name.as_str() { + "rgb" => { + for c in &el.channels { + let v = match c.name.as_str() { + "r" | "red" => r, + "g" | "green" => g, + "b" | "blue" => b, + _ => continue, + }; + out.push(Update { + channel_id: c.channel_id.clone(), + value: v, + }); + } + } + "dimmer" => { + for c in &el.channels { + out.push(Update { + channel_id: c.channel_id.clone(), + value: dim, + }); + } + } + // Zero competing colour layers on the same fixture so the + // panel actually reads as the requested colour. Everything + // else (strobe / wheels / pan / tilt / etc.) is left alone. + "amber" | "white" | "warmwhite" | "uv" => { + for c in &el.channels { + out.push(Update { + channel_id: c.channel_id.clone(), + value: 0, + }); + } + } + _ => {} + } + } + } + out +} + +fn parse_args() -> Result { + let mut host: Option = None; + let mut model: Option = None; + let mut color_arg: Option = None; + let mut dim: u8 = 255; + let mut dry_run = false; + + let mut it = std::env::args().skip(1); + while let Some(a) = it.next() { + match a.as_str() { + "-h" | "--help" => { + print_help(); + std::process::exit(0); + } + "--host" => host = it.next(), + "--model" => model = it.next(), + "--color" => color_arg = it.next(), + "--dim" => { + if let Some(v) = it.next() { + dim = v.parse().context("--dim expects 0-255")?; + } + } + "--dry-run" => dry_run = true, + other if other.starts_with("--host=") => { + host = Some(other.trim_start_matches("--host=").to_string()); + } + other if other.starts_with("--model=") => { + model = Some(other.trim_start_matches("--model=").to_string()); + } + other if other.starts_with("--color=") => { + color_arg = Some(other.trim_start_matches("--color=").to_string()); + } + other if other.starts_with("--dim=") => { + dim = other.trim_start_matches("--dim=").parse()?; + } + _ => { + eprintln!("unknown argument: {a}"); + print_help(); + std::process::exit(2); + } + } + } + + let host = host + .or_else(|| std::env::var("DMX_HOST").ok()) + .unwrap_or_else(|| DEFAULT_HOST.to_string()); + let model = model.unwrap_or_else(|| DEFAULT_MODEL.to_string()); + let color = parse_color(color_arg.as_deref().unwrap_or("red"))?; + + Ok(Args { host, model, color, dim, dry_run }) +} + +fn parse_color(s: &str) -> Result<(u8, u8, u8)> { + let s = s.trim(); + if let Some(hex) = s.strip_prefix('#') { + if hex.len() != 6 { + anyhow::bail!("hex colour must be #RRGGBB"); + } + let r = u8::from_str_radix(&hex[0..2], 16)?; + let g = u8::from_str_radix(&hex[2..4], 16)?; + let b = u8::from_str_radix(&hex[4..6], 16)?; + return Ok((r, g, b)); + } + Ok(match s.to_lowercase().as_str() { + "red" => (255, 0, 0), + "green" => (0, 255, 0), + "blue" => (0, 0, 255), + "white" => (255, 255, 255), + "yellow" => (255, 200, 0), + "orange" => (255, 80, 0), + "magenta" | "pink" => (255, 0, 120), + "cyan" => (0, 255, 255), + "off" | "black" => (0, 0, 0), + other => anyhow::bail!("unknown colour {other:?} (use a name or #RRGGBB)"), + }) +} + +fn print_help() { + println!( + "panels — colour the wall panel fixtures (one-shot) + +USAGE: + panels [OPTIONS] + +OPTIONS: + --host HOST backend host:port (default {DEFAULT_HOST}) + --model NAME fixture model to target (default {DEFAULT_MODEL:?}) + --color C red | green | blue | white | yellow | orange | + magenta | cyan | off | #RRGGBB (default red) + --dim N dimmer value 0-255 (default 255) + --dry-run print the updates without sending them + -h, --help show this help +" + ); +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..ae67012 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,64 @@ +pub mod beat; +pub mod matelight; +mod presets; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +pub use beat::{ + shared as shared_beat, snapshot as snapshot_beat, status_line as beat_status, + spawn as spawn_beat, BeatInput, BeatState, SharedBeat, +}; +pub use matelight::{ + shared as shared_matelight, MatelightFrame, SharedMatelight, DEFAULT_MATELIGHT_WS, +}; +pub use presets::{render_frame, render_frame_with, Preset, PRESETS}; + +#[derive(Deserialize, Clone, Debug)] +pub struct Channel { + pub name: String, + pub channel_id: String, +} + +#[derive(Deserialize, Clone, Debug)] +pub struct Element { + pub name: String, + pub channels: Vec, +} + +#[derive(Deserialize, Clone, Debug)] +pub struct Fixture { + pub fixture_id: String, + pub model: String, + #[serde(default)] + pub pos_x: f32, + #[serde(default)] + pub pos_y: f32, + pub elements: Vec, +} + +#[derive(Serialize, Clone, Debug)] +pub struct Update { + pub channel_id: String, + pub value: u8, +} + +pub async fn fetch_fixtures(client: &reqwest::Client, base: &str) -> Result> { + let url = format!("{base}/api/v1/fixtures/"); + let resp = client + .get(&url) + .send() + .await + .with_context(|| format!("GET {url}"))? + .error_for_status()? + .json::>() + .await?; + Ok(resp) +} + +pub fn http_client() -> Result { + Ok(reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build()?) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..2a1cdd6 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,665 @@ +use anyhow::Result; +use crossterm::event::{self, Event, KeyCode, KeyModifiers}; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use crossterm::ExecutableCommand; +use futures_util::{SinkExt, StreamExt}; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::layout::Rect; +use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Sparkline}; +use ratatui::{Frame, Terminal}; +use std::collections::VecDeque; +use std::io; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::Mutex; +use tokio_tungstenite::tungstenite::Message; + +use lightcontrol::matelight::{feeder as matelight_feeder, StatusFn}; +use lightcontrol::{ + beat_status, fetch_fixtures, http_client, render_frame_with, shared_beat, shared_matelight, + snapshot_beat, spawn_beat, Preset, SharedBeat, SharedMatelight, DEFAULT_MATELIGHT_WS, PRESETS, +}; + +const DEFAULT_HOST: &str = "dmx.cbrp3.c-base.org:8000"; +const TARGET_FPS: f32 = 25.0; + +#[derive(Clone)] +struct AppState { + host: String, + preset: Preset, + selected_idx: usize, + fps: f32, + frame_count: u64, + connected: bool, + fixture_count: usize, + status: String, + matelight_status: String, + mic_status: String, + // Live beat-detector visualisation state (pumped from the mic thread's + // shared `BeatState` at ~20 Hz so the meter feels responsive). + beat_envelope: f32, // current short envelope, 0..~1 + beat_age: Option, // seconds since last detected beat + beat_count: u64, // monotonic counter + beat_bpm: f32, // median-of-8 BPM estimate + beat_history: VecDeque, // last 80 envelope samples × 1000 +} + +const BEAT_HISTORY_LEN: usize = 80; + +#[tokio::main] +async fn main() -> Result<()> { + let Args { host, preset, check, matelight_url } = parse_args(); + + if check { + return check_fixtures(&host).await; + } + + let preset_idx = PRESETS.iter().position(|p| *p == preset).unwrap_or(0); + let state = Arc::new(Mutex::new(AppState { + host: host.clone(), + preset, + selected_idx: preset_idx, + fps: 0.0, + frame_count: 0, + connected: false, + fixture_count: 0, + status: "starting…".to_string(), + matelight_status: "matelight: not started".to_string(), + mic_status: "mic: not started".to_string(), + beat_envelope: 0.0, + beat_age: None, + beat_count: 0, + beat_bpm: 0.0, + beat_history: VecDeque::with_capacity(BEAT_HISTORY_LEN), + })); + + // Spawn microphone capture + beat detector (own thread, owns cpal stream). + let beat = shared_beat(); + spawn_beat(beat.clone()); + + let matelight = shared_matelight(); + let matelight_status_cb: StatusFn = { + let state = state.clone(); + Arc::new(move |msg| { + // Stash the latest matelight status string for the TUI footer. + // Brief lock; never awaits user input. + if let Ok(mut s) = state.try_lock() { + s.matelight_status = msg; + } + }) + }; + let matelight_handle = tokio::spawn(matelight_feeder( + matelight_url.clone(), + matelight.clone(), + matelight_status_cb, + )); + + let engine_handle = tokio::spawn(engine_task( + state.clone(), + host, + matelight.clone(), + beat.clone(), + )); + + // Pump beat-detector state into the TUI at 20Hz so the level meter and + // sparkline feel live. The mic_status text and the beat-viz fields are + // updated in the same tick so they don't drift apart. + let mic_status_handle = { + let state = state.clone(); + let beat = beat.clone(); + tokio::spawn(async move { + let mut tick = tokio::time::interval(Duration::from_millis(50)); + loop { + tick.tick().await; + let now = Instant::now(); + let (envelope, age, count, bpm) = { + let bs = beat.lock().unwrap(); + let age = bs.last_beat.map(|t| now.duration_since(t).as_secs_f32()); + (bs.energy, age, bs.beat_count, bs.bpm) + }; + // Status line text — done separately so we don't double-lock. + let line = beat_status(&beat); + let mut s = state.lock().await; + s.mic_status = line; + s.beat_envelope = envelope; + s.beat_age = age; + s.beat_count = count; + s.beat_bpm = bpm; + // Scale 0..1 envelope into 0..1000 for the sparkline buffer. + // Boost a bit (×2.5) so quiet rooms still show some shape — + // the sparkline auto-scales against `max`. + let sample = ((envelope * 2500.0).clamp(0.0, 1000.0)) as u64; + s.beat_history.push_back(sample); + while s.beat_history.len() > BEAT_HISTORY_LEN { + s.beat_history.pop_front(); + } + } + }) + }; + + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + std::thread::spawn(move || loop { + match event::poll(Duration::from_millis(100)) { + Ok(true) => match event::read() { + Ok(ev) => { + if tx.send(ev).is_err() { + return; + } + } + Err(_) => return, + }, + Ok(false) => {} + Err(_) => return, + } + }); + + enable_raw_mode()?; + let mut stdout = io::stdout(); + stdout.execute(EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let mut tick = tokio::time::interval(Duration::from_millis(100)); + + let outcome: Result<()> = async { + loop { + let snap = state.lock().await.clone(); + terminal.draw(|f| render_ui(f, &snap))?; + + tokio::select! { + _ = tick.tick() => {} + Some(ev) = rx.recv() => { + if let Event::Key(key) = ev { + if (matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))) + && key.modifiers.contains(KeyModifiers::CONTROL) + { + break; + } + let mut s = state.lock().await; + match key.code { + KeyCode::Char('q') | KeyCode::Esc => break, + KeyCode::Char('1') => set_preset(&mut s, Preset::Rainbows), + KeyCode::Char('2') => set_preset(&mut s, Preset::BlueHouse), + KeyCode::Char('3') => set_preset(&mut s, Preset::Disco70), + KeyCode::Char('4') => set_preset(&mut s, Preset::Jungle), + KeyCode::Char('5') => set_preset(&mut s, Preset::Lasertec), + KeyCode::Char('6') => set_preset(&mut s, Preset::Police), + KeyCode::Char('7') => set_preset(&mut s, Preset::MirrorBall), + KeyCode::Char('8') => set_preset(&mut s, Preset::MorningLight), + KeyCode::Char('9') => set_preset(&mut s, Preset::OrangeGreenBall), + KeyCode::Char('m') | KeyCode::Char('M') => { + set_preset(&mut s, Preset::Matelight); + } + KeyCode::Char('s') | KeyCode::Char('S') => { + set_preset(&mut s, Preset::GabbaStrobe); + } + KeyCode::Char('r') | KeyCode::Char('R') => { + set_preset(&mut s, Preset::RedStrobe); + } + // Capital L for blue (b/B is blackout). + KeyCode::Char('l') | KeyCode::Char('L') => { + set_preset(&mut s, Preset::BlueStrobe); + } + KeyCode::Char('p') | KeyCode::Char('P') => { + set_preset(&mut s, Preset::PinkStrobe); + } + KeyCode::Char('k') | KeyCode::Char('K') => { + set_preset(&mut s, Preset::BeatPulse); + } + KeyCode::Char('0') | KeyCode::Char('b') | KeyCode::Char('B') => { + set_preset(&mut s, Preset::Off); + } + KeyCode::Up if s.selected_idx > 0 => { + s.selected_idx -= 1; + } + KeyCode::Down if s.selected_idx + 1 < PRESETS.len() => { + s.selected_idx += 1; + } + KeyCode::Enter | KeyCode::Char(' ') => { + s.preset = PRESETS[s.selected_idx]; + } + _ => {} + } + } + } + } + } + Ok(()) + } + .await; + + disable_raw_mode().ok(); + terminal.backend_mut().execute(LeaveAlternateScreen).ok(); + terminal.show_cursor().ok(); + engine_handle.abort(); + matelight_handle.abort(); + mic_status_handle.abort(); + + outcome +} + +struct Args { + host: String, + preset: Preset, + check: bool, + matelight_url: String, +} + +fn parse_args() -> Args { + let mut host: Option = None; + let mut preset = Preset::Off; + let mut check = false; + let mut matelight_url: Option = None; + let mut it = std::env::args().skip(1); + while let Some(a) = it.next() { + match a.as_str() { + "--host" => host = it.next(), + "--preset" => { + if let Some(v) = it.next() { + preset = preset_from_str(&v).unwrap_or(preset); + } + } + "--matelight" => matelight_url = it.next(), + "--check" => check = true, + other if other.starts_with("--host=") => { + host = Some(other.trim_start_matches("--host=").to_string()); + } + other if other.starts_with("--preset=") => { + let v = other.trim_start_matches("--preset="); + preset = preset_from_str(v).unwrap_or(preset); + } + other if other.starts_with("--matelight=") => { + matelight_url = Some(other.trim_start_matches("--matelight=").to_string()); + } + other if !other.starts_with('-') && host.is_none() => { + host = Some(other.to_string()); + } + _ => {} + } + } + let host = host + .or_else(|| std::env::var("DMX_HOST").ok()) + .unwrap_or_else(|| DEFAULT_HOST.to_string()); + let matelight_url = matelight_url + .or_else(|| std::env::var("MATELIGHT_URL").ok()) + .unwrap_or_else(|| DEFAULT_MATELIGHT_WS.to_string()); + Args { host, preset, check, matelight_url } +} + +fn preset_from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "off" | "blackout" | "0" => Some(Preset::Off), + "rainbow" | "rainbows" | "1" => Some(Preset::Rainbows), + "blue" | "bluehouse" | "blue-house" | "2" => Some(Preset::BlueHouse), + "disco" | "disco70" | "70th" | "70th-disco" | "3" => Some(Preset::Disco70), + "jungle" | "4" => Some(Preset::Jungle), + "lasertec" | "laser" | "5" => Some(Preset::Lasertec), + "police" | "polizei" | "6" => Some(Preset::Police), + "mirror" | "mirrorball" | "mirror-ball" | "discoball" | "kugel" | "7" => { + Some(Preset::MirrorBall) + } + "morning" | "morninglight" | "morning-light" | "sunrise" | "dawn" | "sonnenaufgang" + | "morgen" | "8" => Some(Preset::MorningLight), + "orangegreen" | "orange-green" | "orange" | "green" | "orangeball" | "ogball" + | "kürbis" | "kuerbis" | "9" => Some(Preset::OrangeGreenBall), + "matelight" | "mate" | "sync" | "m" => Some(Preset::Matelight), + "gabba" | "strobe" | "strobo" | "stroboscope" | "s" => Some(Preset::GabbaStrobe), + "redstrobe" | "red-strobe" | "rot" | "rotstrobe" | "rot-strobe" | "red" | "r" + => Some(Preset::RedStrobe), + // Note: bare "blue" is already taken by the slow BlueHouse preset (2). + // The fast strobe needs the explicit "-strobe" or German "blau" alias. + "bluestrobe" | "blue-strobe" | "blau" | "blaustrobe" | "blau-strobe" | "l" + => Some(Preset::BlueStrobe), + "pinkstrobe" | "pink-strobe" | "pink" | "p" | "magenta" + => Some(Preset::PinkStrobe), + "beat" | "beatpulse" | "beat-pulse" | "kick" | "mic" | "audio" | "k" + => Some(Preset::BeatPulse), + _ => None, + } +} + +fn set_preset(s: &mut AppState, p: Preset) { + s.preset = p; + if let Some(i) = PRESETS.iter().position(|x| *x == p) { + s.selected_idx = i; + } +} + +fn render_ui(f: &mut Frame, s: &AppState) { + let area = f.area(); + let outer = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(7), // header + Constraint::Length(5), // beat detector viz + Constraint::Min(1), // preset list + Constraint::Length(3), // help + ]) + .split(area); + + let conn_color = if s.connected { Color::Green } else { Color::Red }; + let conn_text = if s.connected { "connected" } else { "disconnected" }; + let header = Paragraph::new(vec![ + Line::from(vec![ + Span::raw("host: "), + Span::styled(s.host.clone(), Style::default().fg(Color::Cyan)), + Span::raw(" ["), + Span::styled( + conn_text, + Style::default() + .fg(conn_color) + .add_modifier(Modifier::BOLD), + ), + Span::raw("]"), + ]), + Line::from(format!( + "fixtures: {} frames: {} fps: {:.1}", + s.fixture_count, s.frame_count, s.fps + )), + Line::from(format!("status: {}", s.status)), + Line::from(s.matelight_status.clone()), + Line::from(s.mic_status.clone()), + ]) + .block( + Block::default() + .borders(Borders::ALL) + .title(" c-base lightcontrol "), + ); + f.render_widget(header, outer[0]); + render_beat_panel(f, outer[1], s); + + let items: Vec = PRESETS + .iter() + .enumerate() + .map(|(i, p)| { + let active = *p == s.preset; + let hovered = i == s.selected_idx; + let cursor = if hovered { "›" } else { " " }; + let style = if active { + Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else if hovered { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; + ListItem::new(Line::from(vec![ + Span::raw(format!("{cursor} [{}] ", p.key())), + Span::raw(p.label()), + ])) + .style(style) + }) + .collect(); + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .title(" presets "), + ); + f.render_widget(list, outer[2]); + + let help = Paragraph::new(Line::from( + " 1-9 / m / s r L p / k(beat) select ↑/↓ + enter 0/b blackout q quit", + )) + .block(Block::default().borders(Borders::ALL)); + f.render_widget(help, outer[3]); +} + +/// Renders the beat-detector panel: flash bar that lights up on every beat, +/// BPM + count text on the left, a rolling sparkline of the audio envelope +/// on the right. The whole thing makes it instantly visible whether the +/// detector is locked onto the music or just chasing room noise. +fn render_beat_panel(f: &mut Frame, area: Rect, s: &AppState) { + let split = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Length(34), Constraint::Min(20)]) + .split(area); + + // --- Left: flash bar + BPM + counters ------------------------------- + // Flash level decays exponentially from each detected beat. 6/s means + // ~115 ms half-life — bright on the hit, gone in ~400 ms. + let flash = s + .beat_age + .map(|t| (-t * 6.0).exp().clamp(0.0, 1.0)) + .unwrap_or(0.0); + // Cycle a hue palette per beat so consecutive flashes are visually + // distinct — mirrors what the BeatPulse preset actually does to the + // lights. + let palette: [Color; 6] = [ + Color::Red, + Color::LightYellow, + Color::Green, + Color::Cyan, + Color::Magenta, + Color::LightBlue, + ]; + let beat_color = palette[(s.beat_count as usize) % palette.len()]; + let dim_color = if flash > 0.6 { + beat_color + } else if flash > 0.2 { + Color::Yellow + } else if flash > 0.05 { + Color::DarkGray + } else { + Color::Black + }; + // ~13 wide bar made of full blocks; brighter on the hit. + let flash_label = if flash > 0.05 { + Span::styled( + "█████████████", + Style::default().fg(dim_color).add_modifier(Modifier::BOLD), + ) + } else { + Span::styled("░░░░░░░░░░░░░", Style::default().fg(Color::DarkGray)) + }; + + // Level bar — the same envelope the sparkline plots, but as an + // instantaneous gauge so you can watch it climb and dip in realtime. + let level_width: usize = 20; + let lvl = (s.beat_envelope * 2.5).clamp(0.0, 1.0); // same boost as sparkline + let filled = (lvl * level_width as f32).round() as usize; + let mut level_bar = String::with_capacity(level_width); + for i in 0..level_width { + level_bar.push(if i < filled { '█' } else { '░' }); + } + let level_color = if lvl > 0.85 { + Color::LightRed + } else if lvl > 0.5 { + Color::Yellow + } else { + Color::Green + }; + + let bpm_text = if s.beat_bpm > 0.0 { + format!("{:6.1} BPM", s.beat_bpm) + } else { + " --- BPM".to_string() + }; + let age_text = match s.beat_age { + Some(a) if a < 9.9 => format!("{:.2}s ago", a), + _ => "—".to_string(), + }; + + let left = Paragraph::new(vec![ + Line::from(vec![Span::raw("BEAT "), flash_label]), + Line::from(vec![ + Span::raw("level "), + Span::styled(level_bar, Style::default().fg(level_color)), + ]), + Line::from(format!( + "{} beats={:<5} last {}", + bpm_text, s.beat_count, age_text + )), + ]) + .block( + Block::default() + .borders(Borders::ALL) + .title(" beat detector "), + ); + f.render_widget(left, split[0]); + + // --- Right: rolling envelope sparkline ------------------------------ + let history: Vec = s.beat_history.iter().copied().collect(); + let spark = Sparkline::default() + .block( + Block::default() + .borders(Borders::ALL) + .title(" envelope (last ~4 s) "), + ) + .data(&history) + .style(Style::default().fg(Color::Cyan)) + .max(1000); + f.render_widget(spark, split[1]); +} + +async fn engine_task( + state: Arc>, + host: String, + matelight: SharedMatelight, + beat: SharedBeat, +) { + let base = format!("http://{host}"); + let ws_url = format!("ws://{host}/api/v1/websocket_state/"); + let client = match http_client() { + Ok(c) => c, + Err(e) => { + set_status(&state, format!("client init: {e}"), false).await; + return; + } + }; + + loop { + set_status(&state, "fetching fixtures…", false).await; + + let fixtures = match fetch_fixtures(&client, &base).await { + Ok(f) => f, + Err(e) => { + set_status(&state, format!("fixture fetch: {e}"), false).await; + tokio::time::sleep(Duration::from_secs(3)).await; + continue; + } + }; + + { + let mut s = state.lock().await; + s.fixture_count = fixtures.len(); + } + + set_status(&state, "connecting websocket…", false).await; + let stream = match tokio_tungstenite::connect_async(&ws_url).await { + Ok((s, _)) => s, + Err(e) => { + set_status(&state, format!("ws connect: {e}"), false).await; + tokio::time::sleep(Duration::from_secs(3)).await; + continue; + } + }; + let (mut write, mut read) = stream.split(); + + // Server sends a full-state snapshot on connect — drain it. + let _ = tokio::time::timeout(Duration::from_millis(800), read.next()).await; + + set_status(&state, "running", true).await; + + let start = Instant::now(); + let frame_dt = Duration::from_secs_f32(1.0 / TARGET_FPS); + let mut next = Instant::now() + frame_dt; + let mut window_start = Instant::now(); + let mut window_frames: u32 = 0; + + let drop_err: Option = loop { + let preset = { state.lock().await.preset }; + let t = start.elapsed().as_secs_f32(); + let now = Instant::now(); + let beat_input = snapshot_beat(&beat, now); + let mate_guard = matelight.lock().await; + let updates = + render_frame_with(preset, t, &fixtures, mate_guard.as_ref(), beat_input); + drop(mate_guard); + let payload = match serde_json::to_string(&updates) { + Ok(p) => p, + Err(e) => break Some(format!("encode: {e}")), + }; + if let Err(e) = write.send(Message::Text(payload)).await { + break Some(format!("send: {e}")); + } + + window_frames += 1; + if window_start.elapsed() >= Duration::from_millis(500) { + let secs = window_start.elapsed().as_secs_f32().max(1e-3); + let fps = window_frames as f32 / secs; + let mut s = state.lock().await; + s.fps = fps; + s.frame_count += window_frames as u64; + window_frames = 0; + window_start = Instant::now(); + } + + let now = Instant::now(); + if next > now { + tokio::time::sleep(next - now).await; + } + next += frame_dt; + let now = Instant::now(); + if now > next + frame_dt * 4 { + next = now + frame_dt; + } + }; + + let _ = write.close().await; + let msg = drop_err.unwrap_or_else(|| "websocket closed".to_string()); + set_status(&state, format!("reconnect in 2s — {msg}"), false).await; + tokio::time::sleep(Duration::from_secs(2)).await; + } +} + +async fn check_fixtures(host: &str) -> Result<()> { + let client = http_client()?; + let base = format!("http://{host}"); + let fixtures = fetch_fixtures(&client, &base).await?; + println!("host: {host}"); + println!("fixtures: {}", fixtures.len()); + let total_chans: usize = fixtures.iter() + .flat_map(|f| f.elements.iter().map(|e| e.channels.len())) + .sum(); + println!("channels total: {total_chans}"); + let mut models: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for f in &fixtures { + *models.entry(f.model.clone()).or_default() += 1; + } + println!("models:"); + for (m, n) in &models { + println!(" {n:3} x {m}"); + } + let mut elements: std::collections::BTreeSet = std::collections::BTreeSet::new(); + for f in &fixtures { + for e in &f.elements { + elements.insert(e.name.clone()); + } + } + println!("element names: {:?}", elements); + println!("\nfirst fixture sample:"); + if let Some(f) = fixtures.first() { + println!(" {} ({}) @({},{})", f.fixture_id, f.model, f.pos_x, f.pos_y); + for e in &f.elements { + let chans: Vec<&str> = e.channels.iter().map(|c| c.name.as_str()).collect(); + println!(" {}: [{}]", e.name, chans.join(", ")); + for c in &e.channels { + println!(" {} -> {}", c.name, c.channel_id); + } + } + } + Ok(()) +} + +async fn set_status(state: &Arc>, msg: impl Into, connected: bool) { + let mut s = state.lock().await; + s.status = msg.into(); + s.connected = connected; +} diff --git a/src/matelight.rs b/src/matelight.rs new file mode 100644 index 0000000..9adeb15 --- /dev/null +++ b/src/matelight.rs @@ -0,0 +1,238 @@ +//! Matelight live frame feeder. +//! +//! Connects to the Matelight hardware controller's `/monitor` WebSocket +//! (subprotocol `matemon`), parses JSON config + Brightness messages and +//! binary RGB framebuffer pushes, and keeps a shared `MatelightFrame` up +//! to date. The `Matelight` preset samples from this frame to colour the +//! hall lights in real time. +//! +//! Wire format (reverse-engineered from the live server's `/monitor` HTML): +//! - On connect, server sends JSON: `{"Screen":{"Crates_X":8,"Crates_Y":4, +//! "CrateSize_X":5,"CrateSize_Y":4}}` and `{"Brightness":}`. +//! - Subsequent binary messages: `width*height*3` bytes of row-major RGB +//! (40×16 = 1920 bytes for the standard rig). + +use anyhow::{anyhow, Context, Result}; +use futures_util::StreamExt; +use serde::Deserialize; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::Mutex; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::Message; + +pub const DEFAULT_MATELIGHT_WS: &str = "ws://matelight.cbrp3.c-base.org:8081/monitor"; + +#[derive(Clone, Debug)] +pub struct MatelightFrame { + pub width: usize, + pub height: usize, + /// 0..=255, mirrors the matelight's master brightness slider. + pub brightness: u8, + /// Row-major RGB triples. `pixels.len() == width * height * 3`. + pub pixels: Vec, +} + +impl MatelightFrame { + fn pixel(&self, x: usize, y: usize) -> (u8, u8, u8) { + let i = (y * self.width + x) * 3; + (self.pixels[i], self.pixels[i + 1], self.pixels[i + 2]) + } + + /// Sample a vertical strip of the matelight centred on the column that + /// maps to `t` (0.0 = left edge, 1.0 = right edge). Returns the average + /// RGB of that strip across all rows. `half_band` controls the width: + /// 0 = single column, 1 = ±1 col, 2 = ±2 cols, etc. + pub fn sample_column(&self, t: f32, half_band: usize) -> (u8, u8, u8) { + if self.pixels.is_empty() { + return (0, 0, 0); + } + let t = t.clamp(0.0, 1.0); + let center = ((t * (self.width.saturating_sub(1)) as f32).round() as isize) + .clamp(0, self.width as isize - 1) as usize; + let lo = center.saturating_sub(half_band); + let hi = (center + half_band).min(self.width - 1); + + let mut r_sum: u32 = 0; + let mut g_sum: u32 = 0; + let mut b_sum: u32 = 0; + let mut count: u32 = 0; + for y in 0..self.height { + for x in lo..=hi { + let (r, g, b) = self.pixel(x, y); + r_sum += r as u32; + g_sum += g as u32; + b_sum += b as u32; + count += 1; + } + } + if count == 0 { + return (0, 0, 0); + } + ((r_sum / count) as u8, (g_sum / count) as u8, (b_sum / count) as u8) + } +} + +pub type SharedMatelight = Arc>>; + +pub fn shared() -> SharedMatelight { + Arc::new(Mutex::new(None)) +} + +#[derive(Deserialize)] +struct ScreenMsg { + #[serde(rename = "Screen")] + screen: Option, + #[serde(rename = "Brightness")] + brightness: Option, +} + +#[derive(Deserialize)] +struct ScreenSpec { + #[serde(rename = "Crates_X")] + crates_x: usize, + #[serde(rename = "Crates_Y")] + crates_y: usize, + #[serde(rename = "CrateSize_X")] + crate_size_x: usize, + #[serde(rename = "CrateSize_Y")] + crate_size_y: usize, +} + +/// Status callback signature: receives short human-readable status updates +/// (e.g. "matelight: connected 40x16"). Engine plumbs these into the TUI. +pub type StatusFn = Arc; + +/// Connects to the matelight monitor websocket and pushes frames into +/// `shared` until cancelled. Reconnects on error with a 3s backoff. +pub async fn feeder(url: String, shared: SharedMatelight, status: StatusFn) { + loop { + status(format!("matelight: connecting {url}")); + match feeder_once(&url, &shared, &status).await { + Ok(()) => status("matelight: stream ended".to_string()), + Err(e) => status(format!("matelight: {e}")), + } + // Drop the last frame so the renderer goes back to "no signal". + *shared.lock().await = None; + tokio::time::sleep(Duration::from_secs(3)).await; + } +} + +async fn feeder_once(url: &str, shared: &SharedMatelight, status: &StatusFn) -> Result<()> { + // Hand-build a request so we can attach the `matemon` subprotocol header + // (the server JS uses `new WebSocket(url, 'matemon')`). + let mut req = url.into_client_request().context("invalid matelight URL")?; + req.headers_mut().insert( + "Sec-WebSocket-Protocol", + "matemon".parse().expect("static header value"), + ); + + let (stream, _) = tokio_tungstenite::connect_async(req) + .await + .context("matelight ws connect")?; + let (_write, mut read) = stream.split(); + + let mut width = 0usize; + let mut height = 0usize; + let mut brightness: u8 = 255; + + while let Some(msg) = read.next().await { + match msg.context("matelight ws read")? { + Message::Text(t) => { + let parsed: ScreenMsg = + serde_json::from_str(&t).context("matelight ws json decode")?; + if let Some(s) = parsed.screen { + width = s.crates_x * s.crate_size_x; + height = s.crates_y * s.crate_size_y; + status(format!("matelight: connected {width}x{height}")); + } + if let Some(b) = parsed.brightness { + // The server's "Brightness" message is a 0..1 float (the + // hardware controller normalises it from the 0..255 byte). + brightness = (b.clamp(0.0, 1.0) * 255.0).round() as u8; + } + } + Message::Binary(buf) => { + if width == 0 || height == 0 { + // Got pixels before the Screen config — ignore until we + // know the geometry. + continue; + } + let expected = width * height * 3; + if buf.len() != expected { + // Wrong-size frame: don't trust it, but don't kill the + // stream either. + status(format!( + "matelight: bad frame {} bytes (want {})", + buf.len(), + expected + )); + continue; + } + let frame = MatelightFrame { + width, + height, + brightness, + pixels: buf.to_vec(), + }; + *shared.lock().await = Some(frame); + } + Message::Close(_) => return Err(anyhow!("server closed connection")), + // Pings/pongs/frame fragments — the tungstenite default handler + // takes care of pongs; nothing else to do. + _ => {} + } + } + Err(anyhow!("ws stream ended without close frame")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn solid_frame(w: usize, h: usize, r: u8, g: u8, b: u8) -> MatelightFrame { + let mut pixels = Vec::with_capacity(w * h * 3); + for _ in 0..(w * h) { + pixels.push(r); + pixels.push(g); + pixels.push(b); + } + MatelightFrame { width: w, height: h, brightness: 255, pixels } + } + + #[test] + fn sample_column_returns_solid_colour() { + let f = solid_frame(40, 16, 200, 80, 30); + assert_eq!(f.sample_column(0.0, 2), (200, 80, 30)); + assert_eq!(f.sample_column(0.5, 2), (200, 80, 30)); + assert_eq!(f.sample_column(1.0, 2), (200, 80, 30)); + } + + #[test] + fn sample_column_picks_correct_side() { + // Left half red, right half blue. Half-band 0 → single column. + let w = 40; + let h = 16; + let mut pixels = Vec::with_capacity(w * h * 3); + for _ in 0..h { + for x in 0..w { + if x < w / 2 { + pixels.extend_from_slice(&[255, 0, 0]); + } else { + pixels.extend_from_slice(&[0, 0, 255]); + } + } + } + let f = MatelightFrame { width: w, height: h, brightness: 255, pixels }; + let (lr, _, lb) = f.sample_column(0.05, 0); + let (rr, _, rb) = f.sample_column(0.95, 0); + assert!(lr > 200 && lb < 20, "left should be red, got ({lr},_,{lb})"); + assert!(rb > 200 && rr < 20, "right should be blue, got ({rr},_,{rb})"); + } + + #[test] + fn sample_handles_empty_gracefully() { + let f = MatelightFrame { width: 0, height: 0, brightness: 0, pixels: vec![] }; + assert_eq!(f.sample_column(0.5, 2), (0, 0, 0)); + } +} diff --git a/src/presets.rs b/src/presets.rs new file mode 100644 index 0000000..9a8d37e --- /dev/null +++ b/src/presets.rs @@ -0,0 +1,1458 @@ +use crate::{BeatInput, Element, Fixture, MatelightFrame, Update}; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Preset { + Off, + Rainbows, + BlueHouse, + Disco70, + Jungle, + Lasertec, + Police, + MirrorBall, + MorningLight, + OrangeGreenBall, + Matelight, + GabbaStrobe, + RedStrobe, + BlueStrobe, + PinkStrobe, + BeatPulse, +} + +impl Preset { + pub fn label(self) -> &'static str { + match self { + Preset::Rainbows => "Rainbows", + Preset::BlueHouse => "Blue House", + Preset::Disco70 => "70th Disco", + Preset::Jungle => "Jungle", + Preset::Lasertec => "Lasertec", + Preset::Police => "Police", + Preset::MirrorBall => "Mirror Ball (blue fade)", + Preset::MorningLight => "Morning Light (sunrise)", + Preset::OrangeGreenBall => "Orange/Green Mirror Ball", + Preset::Matelight => "Matelight Sync (live)", + Preset::GabbaStrobe => "Gabba Strobe (white, fast)", + Preset::RedStrobe => "Red Strobe (fast)", + Preset::BlueStrobe => "Blue Strobe (fast)", + Preset::PinkStrobe => "Pink Strobe (fast)", + Preset::BeatPulse => "Beat Pulse (mic)", + Preset::Off => "Blackout", + } + } + + pub fn key(self) -> char { + match self { + Preset::Rainbows => '1', + Preset::BlueHouse => '2', + Preset::Disco70 => '3', + Preset::Jungle => '4', + Preset::Lasertec => '5', + Preset::Police => '6', + Preset::MirrorBall => '7', + Preset::MorningLight => '8', + Preset::OrangeGreenBall => '9', + Preset::Matelight => 'm', + Preset::GabbaStrobe => 's', + Preset::RedStrobe => 'r', + Preset::BlueStrobe => 'L', + Preset::PinkStrobe => 'p', + Preset::BeatPulse => 'k', + Preset::Off => '0', + } + } +} + +pub const PRESETS: &[Preset] = &[ + Preset::Rainbows, + Preset::BlueHouse, + Preset::Disco70, + Preset::Jungle, + Preset::Lasertec, + Preset::Police, + Preset::MirrorBall, + Preset::MorningLight, + Preset::OrangeGreenBall, + Preset::Matelight, + Preset::GabbaStrobe, + Preset::RedStrobe, + Preset::BlueStrobe, + Preset::PinkStrobe, + Preset::BeatPulse, + Preset::Off, +]; + +const WHEEL_ONLY_MODELS: &[&str] = &[ + "LED TSL-250 Scan COB", +]; + +// CB-100 colour-wheel macros (centre of each 8-value band). Each macro +// is "closed" by default in `write_frame` (shutter held at 0); presets +// that want the CB-100 lit must set `shutter_open: true` AND drive a +// `colorwheel` value, otherwise the lamp stays dark. +const CB100_GREEN: u8 = 35; +const CB100_ORANGE_RED: u8 = 59; +/// CB-100 colour-wheel palette in roughly the same hue order as the TSL-250 +/// palette below, so a single beat-driven hue index drives both fixture +/// families to similar colours. Wheel macro centres: white=3, dark +/// blue=11, yellow=19, pink=27, green=35, red=43, light blue=51, orange +/// red=59. We omit "white" and "dark blue" (low-impact / muddy) and use 6 +/// punchy palette entries. +const CB100_BEAT_PALETTE: &[u8] = &[ + 43, // red + 59, // orange red + 19, // yellow + 35, // green + 51, // light blue + 27, // pink +]; +fn is_cb100(model: &str) -> bool { + matches!(model, "CB100 Led Color" | "CB-100 LED Color") +} + +// TSL-250 colour-wheel macros (centre of each 10-value band from the QXF +// fixture def). Used by the `BeatPulse` preset to bring the scanners +// alive on the beat. +fn is_tsl250(model: &str) -> bool { + model == "LED TSL-250 Scan COB" +} +/// 7-entry hue palette for the TSL-250 wheel. Centre of each macro band: +/// red=14, orange=74, yellow=44, green=24, light-blue=64, blue=34, magenta=54. +const TSL250_BEAT_PALETTE: &[u8] = &[14, 74, 44, 24, 64, 34, 54]; + +#[derive(Default, Clone, Copy)] +struct FixtureFrame { + r: u8, + g: u8, + b: u8, + amber: u8, + white: u8, + warmwhite: u8, + uv: u8, + strobe: u8, + dimmer: u8, + /// Mirror-ball / motor speed element. None = leave untouched (default). + speed: Option, + /// Colour-wheel position (CB-100 etc). None = wheel left untouched. + colorwheel: Option, + /// True → shutter held open (220); false → shutter held closed (0). + /// CB-100s need this true to actually emit light. + shutter_open: bool, +} + +pub fn render_frame(preset: Preset, t: f32, fixtures: &[Fixture]) -> Vec { + render_frame_with(preset, t, fixtures, None, None) +} + +/// Variant of [`render_frame`] that accepts optional live context for +/// presets that need it: a matelight frame (for `Matelight`) and a beat +/// snapshot (for `BeatPulse`). Both are ignored by presets that don't +/// consume them. The non-suffixed `render_frame` wraps this with `None`s +/// so existing callers and tests stay terse. +pub fn render_frame_with( + preset: Preset, + t: f32, + fixtures: &[Fixture], + matelight: Option<&MatelightFrame>, + beat: Option, +) -> Vec { + let mut out = Vec::with_capacity(fixtures.len() * 4); + let hall_x_range = hall_x_range(fixtures); + for fixture in fixtures { + let mut frame = match preset { + Preset::Off => FixtureFrame::default(), + Preset::Rainbows => rainbow(t, fixture), + Preset::BlueHouse => blue_house(t, fixture), + Preset::Disco70 => disco70(t, fixture), + Preset::Jungle => jungle(t, fixture), + Preset::Lasertec => lasertec(t, fixture), + Preset::Police => police(t, fixture), + Preset::MirrorBall => mirror_ball(t, fixture), + Preset::MorningLight => morning_light(t, fixture), + Preset::OrangeGreenBall => orange_green_ball(t, fixture), + Preset::Matelight => matelight_sync(t, fixture, matelight, hall_x_range), + Preset::GabbaStrobe => color_strobe(t, fixture, (255, 255, 255), true), + Preset::RedStrobe => color_strobe(t, fixture, (255, 0, 0), false), + Preset::BlueStrobe => color_strobe(t, fixture, (0, 60, 255), false), + Preset::PinkStrobe => color_strobe(t, fixture, (255, 60, 180), false), + Preset::BeatPulse => beat_pulse(t, fixture, beat), + }; + // Wheel-only fixtures (TSL-250 scanners) are normally blacked out + // because most presets can't paint a wheel coherently. BeatPulse is + // the exception — it explicitly drives the wheel itself, so it gets + // to opt out of the blanket blackout. + if WHEEL_ONLY_MODELS.contains(&fixture.model.as_str()) && preset != Preset::BeatPulse { + frame = FixtureFrame::default(); + } + write_frame(frame, fixture, &mut out); + } + out +} + +fn write_frame(frame: FixtureFrame, fixture: &Fixture, out: &mut Vec) { + for el in &fixture.elements { + match el.name.as_str() { + "rgb" => { + for c in &el.channels { + let v = match c.name.as_str() { + "r" | "red" => frame.r, + "g" | "green" => frame.g, + "b" | "blue" => frame.b, + _ => 0, + }; + out.push(Update { channel_id: c.channel_id.clone(), value: v }); + } + } + "dimmer" => emit_all(el, frame.dimmer, out), + "amber" => emit_all(el, frame.amber, out), + "white" => emit_all(el, frame.white, out), + "warmwhite" => emit_all(el, frame.warmwhite, out), + "uv" => emit_all(el, frame.uv, out), + "strobe" => emit_all(el, frame.strobe, out), + // Mirror-ball motor: only driven when a preset asks for it. + "speed" => { + if let Some(v) = frame.speed { + emit_all(el, v, out); + } + } + // Colour-wheel (CB-100 macros) — only emitted when a preset asks. + "colorwheel" => { + if let Some(v) = frame.colorwheel { + emit_all(el, v, out); + } + } + // Shutter: 220 = open, 0 = closed. Default closed so wheel-driven + // fixtures stay dark unless a preset explicitly opens them. + "shutter" => { + let v = if frame.shutter_open { 220 } else { 0 }; + emit_all(el, v, out); + } + // Explicitly held off so no leftover macro/auto state leaks through + "auto" | "functions" => emit_all(el, 0, out), + // Untouched: gobo, goborot, pan, tilt + _ => {} + } + } +} + +fn emit_all(el: &Element, value: u8, out: &mut Vec) { + for c in &el.channels { + out.push(Update { channel_id: c.channel_id.clone(), value }); + } +} + +// --- preset implementations ------------------------------------------------- + +fn rainbow(t: f32, f: &Fixture) -> FixtureFrame { + let hue = (t * 28.0 + f.pos_x * 0.55).rem_euclid(360.0); + let (r, g, b) = hsv_to_rgb(hue, 1.0, 1.0); + let breath = 0.82 + 0.18 * (t * 1.3 + f.pos_y * 0.005).sin(); + FixtureFrame { + r: scale(r, breath), + g: scale(g, breath), + b: scale(b, breath), + dimmer: scale(255, breath), + ..Default::default() + } +} + +fn blue_house(t: f32, f: &Fixture) -> FixtureFrame { + let breath = 0.50 + 0.32 * (t * 0.45 + f.pos_y * 0.003).sin(); + let teal = 0.5 + 0.5 * (t * 0.20 + f.pos_x * 0.0025).sin(); + FixtureFrame { + r: 0, + g: scale(60, breath * teal), + b: scale(255, breath), + dimmer: scale(220, breath), + ..Default::default() + } +} + +fn disco70(t: f32, f: &Fixture) -> FixtureFrame { + // Saturated 6-color chase, beat-driven + const COLORS: [(u8, u8, u8); 6] = [ + (255, 0, 0), // red + (255, 120, 0), // orange + (255, 230, 0), // yellow + (0, 220, 60), // green + (0, 90, 255), // blue + (200, 0, 220), // magenta + ]; + let beat = t * 2.6 + f.pos_x * 0.012; + let idx = (beat.floor() as i64).rem_euclid(COLORS.len() as i64) as usize; + let (r, g, b) = COLORS[idx]; + let frac = beat - beat.floor(); + // Sharp-attack / soft-decay pulse on each beat + let pulse = (0.55 + 0.45 * (1.0 - frac).powf(1.6)).clamp(0.3, 1.0); + FixtureFrame { + r: scale(r, pulse), + g: scale(g, pulse), + b: scale(b, pulse), + dimmer: scale(255, pulse), + ..Default::default() + } +} + +fn jungle(t: f32, f: &Fixture) -> FixtureFrame { + let canopy = pseudo_noise(t * 0.55, f.pos_x * 0.013, f.pos_y * 0.021); + let sunspots = pseudo_noise(t * 0.35 + 5.0, f.pos_x * 0.006, f.pos_y * 0.008); + let g = (140.0 + canopy * 110.0).clamp(60.0, 255.0) as u8; + let r = (35.0 + sunspots * 70.0).clamp(10.0, 120.0) as u8; + let b = 6u8; + let amber = (50.0 + sunspots * 130.0).clamp(0.0, 230.0) as u8; + let bright = (0.5 + 0.45 * canopy).clamp(0.22, 1.0); + FixtureFrame { + r: scale(r, bright), + g: scale(g, bright), + b: scale(b, bright), + amber: scale(amber, bright), + dimmer: scale(235, bright), + ..Default::default() + } +} + +fn lasertec(t: f32, f: &Fixture) -> FixtureFrame { + // Hard cyan/magenta alternation with software strobe via dimmer + let phase = (t * 3.5 + f.pos_x * 0.018).sin(); + let (r, g, b) = if phase > 0.0 { + (0, 255, 255) + } else { + (255, 0, 255) + }; + // ~9 Hz square-wave strobe with positional offset + let strobe = (t * 18.0 + f.pos_x * 0.04 + f.pos_y * 0.02).sin(); + let on = strobe > 0.1; + FixtureFrame { + r: if on { r } else { 0 }, + g: if on { g } else { 0 }, + b: if on { b } else { 0 }, + dimmer: if on { 255 } else { 0 }, + ..Default::default() + } +} + +fn police(t: f32, f: &Fixture) -> FixtureFrame { + // Split the room into two banks by x position. Each bank flashes hard, + // the two banks 180° out of phase; the red/blue assignment swaps every + // couple of seconds so it doesn't sit static. + let side = (f.pos_x * 0.012).sin() > 0.0; + let swap = ((t / 2.0) as i64).rem_euclid(2) == 0; + let red = side ^ swap; + let (r, g, b) = if red { (255, 0, 0) } else { (0, 0, 255) }; + let off = if side { 0.0 } else { std::f32::consts::PI }; + let on = (t * 18.0 + off).sin() > 0.2; + FixtureFrame { + r: if on { r } else { 0 }, + g: if on { g } else { 0 }, + b: if on { b } else { 0 }, + dimmer: if on { 255 } else { 0 }, + ..Default::default() + } +} + +fn mirror_ball(t: f32, f: &Fixture) -> FixtureFrame { + // Slow, dramatic blue swell from near-dark to full and back, with a gentle + // deep-blue ↔ cyan-blue drift so the reflected dots feel alive. Spins the + // mirror-ball motor at a moderate, steady speed. + let fade = (0.5 - 0.5 * (t * 0.38).cos()).powf(1.3); // 0..1 slow breath + let cyan = 0.5 + 0.5 * (t * 0.22 + f.pos_x * 0.0025).sin(); + let g = (75.0 * cyan) as u8; + FixtureFrame { + r: 0, + g: scale(g, fade), + b: scale(255, fade.max(0.06)), // keep a faint blue floor so it never fully dies + dimmer: scale(255, fade.max(0.06)), + speed: Some(110), // moderate, steady rotation of the disco ball + ..Default::default() + } +} + +fn beat_pulse(t: f32, f: &Fixture, beat: Option) -> FixtureFrame { + // ALL fixtures fire on the beat — RGB PARs cycle full-spectrum hues, + // CB-100s snap through their colour-wheel macros, TSL-250 scanners + // wake up and cycle through their 7-colour wheel. Brightness peaks + // hard at the beat and decays exponentially so each hit feels like a + // hit, not a smear. + // + // No mic / no beat yet → slow dim cyan heartbeat so the hall isn't + // dead and the operator can see "still alive, waiting for audio". + let Some(beat) = beat else { + return beat_pulse_no_mic(t, f); + }; + + // Exponential decay envelope, ~150ms half-life. Anchored so the very + // first frame after a beat is full bright (env=1) and falls fast. + let env = (-beat.time_since_beat * 5.0).exp().clamp(0.0, 1.0); + // Tiny floor so the room isn't pitch-black between beats. + const FLOOR: f32 = 0.10; + let lvl = (FLOOR + (1.0 - FLOOR) * env).clamp(0.0, 1.0); + + // TSL-250 scanners — drive the wheel from the beat-indexed palette, + // dimmer pulses with the envelope. Gobo/pan/tilt left untouched so + // operators can pre-aim them. + if is_tsl250(&f.model) { + let wheel = TSL250_BEAT_PALETTE[(beat.beat_index as usize) % TSL250_BEAT_PALETTE.len()]; + return FixtureFrame { + colorwheel: Some(wheel), + shutter_open: true, + dimmer: scale(255, lvl), + ..Default::default() + }; + } + + // CB-100 LED Color — same idea with the CB-100 palette. + if is_cb100(&f.model) { + let wheel = CB100_BEAT_PALETTE[(beat.beat_index as usize) % CB100_BEAT_PALETTE.len()]; + return FixtureFrame { + colorwheel: Some(wheel), + shutter_open: true, + dimmer: scale(255, lvl), + ..Default::default() + }; + } + + // RGB fixtures — cycle through the full hue circle, advancing 67° per + // beat (relatively prime to 360 so consecutive beats are clearly + // different colours). Fixture x-position adds a small per-fixture + // offset so the room reads as a wave rather than one flat colour. + let hue = ((beat.beat_index as f32) * 67.0 + f.pos_x * 0.4).rem_euclid(360.0); + let (r, g, b) = hsv_to_rgb(hue, 1.0, 1.0); + FixtureFrame { + r: scale(r, lvl), + g: scale(g, lvl), + b: scale(b, lvl), + dimmer: scale(255, lvl), + ..Default::default() + } +} + +fn beat_pulse_no_mic(t: f32, f: &Fixture) -> FixtureFrame { + // Slow dim cyan heartbeat — clearly "waiting", not running. + let pulse = 0.20 + 0.12 * (t * 1.2).sin(); + if is_tsl250(&f.model) { + return FixtureFrame { + colorwheel: Some(64), // light blue + shutter_open: true, + dimmer: scale(80, pulse), + ..Default::default() + }; + } + if is_cb100(&f.model) { + return FixtureFrame { + colorwheel: Some(51), // light blue + shutter_open: true, + dimmer: scale(80, pulse), + ..Default::default() + }; + } + FixtureFrame { + r: 0, + g: scale(120, pulse), + b: scale(200, pulse), + dimmer: scale(160, pulse), + ..Default::default() + } +} + +fn color_strobe(t: f32, f: &Fixture, color: (u8, u8, u8), with_cold_white: bool) -> FixtureFrame { + // Brutal strobe at the engine's Nyquist rate — one ON frame, one OFF + // frame, alternating cleanly at 25 fps for a 12.5 Hz strobe. About as + // fast a coherent strobe as we can push over the wire. + // + // We derive the strobe state from the *frame index*, not from + // `t * rate`.fract() — fp rounding makes the fract() approach unstable + // right at the threshold (0.04 s ticks land at 0.5 ± tiny ε), which + // would produce occasional double-on or double-off frames. Rounding + // `t * 25` to the nearest frame index is bulletproof. + // + // CB-100s have no RGB and the wheel-only models (TSL-250 scanners) + // are kept dark by the existing engine rule. Everyone else flashes + // the chosen colour at full bright. The cold-white channel is only + // engaged for the white (gabba) variant — for coloured strobes it + // would dilute the colour. Warm-white and amber stay off so colours + // read clean rather than muddied. + // + // ⚠ Photosensitivity warning: this is a hard 12.5 Hz strobe. + const ENGINE_FPS: f32 = 25.0; + let frame_idx = (t * ENGINE_FPS).round() as i64; + let on = frame_idx.rem_euclid(2) == 0; + if !on || is_cb100(&f.model) { + return FixtureFrame::default(); + } + let (r, g, b) = color; + FixtureFrame { + r, + g, + b, + white: if with_cold_white { 255 } else { 0 }, + dimmer: 255, + ..Default::default() + } +} + +fn matelight_sync( + t: f32, + f: &Fixture, + mate: Option<&MatelightFrame>, + hall_x: (f32, f32), +) -> FixtureFrame { + // Live room-sync: each fixture samples a 5-column-wide vertical band of + // the matelight, picked by where it sits along the hall's x axis. + // Brightness of the hall scales with the matelight's master brightness + // so when matelight is dimmed, the hall dims with it. + // + // If we don't have a frame yet (just connected, server down, etc), fall + // back to a slow dim white pulse so the hall doesn't go fully black + // mid-show. Visually obvious that the sync isn't active. + let Some(mate) = mate else { + let breath = 0.30 + 0.20 * (t * 1.0).sin(); + return FixtureFrame { + r: scale(180, breath), + g: scale(180, breath), + b: scale(200, breath), + dimmer: scale(180, breath), + ..Default::default() + }; + }; + + // Map fixture x to a 0..1 position across the matelight's column axis. + // hall_x is the (min, max) of fixture x positions in this scene, so the + // outer-most fixtures land at columns 0 and width-1 respectively. + let (xmin, xmax) = hall_x; + let span = (xmax - xmin).max(1.0); + let norm = ((f.pos_x - xmin) / span).clamp(0.0, 1.0); + let (r, g, b) = mate.sample_column(norm, 2); + + // Boost saturation a touch — averaging dilutes punch, and the hall + // wash should be expressive, not muddy. + let (r, g, b) = saturate_rgb(r, g, b, 1.35); + + // Master brightness from matelight's own slider, normalised to 0..1. + let mate_bright = (mate.brightness as f32 / 255.0).clamp(0.0, 1.0); + // Never go fully dark even at master=0 — keep a tiny floor so audio-only + // moments (matelight at 0 brightness) still leave the room readable. + let lvl = (0.08 + 0.92 * mate_bright).clamp(0.0, 1.0); + + FixtureFrame { + r: scale(r, lvl), + g: scale(g, lvl), + b: scale(b, lvl), + dimmer: scale(255, lvl), + ..Default::default() + } +} + +fn hall_x_range(fixtures: &[Fixture]) -> (f32, f32) { + let mut lo = f32::INFINITY; + let mut hi = f32::NEG_INFINITY; + for f in fixtures { + if f.pos_x < lo { + lo = f.pos_x; + } + if f.pos_x > hi { + hi = f.pos_x; + } + } + if !lo.is_finite() || !hi.is_finite() { + return (0.0, 1.0); + } + (lo, hi) +} + +/// Push saturation by `gain` (1.0 = unchanged). Works in RGB by pulling +/// each channel away from the mean — cheap and good enough for a wash. +fn saturate_rgb(r: u8, g: u8, b: u8, gain: f32) -> (u8, u8, u8) { + let rf = r as f32; + let gf = g as f32; + let bf = b as f32; + let mean = (rf + gf + bf) / 3.0; + let r2 = (mean + (rf - mean) * gain).clamp(0.0, 255.0) as u8; + let g2 = (mean + (gf - mean) * gain).clamp(0.0, 255.0) as u8; + let b2 = (mean + (bf - mean) * gain).clamp(0.0, 255.0) as u8; + (r2, g2, b2) +} + +fn orange_green_ball(t: f32, f: &Fixture) -> FixtureFrame { + // Slow, smooth orange ↔ green crossfade with the mirror-ball motor + // spinning. Fixtures near the ball (the two RevueLED pinspots at d≈32-36, + // plus the Root PAR 6 diamond at d≈57) sit clearly brighter than the rest + // of the hall so the reflections pop. + // + // The crossfade is a pure cosine — no dwell shaping — so the colour + // ramps through the cycle smoothly rather than snapping at the ends. + // Period ≈ 21 s. + let alpha = (0.5 + 0.5 * (t * 0.30 + f.pos_x * 0.0010).cos()).clamp(0.0, 1.0); + + // CB-100s (dmx-4-149 / dmx-4-153 — side washes at x=100): no RGB, just a + // colour wheel. Snap between the green and orange-red macros — the wheel + // is discrete and can't smoothly fade, but the snap is gentle enough at + // this slow tempo to read as accent washes rather than flashes. + if is_cb100(&f.model) { + let wheel = if alpha > 0.5 { CB100_ORANGE_RED } else { CB100_GREEN }; + let pulse = 0.80 + 0.20 * (t * 0.6).sin(); + return FixtureFrame { + colorwheel: Some(wheel), + shutter_open: true, + dimmer: scale(255, pulse), + ..Default::default() + }; + } + + // alpha = 1 → orange (255,110,0), alpha = 0 → green (10,220,30) + let r = lerp_u8(10.0, 255.0, alpha); + let g = lerp_u8(220.0, 110.0, alpha); + let b = lerp_u8(30.0, 0.0, alpha); + // Warm channels only kick in on the orange side; greens stay clean. + let amber = lerp_u8(0.0, 200.0, alpha); + let warmwhite = lerp_u8(0.0, 110.0, alpha); + + // Spatial boost around mirror-ball motor @ (520, 395). 160 px radius + // covers the pinspots, Root PAR 6 diamond, and a touch beyond into the + // surrounding PAR ring. Near level is held high (0.82..1.00) so the + // ball's spotlight reads clearly brighter than the ambient wash. + let dist = ((f.pos_x - 520.0).powi(2) + (f.pos_y - 395.0).powi(2)).sqrt(); + let near = (1.0 - dist / 160.0).clamp(0.0, 1.0); + let throb = 0.5 + 0.5 * (t * 1.1).sin(); + let near_level = 0.82 + 0.18 * throb; + let far_level = 0.38 + 0.10 * (t * 0.30 + f.pos_y * 0.004).sin(); + let lvl = (far_level + near * (near_level - far_level)).clamp(0.18, 1.0); + + FixtureFrame { + r: scale(r, lvl), + g: scale(g, lvl), + b: scale(b, lvl), + amber: scale(amber, lvl), + warmwhite: scale(warmwhite, lvl), + dimmer: scale(255, lvl), + speed: Some(140), // a touch faster than the blue mirror-ball preset + ..Default::default() + } +} + +fn lerp_u8(a: f32, b: f32, t: f32) -> u8 { + (a + (b - a) * t).round().clamp(0.0, 255.0) as u8 +} + +fn morning_light(t: f32, f: &Fixture) -> FixtureFrame { + // A slow, continuous sunrise: deep night blue → red dawn horizon → warm + // amber sunrise → bright golden daylight, then gently back down. One full + // loop is ~100s. A faint per-fixture spatial offset makes the warmth roll + // across the hall like real daylight rather than flat-fading everything in + // lockstep. Leans on amber/white/warmwhite where fixtures have them so the + // morning glow reads as warm, not just bright RGB. + let cycle = t * 0.063 + f.pos_x * 0.0015 + f.pos_y * 0.0010; + let elev = (0.5 - 0.5 * cycle.cos()).clamp(0.0, 1.0); // 0 = night, 1 = noon + + // Colour-temperature keyframes across the sun's elevation: + // (elev, r, g, b, amber, warmwhite, white) + const K: &[(f32, f32, f32, f32, f32, f32, f32)] = &[ + (0.00, 2.0, 4.0, 30.0, 0.0, 0.0, 0.0), // deep night + (0.18, 60.0, 16.0, 40.0, 20.0, 18.0, 0.0), // first predawn + (0.34, 190.0, 45.0, 18.0, 140.0, 70.0, 0.0), // red dawn horizon + (0.55, 255.0, 120.0, 22.0, 225.0, 150.0, 25.0), // warm sunrise + (0.78, 255.0, 190.0, 95.0, 200.0, 225.0, 130.0), // golden morning + (1.00, 255.0, 236.0, 180.0, 120.0, 255.0, 220.0), // bright daylight + ]; + let (r, g, b, amber, ww, white) = ramp6(K, elev); + + // Brightness tracks the sun but keeps a low night floor so it never blacks + // out fully; a slow shimmer adds a breath of atmosphere. + let dim = 0.16 + 0.84 * elev; + let shimmer = 0.96 + 0.04 * (t * 0.7 + f.pos_x * 0.01).sin(); + let lvl = (dim * shimmer).clamp(0.0, 1.0); + + FixtureFrame { + r: scale(r as u8, lvl), + g: scale(g as u8, lvl), + b: scale(b as u8, lvl), + amber: scale(amber as u8, lvl), + white: scale(white as u8, lvl), + warmwhite: scale(ww as u8, lvl), + dimmer: scale(255, lvl), + ..Default::default() + } +} + +// --- helpers ---------------------------------------------------------------- + +/// Linear interpolation through a table of `(x, a, b, c, d, e, f)` keyframes, +/// sorted ascending by `x`. Clamps to the table's endpoints outside its range. +#[allow(clippy::type_complexity)] +fn ramp6( + keys: &[(f32, f32, f32, f32, f32, f32, f32)], + x: f32, +) -> (f32, f32, f32, f32, f32, f32) { + let x = x.clamp(keys[0].0, keys[keys.len() - 1].0); + for w in keys.windows(2) { + let (x0, x1) = (w[0].0, w[1].0); + if x >= x0 && x <= x1 { + let f = if (x1 - x0).abs() < 1e-6 { + 0.0 + } else { + (x - x0) / (x1 - x0) + }; + let l = |a: f32, b: f32| a + (b - a) * f; + return ( + l(w[0].1, w[1].1), + l(w[0].2, w[1].2), + l(w[0].3, w[1].3), + l(w[0].4, w[1].4), + l(w[0].5, w[1].5), + l(w[0].6, w[1].6), + ); + } + } + let k = keys[keys.len() - 1]; + (k.1, k.2, k.3, k.4, k.5, k.6) +} + +fn scale(v: u8, factor: f32) -> u8 { + let factor = factor.clamp(0.0, 1.0); + (v as f32 * factor).round().clamp(0.0, 255.0) as u8 +} + +fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) { + let h = h.rem_euclid(360.0); + let c = v * s; + let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs()); + let m = v - c; + let (r1, g1, b1) = match (h as u32 / 60).min(5) { + 0 => (c, x, 0.0), + 1 => (x, c, 0.0), + 2 => (0.0, c, x), + 3 => (0.0, x, c), + 4 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + ( + ((r1 + m) * 255.0).round().clamp(0.0, 255.0) as u8, + ((g1 + m) * 255.0).round().clamp(0.0, 255.0) as u8, + ((b1 + m) * 255.0).round().clamp(0.0, 255.0) as u8, + ) +} + +fn pseudo_noise(t: f32, x: f32, y: f32) -> f32 { + let a = (t * 0.73 + x * 1.31).sin(); + let b = (t * 0.41 + y * 2.17).sin(); + let c = (t * 0.59 + (x * 0.71 + y * 1.13).sin() * 2.0).cos(); + ((a + b + c) / 3.0 + 1.0) * 0.5 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Channel, Element}; + use std::collections::HashMap; + + fn mk(id: u32, model: &str, x: f32, y: f32, elements: &[(&str, &[&str])]) -> Fixture { + let fid = format!("f{id}"); + let elements = elements + .iter() + .map(|(name, chans)| Element { + name: (*name).to_string(), + channels: chans + .iter() + .map(|c| Channel { + name: (*c).to_string(), + channel_id: format!("{fid}/{name}/{c}"), + }) + .collect(), + }) + .collect(); + Fixture { + fixture_id: fid, + model: model.to_string(), + pos_x: x, + pos_y: y, + elements, + } + } + + fn fixtures_sample() -> Vec { + vec![ + mk(0, "Cameo Root PAR 6", 100.0, 200.0, + &[("rgb", &["r","g","b"]), ("amber", &["amber"]), + ("white", &["white"]), ("uv", &["uv"]), + ("strobe", &["strobe"]), ("dimmer", &["dimmer"])]), + mk(1, "Showtec LED Par 56", 300.0, 200.0, + &[("rgb", &["r","g","b"]), ("dimmer", &["dimmer"])]), + mk(2, "LED TSL-250 Scan COB", 500.0, 200.0, + &[("dimmer", &["dimmer"]), ("colorwheel", &["color"]), + ("pan", &["pan"]), ("tilt", &["tilt"])]), + mk(3, "Stairville Octagon Theater 20x6W CW/WW/A", 700.0, 200.0, + &[("warmwhite", &["ww"]), ("white", &["cw"]), + ("amber", &["amber"]), ("dimmer", &["dimmer"])]), + ] + } + + fn updates_by_channel(updates: &[Update]) -> HashMap { + updates.iter().map(|u| (u.channel_id.clone(), u.value)).collect() + } + + #[test] + fn off_blacks_everything() { + let f = fixtures_sample(); + let u = render_frame(Preset::Off, 0.0, &f); + // Every emitted update is zero + assert!(!u.is_empty()); + assert!(u.iter().all(|u| u.value == 0)); + } + + fn cid(fid: u32, element: &str, chan: &str) -> String { + format!("f{fid}/{element}/{chan}") + } + + #[test] + fn wheel_only_fixture_stays_dark() { + let f = fixtures_sample(); + let u = render_frame(Preset::Rainbows, 1.0, &f); + let map = updates_by_channel(&u); + let scanner_dim = cid(2, "dimmer", "dimmer"); + assert_eq!(map.get(&scanner_dim), Some(&0), + "wheel-only scanner dimmer must be 0, got {:?}", map.get(&scanner_dim)); + // colorwheel/pan/tilt must NOT be in the update set (untouched) + for ch in [cid(2, "colorwheel", "color"), cid(2, "pan", "pan"), cid(2, "tilt", "tilt")] { + assert!(!map.contains_key(&ch), "channel {ch} should be untouched"); + } + } + + #[test] + fn rainbows_changes_color_over_time() { + let f = fixtures_sample(); + let a = render_frame(Preset::Rainbows, 0.0, &f); + let b = render_frame(Preset::Rainbows, 2.0, &f); + let ma = updates_by_channel(&a); + let mb = updates_by_channel(&b); + let r = cid(0, "rgb", "r"); + assert_ne!(ma[&r], mb[&r], "rainbow should change color over time"); + assert!(a.iter().any(|u| u.value > 0), "rainbow at t=0 should light something"); + } + + #[test] + fn blue_house_is_blue_dominant() { + let f = fixtures_sample(); + let u = render_frame(Preset::BlueHouse, 1.0, &f); + let m = updates_by_channel(&u); + let (r, g, b) = (m[&cid(0,"rgb","r")], m[&cid(0,"rgb","g")], m[&cid(0,"rgb","b")]); + assert_eq!(r, 0, "blue house has no red"); + assert!(b > g, "blue house: blue ({b}) should dominate green ({g})"); + assert!(b > 50, "blue house should have meaningful blue (got {b})"); + } + + #[test] + fn disco_changes_hue_across_x() { + let f = fixtures_sample(); + let mut differ = false; + for t in [0.0f32, 0.5, 1.5, 3.0] { + let u = render_frame(Preset::Disco70, t, &f); + let m = updates_by_channel(&u); + let p0 = (m[&cid(0,"rgb","r")], m[&cid(0,"rgb","g")], m[&cid(0,"rgb","b")]); + let p1 = (m[&cid(1,"rgb","r")], m[&cid(1,"rgb","g")], m[&cid(1,"rgb","b")]); + if p0 != p1 { differ = true; break; } + } + assert!(differ, "disco should produce different colors at different x positions"); + } + + #[test] + fn lasertec_strobes() { + let f = vec![mk(0, "FakePAR", 0.0, 0.0, + &[("rgb", &["r","g","b"]), ("dimmer", &["dim"])])]; + let mut on = 0; + let mut off = 0; + for i in 0..40 { + let t = i as f32 * 0.025; + let u = render_frame(Preset::Lasertec, t, &f); + let m = updates_by_channel(&u); + if m[&cid(0,"dimmer","dim")] > 0 { on += 1; } else { off += 1; } + } + assert!(on >= 5 && off >= 5, + "lasertec dimmer should toggle in 1s, got on={on} off={off}"); + } + + #[test] + fn jungle_is_green_dominant() { + let f = fixtures_sample(); + let mut g_total = 0u32; + let mut r_total = 0u32; + let mut b_total = 0u32; + for i in 0..30 { + let t = i as f32 * 0.13; + let u = render_frame(Preset::Jungle, t, &f); + let m = updates_by_channel(&u); + r_total += m[&cid(0,"rgb","r")] as u32; + g_total += m[&cid(0,"rgb","g")] as u32; + b_total += m[&cid(0,"rgb","b")] as u32; + } + assert!(g_total > r_total * 2, "jungle: green ({g_total}) >> red ({r_total})"); + assert!(g_total > b_total * 5, "jungle: green ({g_total}) >> blue ({b_total})"); + } + + #[test] + fn police_is_red_blue_only_and_strobes() { + // fixtures 0 (x=100) and 1 (x=300) land on opposite banks. + let f = fixtures_sample(); + let mut saw_red = false; + let mut saw_blue = false; + let mut saw_dark = false; + for i in 0..80 { + let t = i as f32 * 0.04; + let u = render_frame(Preset::Police, t, &f); + let m = updates_by_channel(&u); + for fx in [0u32, 1] { + let r = m[&cid(fx, "rgb", "r")]; + let g = m[&cid(fx, "rgb", "g")]; + let b = m[&cid(fx, "rgb", "b")]; + assert_eq!(g, 0, "police never uses green"); + if r > 0 { + saw_red = true; + } + if b > 0 { + saw_blue = true; + } + if r == 0 && b == 0 { + saw_dark = true; + } + } + } + assert!(saw_red, "police should show red"); + assert!(saw_blue, "police should show blue"); + assert!(saw_dark, "police should strobe (have dark frames)"); + } + + #[test] + fn mirror_ball_drives_motor_and_is_blue() { + let f = vec![ + mk(0, "Cameo Root PAR 6", 100.0, 200.0, + &[("rgb", &["r", "g", "b"]), ("dimmer", &["dim"])]), + mk(1, "MBM40D Mirror Ball Motor DMX", 50.0, 50.0, + &[("speed", &["spd"])]), + ]; + // Sample mid-swell so the fade isn't at a trough. + let mut motor_driven = false; + let mut blue_dominant_seen = false; + for i in 0..40 { + let t = i as f32 * 0.2; + let u = render_frame(Preset::MirrorBall, t, &f); + let m = updates_by_channel(&u); + if let Some(&spd) = m.get(&cid(1, "speed", "spd")) { + if spd > 0 { + motor_driven = true; + } + } + let r = m[&cid(0, "rgb", "r")]; + let b = m[&cid(0, "rgb", "b")]; + assert_eq!(r, 0, "mirror ball preset uses no red"); + if b > 20 { + blue_dominant_seen = true; + } + } + assert!(motor_driven, "mirror ball motor speed should be driven"); + assert!(blue_dominant_seen, "mirror ball preset should produce blue"); + } + + #[test] + fn other_presets_leave_motor_untouched() { + // The mirror-ball 'speed' element must NOT be emitted by other presets. + let f = vec![mk(0, "MBM40D Mirror Ball Motor DMX", 0.0, 0.0, + &[("speed", &["spd"])])]; + for p in [Preset::Rainbows, Preset::BlueHouse, Preset::Disco70, + Preset::Jungle, Preset::Lasertec, Preset::Police, + Preset::MorningLight, Preset::Matelight, Preset::GabbaStrobe, + Preset::RedStrobe, Preset::BlueStrobe, Preset::PinkStrobe, + Preset::BeatPulse] { + let u = render_frame(p, 1.0, &f); + assert!( + u.iter().all(|x| x.channel_id != cid(0, "speed", "spd")), + "{p:?} must leave mirror-ball speed untouched" + ); + } + } + + #[test] + fn orange_green_ball_drives_motor_and_crossfades_warm_to_green() { + // Place a fixture under the mirror-ball spotlight area, plus the motor. + let f = vec![ + mk(0, "Cameo Root PAR 6", 520.0, 395.0, + &[("rgb", &["r", "g", "b"]), ("amber", &["amber"]), + ("warmwhite", &["ww"]), ("dimmer", &["dim"])]), + mk(1, "MBM40D Mirror Ball Motor DMX", 50.0, 50.0, + &[("speed", &["spd"])]), + ]; + let mut motor_driven = false; + let mut saw_orange = false; + let mut saw_green = false; + // Sample two full crossfade cycles. + for i in 0..200 { + let t = i as f32 * 0.12; + let u = render_frame(Preset::OrangeGreenBall, t, &f); + let m = updates_by_channel(&u); + if let Some(&spd) = m.get(&cid(1, "speed", "spd")) { + if spd > 0 { + motor_driven = true; + } + } + let r = m[&cid(0, "rgb", "r")] as i32; + let g = m[&cid(0, "rgb", "g")] as i32; + let b = m[&cid(0, "rgb", "b")] as i32; + let amber = m[&cid(0, "amber", "amber")] as i32; + // Orange phase: red dominates, blue near zero, amber non-zero. + if r > 150 && r - g > 30 && b < 20 && amber > 30 { + saw_orange = true; + } + // Green phase: green dominates, red low. + if g > 120 && g - r > 40 && b < 30 { + saw_green = true; + } + } + assert!(motor_driven, "mirror-ball motor should be spinning"); + assert!(saw_orange, "should crossfade to a clearly-orange frame"); + assert!(saw_green, "should crossfade to a clearly-green frame"); + } + + #[test] + fn orange_green_ball_lights_cb100s_with_wheel_macros() { + // Real fixture IDs from the rig — dmx-4-149 and dmx-4-153, both at x=100. + let f = vec![ + Fixture { + fixture_id: "dmx-4-149".to_string(), + model: "CB100 Led Color".to_string(), + pos_x: 100.0, + pos_y: 360.0, + elements: vec![ + Element { name: "dimmer".to_string(), channels: vec![ + crate::Channel { name: "dim".to_string(), channel_id: "dmx-4-149/dimmer/dim".to_string() }]}, + Element { name: "colorwheel".to_string(), channels: vec![ + crate::Channel { name: "whe".to_string(), channel_id: "dmx-4-149/colorwheel/whe".to_string() }]}, + Element { name: "shutter".to_string(), channels: vec![ + crate::Channel { name: "shu".to_string(), channel_id: "dmx-4-149/shutter/shu".to_string() }]}, + Element { name: "functions".to_string(), channels: vec![ + crate::Channel { name: "fnc".to_string(), channel_id: "dmx-4-149/functions/fnc".to_string() }]}, + ], + }, + ]; + let mut saw_green_macro = false; + let mut saw_orange_macro = false; + let mut shutter_was_open = false; + let mut dimmer_was_up = false; + for i in 0..120 { + let t = i as f32 * 0.18; + let u = render_frame(Preset::OrangeGreenBall, t, &f); + let m: HashMap = u.iter().map(|x| (x.channel_id.clone(), x.value)).collect(); + let wheel = m["dmx-4-149/colorwheel/whe"]; + let shutter = m["dmx-4-149/shutter/shu"]; + let dim = m["dmx-4-149/dimmer/dim"]; + // Centre-of-band values: green=35, orange-red=59. + if wheel == CB100_GREEN { saw_green_macro = true; } + if wheel == CB100_ORANGE_RED { saw_orange_macro = true; } + if shutter >= 200 { shutter_was_open = true; } + if dim > 150 { dimmer_was_up = true; } + assert_eq!(m["dmx-4-149/functions/fnc"], 0, + "CB-100 functions channel must stay at 0 (no macro effect)"); + } + assert!(saw_green_macro, "CB-100 should snap to wheel=35 (green) on green phase"); + assert!(saw_orange_macro, "CB-100 should snap to wheel=59 (orange-red) on orange phase"); + assert!(shutter_was_open, "CB-100 shutter must be opened (≥200), not held closed"); + assert!(dimmer_was_up, "CB-100 dimmer must come up"); + } + + #[test] + fn other_presets_keep_cb100_dark() { + // CB-100s have no rgb — without shutter open + wheel set, they stay + // dark even though they're no longer in the wheel-only blacklist. + let f = vec![ + Fixture { + fixture_id: "dmx-4-149".to_string(), + model: "CB100 Led Color".to_string(), + pos_x: 100.0, + pos_y: 360.0, + elements: vec![ + Element { name: "dimmer".to_string(), channels: vec![ + crate::Channel { name: "dim".to_string(), channel_id: "cb/dimmer/dim".to_string() }]}, + Element { name: "colorwheel".to_string(), channels: vec![ + crate::Channel { name: "whe".to_string(), channel_id: "cb/colorwheel/whe".to_string() }]}, + Element { name: "shutter".to_string(), channels: vec![ + crate::Channel { name: "shu".to_string(), channel_id: "cb/shutter/shu".to_string() }]}, + ], + }, + ]; + for p in [Preset::Rainbows, Preset::BlueHouse, Preset::Disco70, + Preset::Jungle, Preset::Lasertec, Preset::Police, + Preset::MirrorBall, Preset::MorningLight, Preset::Matelight, + Preset::GabbaStrobe, Preset::RedStrobe, Preset::BlueStrobe, + Preset::PinkStrobe] { + let u = render_frame(p, 1.0, &f); + let m: HashMap = u.iter().map(|x| (x.channel_id.clone(), x.value)).collect(); + // Shutter must be 0 (closed) — that's what keeps them dark even if + // the RGB-derived dimmer is non-zero. + assert_eq!(m["cb/shutter/shu"], 0, "{p:?} must hold CB-100 shutter closed"); + // Colorwheel must be absent (left untouched) — no preset other + // than OrangeGreenBall has any business driving it. + assert!(!m.contains_key("cb/colorwheel/whe"), + "{p:?} must leave CB-100 colourwheel untouched"); + } + } + + #[test] + fn orange_green_ball_boosts_near_mirror_ball() { + // Same colour cycle, but compare a fixture on top of the ball vs one + // out in the far hall — near must average brighter. + let f = vec![ + mk(0, "Cameo Root PAR 6", 520.0, 395.0, + &[("rgb", &["r","g","b"]), ("dimmer", &["dim"])]), + mk(1, "Cameo Root PAR 6", 100.0, 100.0, + &[("rgb", &["r","g","b"]), ("dimmer", &["dim"])]), + ]; + let mut near_sum: u64 = 0; + let mut far_sum: u64 = 0; + for i in 0..80 { + let t = i as f32 * 0.15; + let u = render_frame(Preset::OrangeGreenBall, t, &f); + let m = updates_by_channel(&u); + near_sum += m[&cid(0, "dimmer", "dim")] as u64; + far_sum += m[&cid(1, "dimmer", "dim")] as u64; + } + assert!(near_sum > far_sum, + "fixtures near the mirror ball should be brighter: near={near_sum} far={far_sum}"); + } + + #[test] + fn morning_light_is_warm_and_brightens_over_cycle() { + // Warm-white-capable fixture so we exercise the amber/ww/white path. + let f = vec![mk(0, "Stairville Octagon Theater 20x6W CW/WW/A", 100.0, 200.0, + &[("rgb", &["r","g","b"]), ("amber", &["amber"]), + ("warmwhite", &["ww"]), ("white", &["cw"]), + ("dimmer", &["dimmer"])])]; + + let mut min_dim = 255u8; + let mut max_dim = 0u8; + let mut warm_bias_seen = false; + let mut ww_driven = false; + // Sweep a full ~100s loop. + for i in 0..120 { + let t = i as f32 * 0.9; + let u = render_frame(Preset::MorningLight, t, &f); + let m = updates_by_channel(&u); + let dim = m[&cid(0, "dimmer", "dimmer")]; + min_dim = min_dim.min(dim); + max_dim = max_dim.max(dim); + if m[&cid(0, "warmwhite", "ww")] > 0 { + ww_driven = true; + } + let (r, b) = (m[&cid(0, "rgb", "r")], m[&cid(0, "rgb", "b")]); + // Once the sun is up, the mix is warm: red dominates blue. + if r > 80 && r > b { + warm_bias_seen = true; + } + } + assert!(min_dim > 0, "morning light keeps a night floor, never full black"); + assert!(max_dim > 220, "morning light should reach bright daylight (got {max_dim})"); + assert!((max_dim as i32 - min_dim as i32) > 120, + "morning light should swell night→day (min={min_dim} max={max_dim})"); + assert!(warm_bias_seen, "sunrise should be warm (red over blue)"); + assert!(ww_driven, "morning light should drive warm-white where available"); + } + + #[test] + fn gabba_strobe_alternates_full_white_and_black() { + // At 25 fps the engine samples t in 0.04 s steps. The 12.5 Hz strobe + // must produce one full-white frame followed by one fully-black + // frame in alternation, with no half-lit frames in between. + let f = fixtures_sample(); + let mut on_count = 0; + let mut off_count = 0; + let mut prev_state: Option = None; + let mut alternated = true; + for i in 0..50 { + let t = i as f32 * 0.04; // engine frame rate + let u = render_frame(Preset::GabbaStrobe, t, &f); + let m = updates_by_channel(&u); + // Sample one RGB-only fixture (Showtec LED Par 56 at idx 1). + let (r, g, b, d) = ( + m[&cid(1, "rgb", "r")], + m[&cid(1, "rgb", "g")], + m[&cid(1, "rgb", "b")], + m[&cid(1, "dimmer", "dimmer")], + ); + let on = r == 255 && g == 255 && b == 255 && d == 255; + let off = r == 0 && g == 0 && b == 0 && d == 0; + assert!(on || off, "gabba strobe must be hard on/off, got rgb=({r},{g},{b}) dim={d}"); + if on { on_count += 1; } + if off { off_count += 1; } + if let Some(prev) = prev_state { + if prev == on { alternated = false; } + } + prev_state = Some(on); + } + assert!(on_count >= 20, "expected ~half frames bright, got {on_count}"); + assert!(off_count >= 20, "expected ~half frames dark, got {off_count}"); + assert!(alternated, "every frame should flip state at 12.5 Hz on a 25 fps engine"); + } + + #[test] + fn beat_pulse_drives_rgb_cb100_and_tsl250_on_beat() { + // One of each fixture family the preset must wake up: RGB PAR, + // CB-100 colour-wheel, TSL-250 scanner. + let fixtures = vec![ + mk(0, "Showtec LED Par 56", 300.0, 200.0, + &[("rgb", &["r","g","b"]), ("dimmer", &["dim"])]), + Fixture { + fixture_id: "cb".to_string(), + model: "CB100 Led Color".to_string(), + pos_x: 100.0, pos_y: 360.0, + elements: vec![ + Element { name: "dimmer".to_string(), channels: vec![ + crate::Channel { name: "dim".to_string(), channel_id: "cb/dimmer/dim".to_string() }]}, + Element { name: "colorwheel".to_string(), channels: vec![ + crate::Channel { name: "whe".to_string(), channel_id: "cb/colorwheel/whe".to_string() }]}, + Element { name: "shutter".to_string(), channels: vec![ + crate::Channel { name: "shu".to_string(), channel_id: "cb/shutter/shu".to_string() }]}, + ], + }, + Fixture { + fixture_id: "tsl".to_string(), + model: "LED TSL-250 Scan COB".to_string(), + pos_x: 500.0, pos_y: 200.0, + elements: vec![ + Element { name: "dimmer".to_string(), channels: vec![ + crate::Channel { name: "dim".to_string(), channel_id: "tsl/dimmer/dim".to_string() }]}, + Element { name: "colorwheel".to_string(), channels: vec![ + crate::Channel { name: "whe".to_string(), channel_id: "tsl/colorwheel/whe".to_string() }]}, + Element { name: "shutter".to_string(), channels: vec![ + crate::Channel { name: "shu".to_string(), channel_id: "tsl/shutter/shu".to_string() }]}, + ], + }, + ]; + + // Fresh beat just landed: time_since_beat = 0 → env=1 → full bright. + let beat = BeatInput { time_since_beat: 0.0, beat_index: 1, bpm: 120.0, energy: 0.4 }; + let u = render_frame_with(Preset::BeatPulse, 0.0, &fixtures, None, Some(beat)); + let m = updates_by_channel(&u); + + // RGB PAR: dimmer near full, RGB carrying real colour. + assert!(m[&cid(0, "dimmer", "dim")] > 220, "PAR dimmer near full on beat"); + let (r, g, b) = (m[&cid(0,"rgb","r")] as i32, m[&cid(0,"rgb","g")] as i32, m[&cid(0,"rgb","b")] as i32); + let max_ch = r.max(g).max(b); + assert!(max_ch > 200, "PAR RGB should have saturated channel (max={max_ch})"); + + // CB-100: shutter open, dimmer up, wheel = one of palette macros. + assert!(m["cb/dimmer/dim"] > 220, "CB-100 dimmer near full"); + assert!(m["cb/shutter/shu"] >= 200, "CB-100 shutter open"); + assert!(CB100_BEAT_PALETTE.contains(&m["cb/colorwheel/whe"]), + "CB-100 wheel must land on palette entry, got {}", m["cb/colorwheel/whe"]); + + // TSL-250: same — wheel must be a TSL palette entry, dimmer up. + assert!(m["tsl/dimmer/dim"] > 220, "TSL-250 dimmer near full on beat"); + assert!(m["tsl/shutter/shu"] >= 200, "TSL-250 shutter open"); + assert!(TSL250_BEAT_PALETTE.contains(&m["tsl/colorwheel/whe"]), + "TSL-250 wheel must land on palette entry, got {}", m["tsl/colorwheel/whe"]); + } + + #[test] + fn beat_pulse_decays_between_beats() { + let f = vec![mk(0, "Showtec LED Par 56", 300.0, 200.0, + &[("rgb", &["r","g","b"]), ("dimmer", &["dim"])])]; + + let fresh = BeatInput { time_since_beat: 0.0, beat_index: 1, bpm: 120.0, energy: 0.4 }; + let stale = BeatInput { time_since_beat: 0.5, beat_index: 1, bpm: 120.0, energy: 0.05 }; + + let u0 = render_frame_with(Preset::BeatPulse, 0.0, &f, None, Some(fresh)); + let u1 = render_frame_with(Preset::BeatPulse, 0.0, &f, None, Some(stale)); + let m0 = updates_by_channel(&u0); + let m1 = updates_by_channel(&u1); + assert!(m0[&cid(0, "dimmer", "dim")] > m1[&cid(0, "dimmer", "dim")] + 30, + "dimmer must fall off between beats: fresh={} stale={}", + m0[&cid(0, "dimmer", "dim")], m1[&cid(0, "dimmer", "dim")]); + // Floor still keeps it visible. + assert!(m1[&cid(0, "dimmer", "dim")] > 0, "floor keeps the room lit between beats"); + } + + #[test] + fn beat_pulse_advances_hue_per_beat() { + let f = vec![mk(0, "Showtec LED Par 56", 300.0, 200.0, + &[("rgb", &["r","g","b"]), ("dimmer", &["dim"])])]; + let b1 = BeatInput { time_since_beat: 0.0, beat_index: 1, bpm: 120.0, energy: 0.4 }; + let b2 = BeatInput { time_since_beat: 0.0, beat_index: 2, bpm: 120.0, energy: 0.4 }; + let u1 = render_frame_with(Preset::BeatPulse, 0.0, &f, None, Some(b1)); + let u2 = render_frame_with(Preset::BeatPulse, 0.0, &f, None, Some(b2)); + let m1 = updates_by_channel(&u1); + let m2 = updates_by_channel(&u2); + let c1 = (m1[&cid(0,"rgb","r")], m1[&cid(0,"rgb","g")], m1[&cid(0,"rgb","b")]); + let c2 = (m2[&cid(0,"rgb","r")], m2[&cid(0,"rgb","g")], m2[&cid(0,"rgb","b")]); + assert_ne!(c1, c2, "successive beats must pick different colours"); + } + + #[test] + fn beat_pulse_no_mic_falls_back_to_dim_cyan_pulse() { + let f = fixtures_sample(); + // Sample over a few seconds — should always be lit (no zeros) and + // never go fully bright like the on-beat state. + let mut min_dim = 255u8; + let mut max_dim = 0u8; + for i in 0..40 { + let t = i as f32 * 0.2; + let u = render_frame_with(Preset::BeatPulse, t, &f, None, None); + let m = updates_by_channel(&u); + let d = m[&cid(1, "dimmer", "dimmer")]; + min_dim = min_dim.min(d); + max_dim = max_dim.max(d); + } + assert!(min_dim > 0, "no-mic fallback must never blackout"); + assert!(max_dim < 200, "no-mic fallback should not max out (it's a 'waiting' state)"); + } + + #[test] + fn colour_strobes_flash_their_colour_and_skip_cold_white() { + // For RedStrobe/BlueStrobe/PinkStrobe, the ON frame must drive the + // requested RGB and NOT touch the cold-white channel (which would + // bleach the colour). + let f = vec![mk(0, "Stairville Octagon Theater 20x6W CW/WW/A", 100.0, 200.0, + &[("rgb", &["r","g","b"]), ("amber", &["amber"]), + ("warmwhite", &["ww"]), ("white", &["cw"]), + ("dimmer", &["dimmer"])])]; + for (preset, ec) in [ + (Preset::RedStrobe, (255u8, 0u8, 0u8)), + (Preset::BlueStrobe, (0, 60, 255)), + (Preset::PinkStrobe, (255, 60, 180)), + ] { + // t=0 → ON frame. + let u = render_frame(preset, 0.0, &f); + let m = updates_by_channel(&u); + assert_eq!(m[&cid(0,"rgb","r")], ec.0, "{preset:?} ON r"); + assert_eq!(m[&cid(0,"rgb","g")], ec.1, "{preset:?} ON g"); + assert_eq!(m[&cid(0,"rgb","b")], ec.2, "{preset:?} ON b"); + assert_eq!(m[&cid(0,"dimmer","dimmer")], 255, "{preset:?} dimmer up"); + assert_eq!(m[&cid(0,"white","cw")], 0, + "{preset:?} must not touch cold-white (would bleach colour)"); + assert_eq!(m[&cid(0,"warmwhite","ww")], 0, "{preset:?} no warm-white"); + assert_eq!(m[&cid(0,"amber","amber")], 0, "{preset:?} no amber"); + + // t=0.04 → OFF frame. + let u_off = render_frame(preset, 0.04, &f); + let mo = updates_by_channel(&u_off); + assert_eq!(mo[&cid(0,"dimmer","dimmer")], 0, + "{preset:?} OFF frame must blackout"); + assert_eq!(mo[&cid(0,"rgb","r")], 0); + assert_eq!(mo[&cid(0,"rgb","g")], 0); + assert_eq!(mo[&cid(0,"rgb","b")], 0); + } + } + + #[test] + fn gabba_strobe_uses_cold_white_not_warm() { + // The warmwhite channel must stay dark — gabba reads as clinical + // cold white, not yellow. + let f = vec![mk(0, "Stairville Octagon Theater 20x6W CW/WW/A", 100.0, 200.0, + &[("rgb", &["r","g","b"]), ("amber", &["amber"]), + ("warmwhite", &["ww"]), ("white", &["cw"]), + ("dimmer", &["dimmer"])])]; + // Pick an ON frame. + let u = render_frame(Preset::GabbaStrobe, 0.0, &f); + let m = updates_by_channel(&u); + assert_eq!(m[&cid(0, "rgb", "r")], 255, "ON frame should drive RGB white"); + assert_eq!(m[&cid(0, "white", "cw")], 255, "cold-white channel must fire"); + assert_eq!(m[&cid(0, "warmwhite", "ww")], 0, "warm-white must stay off"); + assert_eq!(m[&cid(0, "amber", "amber")], 0, "amber must stay off"); + } + + #[test] + fn matelight_sync_no_frame_pulses_dim_white() { + // Without a matelight frame, the preset must not go black — it + // should fall back to a slow dim pulse so the hall stays readable. + let f = fixtures_sample(); + let mut max_dim = 0u8; + let mut min_dim = 255u8; + for i in 0..40 { + let t = i as f32 * 0.2; + let u = render_frame(Preset::Matelight, t, &f); + let m = updates_by_channel(&u); + let d = m[&cid(1, "dimmer", "dimmer")]; + max_dim = max_dim.max(d); + min_dim = min_dim.min(d); + } + assert!(min_dim > 0, "matelight without frame must never go fully black"); + assert!(max_dim > 30, "matelight without frame should pulse visibly"); + } + + #[test] + fn matelight_sync_mirrors_left_right_colours() { + // Build a synthetic matelight frame: left half red, right half blue. + // Two fixtures at opposite ends of the hall — they should mirror + // those colours respectively. + let w = 40; + let h = 16; + let mut pixels = Vec::with_capacity(w * h * 3); + for _ in 0..h { + for x in 0..w { + if x < w / 2 { + pixels.extend_from_slice(&[255, 0, 0]); + } else { + pixels.extend_from_slice(&[0, 0, 255]); + } + } + } + let mate = MatelightFrame { width: w, height: h, brightness: 255, pixels }; + + let fixtures = vec![ + mk(0, "Cameo Root PAR 6", 0.0, 200.0, + &[("rgb", &["r","g","b"]), ("dimmer", &["dimmer"])]), + mk(1, "Cameo Root PAR 6", 900.0, 200.0, + &[("rgb", &["r","g","b"]), ("dimmer", &["dimmer"])]), + ]; + let u = render_frame_with(Preset::Matelight, 0.0, &fixtures, Some(&mate), None); + let m = updates_by_channel(&u); + + let (lr, lg, lb) = (m[&cid(0,"rgb","r")] as i32, m[&cid(0,"rgb","g")] as i32, m[&cid(0,"rgb","b")] as i32); + let (rr, rg, rb) = (m[&cid(1,"rgb","r")] as i32, m[&cid(1,"rgb","g")] as i32, m[&cid(1,"rgb","b")] as i32); + + assert!(lr > 150 && lb < 30, + "left fixture should mirror red half of matelight, got rgb=({lr},{lg},{lb})"); + assert!(rb > 150 && rr < 30, + "right fixture should mirror blue half of matelight, got rgb=({rr},{rg},{rb})"); + } + + #[test] + fn matelight_sync_scales_with_matelight_brightness() { + // Same solid-green frame at brightness 255 vs 51 — dimmer at low + // brightness must be clearly lower. + let w = 8; + let h = 4; + let pixels: Vec = (0..w*h).flat_map(|_| [0u8, 200, 0]).collect(); + + let fixtures = vec![mk(0, "Cameo Root PAR 6", 100.0, 200.0, + &[("rgb", &["r","g","b"]), ("dimmer", &["dimmer"])])]; + + let bright = MatelightFrame { width: w, height: h, brightness: 255, pixels: pixels.clone() }; + let dim = MatelightFrame { width: w, height: h, brightness: 51, pixels }; + + let u_bright = render_frame_with(Preset::Matelight, 0.0, &fixtures, Some(&bright), None); + let u_dim = render_frame_with(Preset::Matelight, 0.0, &fixtures, Some(&dim), None); + + let mb = updates_by_channel(&u_bright); + let md = updates_by_channel(&u_dim); + let d_bright = mb[&cid(0, "dimmer", "dimmer")]; + let d_dim = md[&cid(0, "dimmer", "dimmer")]; + + assert!(d_bright > 200, "bright matelight should drive dimmer high (got {d_bright})"); + assert!(d_dim < d_bright, + "low matelight brightness should dim hall (bright={d_bright}, dim={d_dim})"); + // And it should still be a green-leaning colour. + let g = mb[&cid(0, "rgb", "g")]; + let r = mb[&cid(0, "rgb", "r")]; + assert!(g > r, "matelight green frame → hall should lean green (r={r}, g={g})"); + } + + #[test] + fn shutter_auto_functions_held_at_zero() { + let f = vec![mk(0, "FakePAR", 0.0, 0.0, + &[("rgb", &["r","g","b"]), + ("shutter", &["str"]), + ("auto", &["auto"]), + ("functions", &["fn"])])]; + let u = render_frame(Preset::Rainbows, 1.0, &f); + let m = updates_by_channel(&u); + assert_eq!(m[&cid(0,"shutter","str")], 0); + assert_eq!(m[&cid(0,"auto","auto")], 0); + assert_eq!(m[&cid(0,"functions","fn")], 0); + } +}