5 Commits

Author SHA1 Message Date
822d9d8e01 feat(audio): move beat routing server-side and extend presets
Route beat-triggered manual selects from the controller server, add preset background and beat-counter UI support, and bump led-driver to include the matching pattern/runtime fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-09 20:08:05 +12:00
1db905eaae feat(patterns): add supports_manual metadata in db/pattern.json
Allow staging db/pattern.json by replacing blanket db/ ignore with a whitelist for tracked db files.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-09 17:06:56 +12:00
3d6ef5c7b4 chore(git): stop tracking runtime db state files
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 20:35:50 +12:00
78a4ce009c feat(ui): refresh preset data flow and bump driver pointer
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 20:28:56 +12:00
7ccab6fbc4 feat(zones): persist per-zone brightness and update submodules
Store zone brightness in model/data flow, apply it in the zones UI, and record updated led-driver, led-simulator, and led-tool submodule pointers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 22:49:06 +12:00
32 changed files with 2778 additions and 135 deletions

View File

@@ -7,6 +7,8 @@ alwaysApply: true
1. When creating a new pattern under `led-driver/src/patterns/`, also add/update a corresponding test file in `led-driver/tests/patterns/`. 1. When creating a new pattern under `led-driver/src/patterns/`, also add/update a corresponding test file in `led-driver/tests/patterns/`.
2. When adding a new pattern, ensure led-controller has `db/pattern.json`; if it does not exist, create it. Add the new pattern metadata and parameter mappings there. 2. When adding a new pattern, ensure led-controller has `db/pattern.json`; if it does not exist, create it. Add the new pattern metadata and parameter mappings there. Optionally set **`supports_manual`** to `false` when the pattern is a poor fit for manual mode or audio beat triggers (smooth/blended animations); omit or `true` otherwise.
3. When adding a new pattern, add at least one test preset entry in `db/preset.json` in led-controller that uses the new pattern. 3. When adding a new pattern, add at least one test preset entry in `db/preset.json` in led-controller that uses the new pattern.
4. For any pattern that supports both auto and manual modes, keep behaviour parity unless explicitly requested otherwise: background colour handling, colour-cycling order, and parameter timing semantics (e.g. `n2`/`n3` meaning) must match between auto and manual paths.

View File

@@ -0,0 +1,18 @@
---
description: Keep led-driver and led-tool git submodules in sync when updating led-controller
alwaysApply: true
---
# Submodule pointers (`led-driver`, `led-tool`)
This repo tracks **`led-driver`** and **`led-tool`** as git submodules (see `.gitmodules`).
When you **update led-controller** work that should ship with matching firmware or CLI behaviour—or when you finish changes **inside** those submodule directories—**record the new submodule commits in the parent repo**:
1. In each submodule, commit and push on its remote if there are local commits (or ensure the checkout is the intended revision).
2. From the **led-controller** root: `git add led-driver led-tool` after their HEADs point at the right commits.
3. Include the parent-repo commit that bumps the gitlinks (so CI and clones get consistent trees).
**Do not** leave submodule directories dirty or forgotten while presenting the parent repo as “done”: either commit the submodule pointer update in led-controller, or leave an explicit note if the user must push submodule remotes first.
If the user only asked for a submodule bump with no code edits, a single `chore(submodules): bump led-driver and led-tool` style commit is appropriate (see commit rule).

11
.gitignore vendored
View File

@@ -28,6 +28,17 @@ Thumbs.db
scripts/.led-controller-venv scripts/.led-controller-venv
docs/.help-print.html docs/.help-print.html
settings.json settings.json
# Track shared JSON + preset binaries; ignore other db/*.json (e.g. device, zone) locally
db/*
!db/group.json
!db/palette.json
!db/pattern.json
!db/preset.json
!db/profile.json
!db/scene.json
!db/sequence.json
!db/presets/
!db/presets/*.bin
*.log *.log
*.db *.db
*.sqlite *.sqlite

13
Pipfile
View File

@@ -14,6 +14,8 @@ selenium = "*"
adafruit-ampy = "*" adafruit-ampy = "*"
microdot = "*" microdot = "*"
websockets = "*" websockets = "*"
numpy = "*"
sounddevice = "*"
[dev-packages] [dev-packages]
pytest = "*" pytest = "*"
@@ -22,9 +24,10 @@ pytest = "*"
python_version = "3.11" python_version = "3.11"
[scripts] [scripts]
web = "python /home/pi/led-controller/tests/web.py" web = "python tests/web.py"
watch = "python -m watchfiles 'python tests/web.py' src tests" watch = "python -m watchfiles \"python tests/web.py\" src tests"
install = "pipenv install"
run = "sh -c 'cd src && python main.py'" run = "sh -c 'cd src && python main.py'"
dev = "watchfiles \"sh -c 'cd src && python main.py'\" src" dev = "python -m watchfiles \"sh -c 'cd src && python main.py'\" src"
help-pdf = "sh scripts/build_help_pdf.sh" test = "python -m pytest"
test-browser = "sh -c 'python tests/web.py > /tmp/led-controller-web.log 2>&1 & pid=$!; trap \"kill $pid\" EXIT; sleep 2; LED_CONTROLLER_RUN_BROWSER_TESTS=1 LED_CONTROLLER_DEVICE_IP=http://127.0.0.1:5000 python -m pytest tests/test_browser.py'"
test-browser-device = "sh -c 'LED_CONTROLLER_RUN_BROWSER_TESTS=1 python -m pytest tests/test_browser.py'"

224
Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "98da2012e549e7b62ed49a5e1717acaf535b71e8df61bf4108d25b9023be612e" "sha256": "7975a888034cb3e0f68c7b866f7e69e5a1db7a5228fe1f02d6cb5f9c180de557"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@@ -40,6 +40,13 @@
"markers": "python_version >= '3.9'", "markers": "python_version >= '3.9'",
"version": "==26.1.0" "version": "==26.1.0"
}, },
"aubio": {
"hashes": [
"sha256:df1244f6c4cf5bea382c8c2d35aa43bc31f4cf631fe325ae3992c219546a4202"
],
"index": "pypi",
"version": "==0.4.9"
},
"bitarray": { "bitarray": {
"hashes": [ "hashes": [
"sha256:0256d57e294414bfe4fec4f852fd1d9ae361228c71b082332bf81c8b8fc69f80", "sha256:0256d57e294414bfe4fec4f852fd1d9ae361228c71b082332bf81c8b8fc69f80",
@@ -252,7 +259,7 @@
"sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453",
"sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf" "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"
], ],
"markers": "python_full_version >= '3.9' and platform_python_implementation != 'PyPy'", "markers": "python_version >= '3.9'",
"version": "==2.0.0" "version": "==2.0.0"
}, },
"charset-normalizer": { "charset-normalizer": {
@@ -400,64 +407,65 @@
}, },
"cryptography": { "cryptography": {
"hashes": [ "hashes": [
"sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13",
"sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6",
"sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8",
"sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25",
"sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c",
"sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832",
"sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12",
"sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c",
"sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe", "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7",
"sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c",
"sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475", "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec",
"sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5",
"sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355",
"sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c",
"sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741",
"sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86",
"sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321",
"sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a",
"sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7",
"sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920",
"sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e",
"sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff",
"sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd",
"sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3",
"sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f",
"sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7", "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602",
"sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855",
"sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18",
"sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a",
"sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336",
"sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239",
"sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74",
"sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a",
"sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c",
"sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab", "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4",
"sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c",
"sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f",
"sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4",
"sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db",
"sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166",
"sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8", "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5",
"sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f",
"sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae",
"sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20",
"sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a",
"sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057",
"sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb",
"sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50", "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c",
"sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736" "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b"
], ],
"markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'", "markers": "python_version >= '3.9' and python_full_version not in '3.9.0, 3.9.1'",
"version": "==47.0.0" "version": "==48.0.0"
}, },
"esptool": { "esptool": {
"hashes": [ "hashes": [
"sha256:9c355b7d6331cc92979cc710ae5c41f59830d1ea29ec24c467c6005a092c06d6" "sha256:9c355b7d6331cc92979cc710ae5c41f59830d1ea29ec24c467c6005a092c06d6"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==5.2.0" "version": "==5.2.0"
}, },
"h11": { "h11": {
@@ -485,11 +493,11 @@
}, },
"markdown-it-py": { "markdown-it-py": {
"hashes": [ "hashes": [
"sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49",
"sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3" "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a"
], ],
"markers": "python_version >= '3.10'", "markers": "python_version >= '3.10'",
"version": "==4.0.0" "version": "==4.2.0"
}, },
"mdurl": { "mdurl": {
"hashes": [ "hashes": [
@@ -505,6 +513,7 @@
"sha256:d56824f4510e628dac711c86121957894ceaf833298e4d25f06c29d2b19a1721" "sha256:d56824f4510e628dac711c86121957894ceaf833298e4d25f06c29d2b19a1721"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'",
"version": "==2.6.1" "version": "==2.6.1"
}, },
"mpremote": { "mpremote": {
@@ -513,8 +522,88 @@
"sha256:fdb5626be83dff4e53c0184f8950814cb519b524dba7f1f8b1668aa477257a31" "sha256:fdb5626be83dff4e53c0184f8950814cb519b524dba7f1f8b1668aa477257a31"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.4'",
"version": "==1.28.0" "version": "==1.28.0"
}, },
"numpy": {
"hashes": [
"sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed",
"sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50",
"sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959",
"sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827",
"sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd",
"sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233",
"sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc",
"sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b",
"sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7",
"sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e",
"sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a",
"sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d",
"sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3",
"sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e",
"sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb",
"sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a",
"sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0",
"sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e",
"sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113",
"sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103",
"sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93",
"sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af",
"sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5",
"sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7",
"sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392",
"sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c",
"sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4",
"sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40",
"sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf",
"sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44",
"sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b",
"sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5",
"sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e",
"sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74",
"sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0",
"sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e",
"sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec",
"sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015",
"sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d",
"sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d",
"sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842",
"sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150",
"sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8",
"sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a",
"sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed",
"sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f",
"sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008",
"sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e",
"sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0",
"sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e",
"sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f",
"sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a",
"sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40",
"sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7",
"sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83",
"sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d",
"sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c",
"sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871",
"sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502",
"sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252",
"sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8",
"sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115",
"sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f",
"sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e",
"sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d",
"sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0",
"sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119",
"sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e",
"sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db",
"sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121",
"sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d",
"sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e"
],
"index": "pypi",
"markers": "python_version >= '3.11'",
"version": "==2.4.4"
},
"outcome": { "outcome": {
"hashes": [ "hashes": [
"sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8",
@@ -553,6 +642,7 @@
"sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b" "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==2.12.1" "version": "==2.12.1"
}, },
"pyserial": { "pyserial": {
@@ -671,6 +761,7 @@
"sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a" "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==2.33.1" "version": "==2.33.1"
}, },
"rich": { "rich": {
@@ -695,6 +786,7 @@
"sha256:bada5c08a989f812728a4b5bea884d8e91894e939a441cc3a025201ce718581e" "sha256:bada5c08a989f812728a4b5bea884d8e91894e939a441cc3a025201ce718581e"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==4.43.0" "version": "==4.43.0"
}, },
"sniffio": { "sniffio": {
@@ -712,6 +804,19 @@
], ],
"version": "==2.4.0" "version": "==2.4.0"
}, },
"sounddevice": {
"hashes": [
"sha256:05eb9fd6c54c38d67741441c19164c0dae8ce80453af2d8c4ad2e7823d15b722",
"sha256:1234cc9b4c9df97b6cbe748146ae0ec64dd7d6e44739e8e42eaa5b595313a103",
"sha256:22487b65198cb5bf2208755105b524f78ad173e5ab6b445bdab1c989f6698df3",
"sha256:30ff99f6c107f49d25ad16a45cacd8d91c25a1bcdd3e81a206b921a3a6405b1f",
"sha256:3861901ddd8230d2e0e8ae62ac320cdd4c688d81df89da036dcb812f757bb3e6",
"sha256:cfc6b2c49fb7f555591c78cb8ecf48d6a637fd5b6e1db5fec6ed9365d64b3519"
],
"index": "pypi",
"markers": "python_version >= '3.7'",
"version": "==0.5.5"
},
"tibs": { "tibs": {
"hashes": [ "hashes": [
"sha256:01ea5258bdf942d21560dc07d532082cd04f07cfef65fedd58ae84f7d0d2562a", "sha256:01ea5258bdf942d21560dc07d532082cd04f07cfef65fedd58ae84f7d0d2562a",
@@ -774,7 +879,9 @@
"version": "==4.15.0" "version": "==4.15.0"
}, },
"urllib3": { "urllib3": {
"extras": [], "extras": [
"socks"
],
"hashes": [ "hashes": [
"sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed",
"sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4" "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"
@@ -895,6 +1002,7 @@
"sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf" "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==1.1.1" "version": "==1.1.1"
}, },
"websocket-client": { "websocket-client": {
@@ -970,6 +1078,7 @@
"sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4" "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==16.0" "version": "==16.0"
}, },
"wsproto": { "wsproto": {
@@ -1020,6 +1129,7 @@
"sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c" "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==9.0.3" "version": "==9.0.3"
} }
} }

View File

@@ -1 +0,0 @@
{"188b0e1560a8": {"id": "188b0e1560a8", "name": "led-188b0e1560a8", "type": "led", "transport": "wifi", "address": "10.1.1.192", "default_pattern": null, "zones": []}, "f0f5bdfb9d30": {"id": "f0f5bdfb9d30", "name": "led-f0f5bdfb9d30", "type": "led", "transport": "wifi", "address": "10.1.1.232", "default_pattern": null, "zones": []}}

View File

@@ -1 +1,291 @@
{"on": {"min_delay": 10, "max_delay": 10000, "max_colors": 1}, "off": {"min_delay": 10, "max_delay": 10000, "max_colors": 0}, "rainbow": {"n1": "Step Rate", "min_delay": 10, "max_delay": 10000, "max_colors": 0}, "colour_cycle": {"n1": "Step Rate", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "transition": {"min_delay": 10, "max_delay": 10000, "max_colors": 10}, "chase": {"n1": "Colour 1 Length", "n2": "Colour 2 Length", "n3": "Step 1", "n4": "Step 2", "min_delay": 10, "max_delay": 10000, "max_colors": 2}, "pulse": {"n1": "Attack", "n2": "Hold", "n3": "Decay", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "circle": {"n1": "Head Rate", "n2": "Max Length", "n3": "Tail Rate", "n4": "Min Length", "min_delay": 10, "max_delay": 10000, "max_colors": 2}, "blink": {"min_delay": 10, "max_delay": 10000, "max_colors": 10}, "flicker": {"n1": "Min brightness", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "flame": {"n1": "Min brightness", "n2": "Breath period (ms)", "n3": "Spark gap min (ms, 0=default 10\u201330 s, -1=off)", "n4": "Spark gap max (ms)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "twinkle": {"n1": "Twinkle activity (1\u2013255, higher = more changes)", "n2": "Density (0\u2013255, higher = more of the strip lit)", "n3": "Min adjacent LEDs per twinkle (same as max for fixed length)", "n4": "Max adjacent LEDs per twinkle (same as min for fixed length)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "radiate": {"n1": "Node spacing (LEDs)", "n2": "Out time (ms)", "n3": "In time (ms)", "min_delay": 10, "max_delay": 10000, "max_colors": 2}, "meteor_rain": {"n1": "Tail length", "n2": "Speed (LEDs per frame)", "n3": "Fade amount (1-255)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "scanner": {"n1": "Eye width", "n2": "End pause (frames)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "gradient_scroll": {"n1": "Scroll step rate", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "comet_dual": {"n1": "Tail length", "n2": "Speed", "n3": "Gap", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "sparkle_trail": {"n1": "Spark density", "n2": "Decay", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "wave": {"n1": "Wavelength", "n2": "Amplitude", "n3": "Drift speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "plasma": {"n1": "Scale", "n2": "Speed", "n3": "Contrast", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "segment_chase": {"n1": "Segment size", "n2": "Phase step", "n3": "Segment phase offset", "n4": "Gap per segment", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "bar_graph": {"n1": "Level percent", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "breathing_dual": {"n1": "Phase offset", "n2": "Ease", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "strobe_burst": {"n1": "Burst count", "n2": "Burst gap", "n3": "Cooldown", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "rain_drops": {"n1": "Drop rate", "n2": "Ripple width", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "fireflies": {"n1": "Count", "n2": "Twinkle speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "clock_sweep": {"n1": "Hand width", "n2": "Marker interval", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "marquee": {"n1": "On length", "n2": "Off length", "n3": "Step", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "aurora": {"n1": "Band count", "n2": "Shimmer", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "snowfall": {"n1": "Flake density", "n2": "Fall speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "heartbeat": {"n1": "Pulse 1 ms", "n2": "Pulse 2 ms", "n3": "Pause ms", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "orbit": {"n1": "Orbit count", "n2": "Base speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "palette_morph": {"n1": "Morph ms", "n2": "Warp rate", "n3": "Turbulence", "max_colors": 10, "min_delay": 10, "max_delay": 10000}} {
"on": {
"min_delay": 10,
"max_delay": 10000,
"max_colors": 1,
"supports_manual": true
},
"off": {
"min_delay": 10,
"max_delay": 10000,
"max_colors": 0,
"supports_manual": true
},
"rainbow": {
"n1": "Step Rate",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 0,
"supports_manual": true
},
"colour_cycle": {
"n1": "Step Rate",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"supports_manual": true
},
"transition": {
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"supports_manual": false
},
"chase": {
"n1": "Colour 1 Length",
"n2": "Colour 2 Length",
"n3": "Step 1",
"n4": "Step 2",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 2,
"has_background": true,
"supports_manual": true
},
"pulse": {
"n1": "Attack",
"n2": "Hold",
"n3": "Decay",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
},
"circle": {
"n1": "Head Rate",
"n2": "Max Length",
"n3": "Tail Rate",
"n4": "Min Length",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 2,
"has_background": true,
"supports_manual": false
},
"blink": {
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": false
},
"flicker": {
"n1": "Min brightness",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"supports_manual": true
},
"flame": {
"n1": "Min brightness",
"n2": "Breath period (ms)",
"n3": "Spark gap min (ms, 0=default 1030 s, -1=off)",
"n4": "Spark gap max (ms)",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"supports_manual": false
},
"twinkle": {
"n1": "Twinkle activity (1255, higher = more changes)",
"n2": "Density (0255, higher = more of the strip lit)",
"n3": "Min adjacent LEDs per twinkle (same as max for fixed length)",
"n4": "Max adjacent LEDs per twinkle (same as min for fixed length)",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": false
},
"radiate": {
"n1": "Node spacing (LEDs)",
"n2": "Out time (ms)",
"n3": "In time (ms)",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 2,
"has_background": true,
"supports_manual": true
},
"meteor_rain": {
"n1": "Tail length",
"n2": "Speed (LEDs per frame)",
"n3": "Fade amount (1-255)",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"supports_manual": true
},
"scanner": {
"n1": "Eye width",
"n2": "End pause (frames)",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
},
"gradient_scroll": {
"n1": "Scroll step rate",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"supports_manual": true
},
"comet_dual": {
"n1": "Tail length",
"n2": "Speed",
"n3": "Gap",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"sparkle_trail": {
"n1": "Spark density",
"n2": "Decay",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"supports_manual": true
},
"wave": {
"n1": "Wavelength",
"n2": "Amplitude",
"n3": "Drift speed",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"supports_manual": false
},
"plasma": {
"n1": "Scale",
"n2": "Speed",
"n3": "Contrast",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"supports_manual": false
},
"segment_chase": {
"n1": "Segment size",
"n2": "Phase step",
"n3": "Segment phase offset",
"n4": "Gap per segment",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"bar_graph": {
"n1": "Level percent",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": false
},
"breathing_dual": {
"n1": "Phase offset",
"n2": "Ease",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"supports_manual": false
},
"strobe_burst": {
"n1": "Burst count",
"n2": "Burst gap",
"n3": "Cooldown",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"rain_drops": {
"n1": "Drop rate",
"n2": "Ripple width",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"fireflies": {
"n1": "Count",
"n2": "Twinkle speed",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"clock_sweep": {
"n1": "Hand width",
"n2": "Marker interval",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"marquee": {
"n1": "On length",
"n2": "Off length",
"n3": "Step",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"aurora": {
"n1": "Band count",
"n2": "Shimmer",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"supports_manual": false
},
"snowfall": {
"n1": "Flake density",
"n2": "Fall speed",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"heartbeat": {
"n1": "Pulse 1 ms",
"n2": "Pulse 2 ms",
"n3": "Pause ms",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"orbit": {
"n1": "Orbit count",
"n2": "Base speed",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"palette_morph": {
"n1": "Morph ms",
"n2": "Warp rate",
"n3": "Turbulence",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"supports_manual": false
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
{"1": {"name": "default", "names": ["led-188b0e1560a8", "led-f0f5bdfb9d30"], "presets": [["4", "2", "7"], ["3", "14", "5"], ["8", "9", "1"], ["6", "38", "42"], ["39", "40", "41"], ["43", "44", "45"], ["46", "47", "48"], ["49", "50", "51"], ["52", "53", "54"], ["55", "56", "57"], ["58", "59", "60"], ["61", "62"]], "presets_flat": ["4", "2", "7", "3", "14", "5", "8", "9", "1", "6", "38", "42", "39", "40", "41", "43", "44", "45", "46", "47", "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62"], "default_preset": "41"}, "2": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["16", "17", "18"], ["19", "20", "21"], ["22", "23", "24"], ["25", "26", "27"], ["28", "29", "30"]], "presets_flat": ["16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30"]}, "3": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "4": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "5": {"name": "dj", "names": ["dj"], "presets": [["31", "32", "33"]], "default_preset": "31", "presets_flat": ["31", "32", "33"]}, "6": {"name": "default", "names": ["1"], "presets": [], "default_preset": null, "presets_flat": []}, "7": {"name": "dj", "names": ["dj"], "presets": [["34", "35", "36"]], "default_preset": "34", "presets_flat": ["34", "35", "36"]}, "8": {"name": "test", "names": ["led-188b0e1560a8"], "presets": [["1", "2", "3"], ["4", "5"]], "default_preset": "1", "presets_flat": ["1", "2", "3", "4", "5"]}}

253
scripts/pi-eth-lan-router.sh Executable file
View File

@@ -0,0 +1,253 @@
#!/usr/bin/env bash
# Configure Raspberry Pi OS: Wi-Fi client on IF_WAN (default wlan0), Ethernet IF_LAN
# (default eth0) toward an external AP. Static LAN IP, DHCP via dnsmasq, NAT masquerade.
#
# Usage:
# sudo ./pi-eth-lan-router.sh install
# sudo ./pi-eth-lan-router.sh remove
#
# Environment overrides (optional):
# IF_WAN=wlan0 IF_LAN=eth0 LAN_IP=192.168.4.1 LAN_PREFIX=24 \
# DHCP_START=192.168.4.100 DHCP_END=192.168.4.200 \
# DNSMASQ_DNS=1.1.1.1,8.8.8.8 \
# sudo ./pi-eth-lan-router.sh install
set -euo pipefail
IF_WAN="${IF_WAN:-wlan0}"
IF_LAN="${IF_LAN:-eth0}"
LAN_IP="${LAN_IP:-192.168.4.1}"
LAN_PREFIX="${LAN_PREFIX:-24}"
DHCP_START="${DHCP_START:-192.168.4.100}"
DHCP_END="${DHCP_END:-192.168.4.200}"
# Comma-separated DNS for DHCP clients (Pi does not need to run a resolver).
DNSMASQ_DNS="${DNSMASQ_DNS:-1.1.1.1,8.8.8.8}"
NM_CON_NAME="pi-eth-lan-router"
MARK_BEGIN="# BEGIN pi-eth-lan-router (scripts/pi-eth-lan-router.sh)"
MARK_END="# END pi-eth-lan-router"
SYSCTL_FILE="/etc/sysctl.d/99-pi-eth-lan-router.conf"
DNSMASQ_SNIPPET="/etc/dnsmasq.d/pi-eth-lan-router.conf"
NFT_SNIPPET="/etc/nftables.d/50-pi-eth-lan-router.nft"
NFT_INCLUDE='include "/etc/nftables.d/50-pi-eth-lan-router.nft"'
NFTABLES_CONF="/etc/nftables.conf"
DHCPCD_CONF="/etc/dhcpcd.conf"
die() { echo "error: $*" >&2; exit 1; }
log() { echo "$*"; }
need_root() {
[[ "${EUID:-0}" -eq 0 ]] || die "run as root (sudo)"
}
have_cmd() { command -v "$1" >/dev/null 2>&1; }
apt_install() {
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq dnsmasq nftables
}
write_sysctl() {
cat >"$SYSCTL_FILE" <<EOF
# Managed by scripts/pi-eth-lan-router.sh
net.ipv4.ip_forward=1
EOF
sysctl --system -q 2>/dev/null || sysctl -p "$SYSCTL_FILE" || true
}
remove_sysctl() {
rm -f "$SYSCTL_FILE"
sysctl --system -q 2>/dev/null || true
}
write_dnsmasq() {
local mask="255.255.255.0"
if [[ "$LAN_PREFIX" != "24" ]]; then
die "only LAN_PREFIX=24 is supported by this script (extend dnsmasq netmask manually)"
fi
cat >"$DNSMASQ_SNIPPET" <<EOF
# Managed by scripts/pi-eth-lan-router.sh
interface=$IF_LAN
bind-interfaces
dhcp-range=$DHCP_START,$DHCP_END,$mask,24h
dhcp-option=option:router,$LAN_IP
dhcp-option=option:dns-server,$DNSMASQ_DNS
EOF
}
remove_dnsmasq() {
rm -f "$DNSMASQ_SNIPPET"
}
write_nft() {
mkdir -p /etc/nftables.d
cat >"$NFT_SNIPPET" <<EOF
# Managed by scripts/pi-eth-lan-router.sh
table ip pi_eth_wlan_nat {
chain postrouting {
type nat hook postrouting priority 100; policy accept;
oifname "$IF_WAN" masquerade
}
}
EOF
if [[ -f "$NFTABLES_CONF" ]] && ! grep -qF '50-pi-eth-lan-router.nft' "$NFTABLES_CONF" 2>/dev/null; then
printf '\n# pi-eth-lan-router\n%s\n' "$NFT_INCLUDE" >>"$NFTABLES_CONF"
elif [[ ! -f "$NFTABLES_CONF" ]]; then
log "warning: $NFTABLES_CONF missing; NAT was not added for boot persistence. Install/configure nftables, or add: $NFT_INCLUDE"
fi
}
remove_nft() {
rm -f "$NFT_SNIPPET"
if [[ -f "$NFTABLES_CONF" ]]; then
sed -i '/# pi-eth-lan-router/d;/50-pi-eth-lan-router\.nft/d' "$NFTABLES_CONF" || true
fi
nft delete table ip pi_eth_wlan_nat 2>/dev/null || true
}
apply_nft() {
if have_cmd nft; then
nft delete table ip pi_eth_wlan_nat 2>/dev/null || true
nft -f "$NFT_SNIPPET"
fi
}
configure_nm_eth() {
have_cmd nmcli || return 1
systemctl is-active --quiet NetworkManager 2>/dev/null || return 1
if nmcli -t -f NAME con show --active 2>/dev/null | grep -qxF "$NM_CON_NAME"; then
nmcli con down "$NM_CON_NAME" || true
fi
if nmcli -t -f NAME con show 2>/dev/null | grep -qxF "$NM_CON_NAME"; then
nmcli con mod "$NM_CON_NAME" \
connection.interface-name "$IF_LAN" \
ipv4.method manual \
ipv4.addresses "${LAN_IP}/${LAN_PREFIX}" \
ipv4.gateway "" \
ipv4.dns "" \
ipv4.never-default yes \
ipv6.method ignore
else
nmcli con add type ethernet con-name "$NM_CON_NAME" ifname "$IF_LAN" \
ipv4.method manual \
ipv4.addresses "${LAN_IP}/${LAN_PREFIX}" \
ipv4.gateway "" \
ipv4.dns "" \
ipv4.never-default yes \
ipv6.method ignore
fi
if ! nmcli con up "$NM_CON_NAME"; then
log "warning: could not activate '$NM_CON_NAME' (is $IF_LAN connected?); profile saved for next boot."
fi
return 0
}
remove_nm_eth() {
have_cmd nmcli || return 0
if nmcli -t -f NAME con show 2>/dev/null | grep -qxF "$NM_CON_NAME"; then
nmcli con delete "$NM_CON_NAME" || true
fi
}
configure_dhcpcd_eth() {
[[ -f "$DHCPCD_CONF" ]] || return 1
if grep -qF "$MARK_BEGIN" "$DHCPCD_CONF" 2>/dev/null; then
sed -i "/$MARK_BEGIN/,/$MARK_END/d" "$DHCPCD_CONF" || true
fi
{
echo "$MARK_BEGIN"
echo "interface $IF_LAN"
echo "static ip_address=${LAN_IP}/${LAN_PREFIX}"
echo "nohook wpa_supplicant"
echo "$MARK_END"
} >>"$DHCPCD_CONF"
systemctl restart dhcpcd 2>/dev/null || true
return 0
}
remove_dhcpcd_block() {
[[ -f "$DHCPCD_CONF" ]] || return 0
if grep -qF "$MARK_BEGIN" "$DHCPCD_CONF" 2>/dev/null; then
sed -i "/$MARK_BEGIN/,/$MARK_END/d" "$DHCPCD_CONF" || true
systemctl restart dhcpcd 2>/dev/null || true
fi
}
configure_eth_static() {
if configure_nm_eth; then
log "configured $IF_LAN via NetworkManager profile '$NM_CON_NAME'"
return 0
fi
if configure_dhcpcd_eth; then
log "configured $IF_LAN via dhcpcd ($DHCPCD_CONF)"
return 0
fi
die "neither NetworkManager (active) nor $DHCPCD_CONF found; set $IF_LAN to ${LAN_IP}/${LAN_PREFIX} manually"
}
remove_eth_static() {
remove_nm_eth
remove_dhcpcd_block
}
do_install() {
need_root
log "installing packages (dnsmasq, nftables)…"
apt_install
log "writing sysctl, dnsmasq, nftables snippets…"
write_sysctl
write_dnsmasq
write_nft
log "setting static IP on $IF_LAN"
configure_eth_static
log "restarting dnsmasq…"
systemctl enable dnsmasq
systemctl restart dnsmasq
log "loading NAT rules and enabling nftables…"
apply_nft
systemctl enable nftables 2>/dev/null || true
systemctl restart nftables 2>/dev/null || true
log "done. Connect $IF_LAN to the external AP (DHCP off on the AP)."
log "Join Wi-Fi on $IF_WAN to the uplink network and complete any captive portal on the Pi."
}
do_remove() {
need_root
remove_eth_static
remove_dnsmasq
systemctl restart dnsmasq 2>/dev/null || true
remove_nft
systemctl restart nftables 2>/dev/null || true
remove_sysctl
sysctl -w net.ipv4.ip_forward=0 2>/dev/null || true
log "removed pi-eth-lan-router configuration snippets and NM profile '$NM_CON_NAME' (if present)."
}
usage() {
cat <<EOF
Usage: sudo $0 install|remove
WAN (Wi-Fi client): $IF_WAN
LAN (Ethernet to AP): $IF_LAN
LAN address: ${LAN_IP}/${LAN_PREFIX}
DHCP range: $DHCP_START $DHCP_END
Override with environment variables (see script header).
EOF
}
case "${1:-}" in
install) do_install ;;
remove) do_remove ;;
*) usage; exit 1 ;;
esac

View File

@@ -367,6 +367,8 @@ async def create_driver_pattern(request):
Body JSON: Body JSON:
name, code (required), name, code (required),
min_delay, max_delay, max_colors (optional numbers), min_delay, max_delay, max_colors (optional numbers),
has_background (optional bool),
supports_manual (optional bool, default true if omitted in db),
n1..n8 (optional string labels), n1..n8 (optional string labels),
overwrite (optional, default true). overwrite (optional, default true).
""" """
@@ -409,6 +411,12 @@ async def create_driver_pattern(request):
"Content-Type": "application/json" "Content-Type": "application/json"
} }
if "has_background" in data:
meta["has_background"] = bool(data.get("has_background"))
if "supports_manual" in data:
meta["supports_manual"] = bool(data.get("supports_manual"))
for i in range(1, 9): for i in range(1, 9):
nk = "n%d" % i nk = "n%d" % i
if nk not in data: if nk not in data:

View File

@@ -315,6 +315,13 @@ async def push_driver_messages(request, session):
except Exception: except Exception:
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'} return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
try:
from util.beat_driver_route import sync_beat_route_from_push_sequence
sync_beat_route_from_push_sequence(seq)
except Exception:
pass
return json.dumps({ return json.dumps({
"message": "Delivered", "message": "Delivered",
"deliveries": deliveries, "deliveries": deliveries,

View File

@@ -31,6 +31,7 @@ from util.device_status_broadcaster import (
register_device_status_ws, register_device_status_ws,
unregister_device_status_ws, unregister_device_status_ws,
) )
from util.audio_detector import AudioBeatDetector
_tcp_device_lock = threading.Lock() _tcp_device_lock = threading.Lock()
@@ -246,6 +247,10 @@ async def main(port=80):
set_sender(sender) set_sender(sender)
app = Microdot() app = Microdot()
audio_detector = AudioBeatDetector()
from util import beat_driver_route
beat_driver_route.set_beat_route_main_loop(asyncio.get_running_loop())
# Initialize sessions with a secret key from settings # Initialize sessions with a secret key from settings
secret_key = settings.get('session_secret_key', 'led-controller-secret-key-change-in-production') secret_key = settings.get('session_secret_key', 'led-controller-secret-key-change-in-production')
@@ -290,6 +295,45 @@ async def main(port=80):
def favicon(request): def favicon(request):
return '', 204 return '', 204
@app.route('/api/audio/devices')
async def audio_devices(request):
_ = request
try:
return {
"devices": audio_detector.list_input_devices(),
"diagnostics": audio_detector.diagnostics(),
}
except Exception as e:
return {"error": str(e)}, 500
@app.route('/api/audio/start', methods=['POST'])
async def audio_start(request):
payload = request.json if isinstance(request.json, dict) else {}
device = payload.get("device", None)
if device in ("", None):
device = None
else:
try:
device = int(device)
except (TypeError, ValueError):
pass
try:
audio_detector.start(device=device)
return {"ok": True, "status": audio_detector.status()}
except Exception as e:
return {"ok": False, "error": str(e)}, 500
@app.route('/api/audio/stop', methods=['POST'])
async def audio_stop(request):
_ = request
audio_detector.stop()
return {"ok": True, "status": audio_detector.status()}
@app.route('/api/audio/status')
async def audio_status(request):
_ = request
return {"status": audio_detector.status()}
# Static file route # Static file route
@app.route("/static/<path:path>") @app.route("/static/<path:path>")
def static_handler(request, path): def static_handler(request, path):
@@ -348,6 +392,10 @@ async def main(port=80):
def _graceful_shutdown(*_args): def _graceful_shutdown(*_args):
print("[server] shutting down...") print("[server] shutting down...")
udp_holder["closing"] = True udp_holder["closing"] = True
try:
audio_detector.stop()
except Exception:
pass
u = udp_holder.get("sock") u = udp_holder.get("sock")
if u is not None: if u is not None:
try: try:
@@ -383,6 +431,10 @@ async def main(port=80):
) )
raise raise
finally: finally:
try:
audio_detector.stop()
except Exception:
pass
srv = getattr(app, "server", None) srv = getattr(app, "server", None)
if srv is not None: if srv is not None:
try: try:

View File

@@ -26,6 +26,7 @@ class Preset(Model):
"name": "", "name": "",
"pattern": "", "pattern": "",
"colors": [], "colors": [],
"background": "#000000",
"brightness": 0, "brightness": 0,
"delay": 0, "delay": 0,
"n1": 0, "n1": 0,
@@ -36,6 +37,7 @@ class Preset(Model):
"n6": 0, "n6": 0,
"n7": 0, "n7": 0,
"n8": 0, "n8": 0,
"manual_beat_n": 1,
"profile_id": str(profile_id) if profile_id is not None else None, "profile_id": str(profile_id) if profile_id is not None else None,
} }
self.save() self.save()

View File

@@ -124,15 +124,6 @@ def _register_ws(ip: str, ws) -> None:
_send_locks[key] = asyncio.Lock() _send_locks[key] = asyncio.Lock()
_schedule_status_broadcast(key, True) _schedule_status_broadcast(key, True)
print(f"[WS] driver connected {key!r}") print(f"[WS] driver connected {key!r}")
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
async def _apply_saved_brightness():
await sync_global_brightness_to_driver(key)
loop.create_task(_apply_saved_brightness())
def unregister_tcp_writer(peer_ip: str, ws=None) -> str: def unregister_tcp_writer(peer_ip: str, ws=None) -> str:

View File

@@ -34,6 +34,7 @@ class Zone(Model):
"names": names if names else [], "names": names if names else [],
"presets": presets if presets else [], "presets": presets if presets else [],
"default_preset": None, "default_preset": None,
"brightness": 255,
} }
self.save() self.save()
return next_id return next_id

218
src/static/audio.js Normal file
View File

@@ -0,0 +1,218 @@
(() => {
let pollTimer = null;
let lastBeatSeq = 0;
function el(id) {
return document.getElementById(id);
}
function updateBpmDisplay(bpm) {
const node = el("audio-bpm-value");
if (!node) return;
node.textContent = Number.isFinite(bpm) ? bpm.toFixed(1) : "--";
const topNode = el("audio-top-bpm-value");
if (topNode) {
topNode.textContent = Number.isFinite(bpm) ? bpm.toFixed(1) : "--";
}
}
function updateBeatCounter(seq) {
const topNode = el("audio-top-beat-count");
if (!topNode) return;
const n = Number(seq);
topNode.textContent = Number.isFinite(n) && n >= 0 ? `#${Math.floor(n)}` : "#0";
}
function updateHitTypeDisplay(hitType, confidence) {
const node = el("audio-hit-type-value");
if (!node) return;
const label = String(hitType || "unknown").toLowerCase();
const conf = Number.isFinite(confidence) ? ` (${confidence.toFixed(2)})` : "";
node.textContent = `${label}${conf}`;
}
function flashBeat() {
const node = el("audio-beat-flash");
if (!node) return;
node.classList.add("active");
setTimeout(() => node.classList.remove("active"), 80);
const top = el("audio-top-indicator");
if (top) {
top.classList.add("flash");
setTimeout(() => top.classList.remove("flash"), 90);
}
}
async function stopAudio() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
lastBeatSeq = 0;
updateBeatCounter(0);
try {
await fetch("/api/audio/stop", { method: "POST" });
} catch (e) {
console.warn("audio stop failed", e);
}
}
async function pollStatus() {
try {
const res = await fetch("/api/audio/status");
const data = await res.json();
const status = data?.status || {};
if (status.error && String(status.error).trim()) {
const node = el("audio-hit-type-value");
if (node) {
node.textContent = String(status.error).trim().slice(0, 120);
}
updateBpmDisplay(null);
if (!status.running && pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
return;
}
updateBpmDisplay(status.bpm);
updateHitTypeDisplay(status.beat_type, Number(status.beat_type_confidence));
const seq = Number(status.beat_seq || 0);
updateBeatCounter(seq);
if (seq > lastBeatSeq) {
lastBeatSeq = seq;
flashBeat();
}
} catch (e) {
console.warn("audio status poll failed", e);
}
}
async function startAudio() {
await stopAudio();
const override = (el("audio-device-override")?.value || "").trim();
const selected = el("audio-device-select")?.value || "";
const rawDevice = override !== "" ? override : selected;
const numeric = rawDevice !== "" && /^-?\d+$/.test(rawDevice) ? Number(rawDevice) : rawDevice;
const body = { device: rawDevice === "" ? null : numeric };
const res = await fetch("/api/audio/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "Failed to start audio detector");
}
updateBpmDisplay(null);
updateHitTypeDisplay("unknown", NaN);
updateBeatCounter(0);
pollTimer = setInterval(pollStatus, 250);
await pollStatus();
}
async function refreshDevices() {
const select = el("audio-device-select");
const debug = el("audio-devices-debug");
if (!select) return;
const current = select.value;
const res = await fetch("/api/audio/devices");
const data = await res.json();
const inputs = Array.isArray(data?.devices) ? data.devices.slice() : [];
if (debug) {
debug.value = JSON.stringify(data?.diagnostics || data, null, 2);
}
inputs.sort((a, b) => {
const am = String(a?.name || "").toLowerCase().includes("monitor");
const bm = String(b?.name || "").toLowerCase().includes("monitor");
if (am !== bm) return am ? -1 : 1;
return Number(a?.id || 0) - Number(b?.id || 0);
});
select.innerHTML = '<option value="">System default input</option>';
let defaultId = "";
inputs.forEach((d, idx) => {
const option = document.createElement("option");
option.value = String(d.id);
option.textContent = d.label || d.name || `Input ${idx + 1}`;
if (d.is_default) {
defaultId = String(d.id);
}
select.appendChild(option);
});
if (current) {
select.value = current;
} else if (defaultId) {
select.value = defaultId;
}
}
function bind() {
const modal = el("audio-modal");
const openBtn = el("audio-btn");
const closeBtn = el("audio-close-btn");
const startBtn = el("audio-start-btn");
const stopBtn = el("audio-stop-btn");
const refreshBtn = el("audio-refresh-btn");
if (!modal || !openBtn) return;
openBtn.addEventListener("click", async () => {
modal.classList.add("active");
try {
await refreshDevices();
} catch (e) {
console.warn("audio device refresh failed", e);
}
});
if (closeBtn) {
closeBtn.addEventListener("click", () => {
modal.classList.remove("active");
});
}
if (startBtn) {
startBtn.addEventListener("click", async () => {
try {
await startAudio();
await refreshDevices();
} catch (e) {
console.error("audio start failed", e);
alert("Failed to start audio input. Check mic permissions.");
}
});
}
if (stopBtn) {
stopBtn.addEventListener("click", async () => {
await stopAudio();
});
}
if (refreshBtn) {
refreshBtn.addEventListener("click", async () => {
try {
await refreshDevices();
} catch (e) {
console.error("refresh devices failed", e);
}
});
}
}
async function resumePollingIfDetectorRunning() {
try {
const res = await fetch("/api/audio/status");
const data = await res.json();
const status = data?.status || {};
if (status.running && !pollTimer) {
pollTimer = setInterval(pollStatus, 250);
lastBeatSeq = Number(status.beat_seq || 0);
updateBeatCounter(lastBeatSeq);
await pollStatus();
}
} catch (e) {
console.warn("audio resume poll check failed", e);
}
}
document.addEventListener("DOMContentLoaded", () => {
bind();
resumePollingIfDetectorRunning();
});
})();

View File

@@ -33,6 +33,33 @@ document.addEventListener('DOMContentLoaded', () => {
return Number.isFinite(t) ? t : def; return Number.isFinite(t) ? t : def;
}; };
const coercePresetAuto = (preset) => {
if (!preset || typeof preset !== 'object') {
return true;
}
const v =
preset.auto !== undefined && preset.auto !== null ? preset.auto : preset.a;
if (typeof v === 'boolean') {
return v;
}
if (v === 0 || v === '0') {
return false;
}
if (v === 1 || v === '1') {
return true;
}
if (typeof v === 'string') {
const l = v.trim().toLowerCase();
if (['false', '0', 'no', 'off'].includes(l)) {
return false;
}
if (['true', '1', 'yes', 'on'].includes(l)) {
return true;
}
}
return true;
};
const getCurrentProfileId = async () => { const getCurrentProfileId = async () => {
try { try {
const response = await fetch('/profiles/current', { headers: { Accept: 'application/json' } }); const response = await fetch('/profiles/current', { headers: { Accept: 'application/json' } });
@@ -531,6 +558,7 @@ document.addEventListener('DOMContentLoaded', () => {
const colors = Array.isArray(preset.colors) && preset.colors.length const colors = Array.isArray(preset.colors) && preset.colors.length
? preset.colors ? preset.colors
: ['#FFFFFF']; : ['#FFFFFF'];
const presetAuto = coercePresetAuto(preset);
wirePresets[presetId] = { wirePresets[presetId] = {
pattern: preset.pattern || 'off', pattern: preset.pattern || 'off',
colors, colors,
@@ -538,7 +566,8 @@ document.addEventListener('DOMContentLoaded', () => {
brightness: typeof preset.brightness === 'number' brightness: typeof preset.brightness === 'number'
? preset.brightness ? preset.brightness
: (typeof preset.br === 'number' ? preset.br : 127), : (typeof preset.br === 'number' ? preset.br : 127),
auto: typeof preset.auto === 'boolean' ? preset.auto : true, auto: presetAuto,
a: presetAuto,
n1: coercePresetInt(preset.n1), n1: coercePresetInt(preset.n1),
n2: coercePresetInt(preset.n2), n2: coercePresetInt(preset.n2),
n3: coercePresetInt(preset.n3), n3: coercePresetInt(preset.n3),

View File

@@ -190,6 +190,14 @@ document.addEventListener('DOMContentLoaded', () => {
const presetNewColorInput = document.getElementById('preset-new-color'); const presetNewColorInput = document.getElementById('preset-new-color');
const presetBrightnessInput = document.getElementById('preset-brightness-input'); const presetBrightnessInput = document.getElementById('preset-brightness-input');
const presetDelayInput = document.getElementById('preset-delay-input'); const presetDelayInput = document.getElementById('preset-delay-input');
const presetDelayField = presetDelayInput ? presetDelayInput.closest('.preset-editor-field') : null;
const presetBackgroundInput = document.getElementById('preset-background-input');
const presetBackgroundButton = document.getElementById('preset-background-btn');
const presetManualModeInput = document.getElementById('preset-manual-mode-input');
const presetManualModeHint = document.getElementById('preset-manual-mode-hint');
const presetManualModeLabel = document.getElementById('preset-manual-mode-label');
const presetManualBeatNWrap = document.getElementById('preset-manual-beat-n-wrap');
const presetManualBeatNInput = document.getElementById('preset-manual-beat-n-input');
const presetDefaultButton = document.getElementById('preset-default-btn'); const presetDefaultButton = document.getElementById('preset-default-btn');
const presetRemoveFromTabButton = document.getElementById('preset-remove-from-zone-btn'); const presetRemoveFromTabButton = document.getElementById('preset-remove-from-zone-btn');
const presetSaveButton = document.getElementById('preset-save-btn'); const presetSaveButton = document.getElementById('preset-save-btn');
@@ -219,6 +227,100 @@ document.addEventListener('DOMContentLoaded', () => {
return Infinity; // No limit if not specified return Infinity; // No limit if not specified
}; };
const resolvePatternConfig = (patternName) => {
const rawPatternName = String(patternName || '').trim();
const normalizedPatternName = rawPatternName.endsWith('.py')
? rawPatternName.slice(0, -3)
: rawPatternName;
let patternConfig =
(cachedPatterns && cachedPatterns[rawPatternName]) ||
(cachedPatterns && cachedPatterns[normalizedPatternName]) ||
null;
if (!patternConfig && cachedPatterns && typeof cachedPatterns === 'object') {
const lower = normalizedPatternName.toLowerCase();
const matchedKey = Object.keys(cachedPatterns).find(
(k) => String(k).toLowerCase() === lower,
);
if (matchedKey) {
patternConfig = cachedPatterns[matchedKey];
}
}
if (patternConfig && typeof patternConfig === 'object' && patternConfig.data && typeof patternConfig.data === 'object') {
patternConfig = patternConfig.data;
}
if (
patternConfig &&
typeof patternConfig === 'object' &&
patternConfig.parameter_mappings &&
typeof patternConfig.parameter_mappings === 'object'
) {
patternConfig = patternConfig.parameter_mappings;
}
return patternConfig && typeof patternConfig === 'object' ? patternConfig : null;
};
/** From db/pattern.json; missing key means pattern allows manual / beat (backward compatible). */
const patternSupportsManual = (patternName) => {
const cfg = resolvePatternConfig(patternName);
if (!cfg) {
return true;
}
return cfg.supports_manual !== false;
};
const updateManualBeatNVisibility = () => {
if (!presetManualBeatNWrap) {
return;
}
const manualOn = presetManualModeInput && presetManualModeInput.checked;
const patternName = presetPatternInput ? presetPatternInput.value.trim() : '';
const ok = !patternName || patternSupportsManual(patternName);
presetManualBeatNWrap.style.display = manualOn && ok ? '' : 'none';
};
const updatePresetBackgroundButton = () => {
if (!presetBackgroundButton || !presetBackgroundInput) return;
const color = coercePresetBackground({ background: presetBackgroundInput.value });
presetBackgroundInput.value = color;
presetBackgroundButton.textContent = color;
presetBackgroundButton.style.backgroundColor = color;
presetBackgroundButton.style.color = '#fff';
presetBackgroundButton.style.borderColor = 'rgba(255, 255, 255, 0.6)';
};
const updateDelayVisibilityForManualMode = () => {
if (!presetDelayField) return;
const manualOn = presetManualModeInput && presetManualModeInput.checked;
presetDelayField.style.display = manualOn ? 'none' : '';
};
const updateManualModeAvailability = () => {
if (!presetManualModeInput) {
return;
}
const patternName = presetPatternInput ? presetPatternInput.value.trim() : '';
const ok = !patternName || patternSupportsManual(patternName);
presetManualModeInput.disabled = !ok;
if (presetManualModeLabel) {
presetManualModeLabel.style.opacity = ok ? '' : '0.55';
}
if (presetManualModeHint) {
if (!patternName || ok) {
presetManualModeHint.style.display = 'none';
presetManualModeHint.textContent = '';
} else {
presetManualModeHint.style.display = '';
presetManualModeHint.textContent =
'This pattern is a poor fit for manual mode or audio beat triggers; use auto mode for best results.';
}
}
if (!ok) {
presetManualModeInput.checked = false;
}
updateManualBeatNVisibility();
updateDelayVisibilityForManualMode();
};
// Function to show/hide color section based on max_colors // Function to show/hide color section based on max_colors
const updateColorSectionVisibility = () => { const updateColorSectionVisibility = () => {
const maxColors = getMaxColors(); const maxColors = getMaxColors();
@@ -296,7 +398,7 @@ document.addEventListener('DOMContentLoaded', () => {
} }
const swatchContainer = document.createElement('div'); const swatchContainer = document.createElement('div');
swatchContainer.style.cssText = 'display: flex; flex-wrap: wrap; gap: 0.5rem;'; swatchContainer.style.cssText = 'display: flex; flex-wrap: nowrap; gap: 0.5rem; align-items: flex-start; overflow-x: auto;';
swatchContainer.classList.add('color-swatches-container'); swatchContainer.classList.add('color-swatches-container');
currentPresetColors.forEach((color, index) => { currentPresetColors.forEach((color, index) => {
@@ -492,11 +594,26 @@ document.addEventListener('DOMContentLoaded', () => {
presetNameInput.value = preset.name || ''; presetNameInput.value = preset.name || '';
const patternName = preset.pattern || ''; const patternName = preset.pattern || '';
presetPatternInput.value = patternName; presetPatternInput.value = patternName;
const colors = Array.isArray(preset.colors) ? preset.colors : []; const colors = Array.isArray(preset.colors) ? preset.colors.slice() : [];
const paletteRefs = Array.isArray(preset.palette_refs) ? preset.palette_refs : []; const paletteRefs = Array.isArray(preset.palette_refs) ? preset.palette_refs.slice() : [];
renderPresetColors(colors, paletteRefs); renderPresetColors(colors, paletteRefs);
presetBrightnessInput.value = preset.brightness || 0; presetBrightnessInput.value = preset.brightness || 0;
presetDelayInput.value = preset.delay || 0; presetDelayInput.value = preset.delay || 0;
if (presetBackgroundInput) {
presetBackgroundInput.value = coercePresetBackground(preset);
}
updatePresetBackgroundButton();
if (presetManualModeInput) {
const autoVal = typeof preset.auto === 'boolean' ? preset.auto : true;
presetManualModeInput.checked = !autoVal;
}
if (presetManualBeatNInput) {
const raw = preset.manual_beat_n;
let n = typeof raw === 'number' ? raw : parseInt(String(raw != null ? raw : '1'), 10);
if (!Number.isFinite(n)) n = 1;
n = Math.max(1, Math.min(64, n));
presetManualBeatNInput.value = String(n);
}
// Update color section visibility based on pattern // Update color section visibility based on pattern
updateColorSectionVisibility(); updateColorSectionVisibility();
@@ -552,6 +669,7 @@ document.addEventListener('DOMContentLoaded', () => {
// After values: show only mapped n params with labels from pattern.json; clear hidden inputs // After values: show only mapped n params with labels from pattern.json; clear hidden inputs
updatePresetNLabels(patternName); updatePresetNLabels(patternName);
updateManualModeAvailability();
updatePresetEditorTabActionsVisibility(); updatePresetEditorTabActionsVisibility();
}; };
@@ -574,7 +692,21 @@ document.addEventListener('DOMContentLoaded', () => {
n6: 0, n6: 0,
n7: 0, n7: 0,
n8: 0, n8: 0,
background: '#000000',
auto: true,
manual_beat_n: 1,
}); });
if (presetManualModeInput) {
presetManualModeInput.checked = false;
}
if (presetManualBeatNInput) {
presetManualBeatNInput.value = '1';
}
if (presetBackgroundInput) {
presetBackgroundInput.value = '#000000';
}
updatePresetBackgroundButton();
updateManualModeAvailability();
// Re-enable name and pattern when clearing (for new preset) // Re-enable name and pattern when clearing (for new preset)
if (presetNameInput) { if (presetNameInput) {
presetNameInput.disabled = false; presetNameInput.disabled = false;
@@ -652,6 +784,14 @@ document.addEventListener('DOMContentLoaded', () => {
// Use canonical field names expected by the device / API // Use canonical field names expected by the device / API
brightness: presetBrightnessInput ? parseInt(presetBrightnessInput.value, 10) || 0 : 0, brightness: presetBrightnessInput ? parseInt(presetBrightnessInput.value, 10) || 0 : 0,
delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0, delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0,
background: presetBackgroundInput ? presetBackgroundInput.value : '#000000',
auto: presetManualModeInput ? !presetManualModeInput.checked : true,
manual_beat_n: (() => {
if (!presetManualBeatNInput) return 1;
let n = parseInt(presetManualBeatNInput.value, 10);
if (!Number.isFinite(n)) n = 1;
return Math.max(1, Math.min(64, n));
})(),
}; };
// Always store numeric parameters as n1..n8. // Always store numeric parameters as n1..n8.
@@ -812,6 +952,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (nGrid) { if (nGrid) {
nGrid.style.display = visibleNKeys.size > 0 ? '' : 'none'; nGrid.style.display = visibleNKeys.size > 0 ? '' : 'none';
} }
updateManualModeAvailability();
}; };
const renderPresets = (presets) => { const renderPresets = (presets) => {
@@ -1185,6 +1326,21 @@ document.addEventListener('DOMContentLoaded', () => {
updateColorSectionVisibility(); updateColorSectionVisibility();
// Re-render colors to show updated max colors limit // Re-render colors to show updated max colors limit
renderPresetColors(currentPresetColors, currentPresetPaletteRefs); renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
updateManualModeAvailability();
});
}
if (presetManualModeInput) {
presetManualModeInput.addEventListener('change', () => {
updateManualBeatNVisibility();
updateDelayVisibilityForManualMode();
});
}
if (presetBackgroundButton && presetBackgroundInput) {
presetBackgroundButton.addEventListener('click', () => {
presetBackgroundInput.click();
});
presetBackgroundInput.addEventListener('input', () => {
updatePresetBackgroundButton();
}); });
} }
// Color picker auto-add handler // Color picker auto-add handler
@@ -1417,6 +1573,65 @@ const coercePresetInt = (v, def = 0) => {
return Number.isFinite(t) ? t : def; return Number.isFinite(t) ? t : def;
}; };
/** Device field ``a`` / API ``auto``; missing → auto-run (matches server build_preset_dict). */
const coercePresetAuto = (preset) => {
if (!preset || typeof preset !== 'object') {
return true;
}
const v =
preset.auto !== undefined && preset.auto !== null ? preset.auto : preset.a;
if (typeof v === 'boolean') {
return v;
}
if (v === 0 || v === '0') {
return false;
}
if (v === 1 || v === '1') {
return true;
}
if (typeof v === 'string') {
const l = v.trim().toLowerCase();
if (['false', '0', 'no', 'off'].includes(l)) {
return false;
}
if (['true', '1', 'yes', 'on'].includes(l)) {
return true;
}
}
return true;
};
/** Preset background colour; accepts #RRGGBB or [r,g,b]. */
const coercePresetBackground = (preset) => {
if (!preset || typeof preset !== 'object') {
return '#000000';
}
const raw = preset.background !== undefined && preset.background !== null ? preset.background : preset.bg;
if (typeof raw === 'string') {
const s = raw.trim();
if (/^#[0-9a-fA-F]{6}$/.test(s)) {
return s.toUpperCase();
}
}
if (Array.isArray(raw) && raw.length === 3) {
const r = coercePresetInt(raw[0], 0);
const g = coercePresetInt(raw[1], 0);
const b = coercePresetInt(raw[2], 0);
const clamp = (n) => Math.max(0, Math.min(255, n));
return `#${clamp(r).toString(16).padStart(2, '0')}${clamp(g).toString(16).padStart(2, '0')}${clamp(b).toString(16).padStart(2, '0')}`.toUpperCase();
}
return '#000000';
};
/** Audio beat stride for manual presets (led-controller only; firmware ignores this key). */
const coerceManualBeatN = (preset) => {
if (!preset || typeof preset !== 'object') return 1;
const raw = preset.manual_beat_n;
let n = typeof raw === 'number' ? raw : parseInt(String(raw != null ? raw : '1'), 10);
if (!Number.isFinite(n)) n = 1;
return Math.max(1, Math.min(64, n));
};
// Build driver messages for a single preset; deliver via /presets/push (ESP-NOW + TCP). // Build driver messages for a single preset; deliver via /presets/push (ESP-NOW + TCP).
// Send order: // Send order:
// 1) preset payload (optionally with save) // 1) preset payload (optionally with save)
@@ -1438,23 +1653,28 @@ const sendPresetViaEspNow = async (
const colors = resolveColorsWithPaletteRefs(baseColors, preset.palette_refs, paletteColors); const colors = resolveColorsWithPaletteRefs(baseColors, preset.palette_refs, paletteColors);
const wirePresetId = devicePresetId != null ? String(devicePresetId) : String(presetId); const wirePresetId = devicePresetId != null ? String(devicePresetId) : String(presetId);
const presetAuto = coercePresetAuto(preset);
const presetBackground = coercePresetBackground(preset);
const presetMessage = { const presetMessage = {
v: '1', v: '1',
presets: { presets: {
[wirePresetId]: { [wirePresetId]: {
pattern: preset.pattern || 'off', pattern: preset.pattern || 'off',
colors, colors,
bg: presetBackground,
delay: typeof preset.delay === 'number' ? preset.delay : 100, delay: typeof preset.delay === 'number' ? preset.delay : 100,
brightness: typeof preset.brightness === 'number' brightness: typeof preset.brightness === 'number'
? preset.brightness ? preset.brightness
: (typeof preset.br === 'number' ? preset.br : 127), : (typeof preset.br === 'number' ? preset.br : 127),
auto: typeof preset.auto === 'boolean' ? preset.auto : true, auto: presetAuto,
a: presetAuto,
n1: coercePresetInt(preset.n1), n1: coercePresetInt(preset.n1),
n2: coercePresetInt(preset.n2), n2: coercePresetInt(preset.n2),
n3: coercePresetInt(preset.n3), n3: coercePresetInt(preset.n3),
n4: coercePresetInt(preset.n4), n4: coercePresetInt(preset.n4),
n5: coercePresetInt(preset.n5), n5: coercePresetInt(preset.n5),
n6: coercePresetInt(preset.n6), n6: coercePresetInt(preset.n6),
manual_beat_n: coerceManualBeatN(preset),
}, },
}, },
}; };
@@ -1520,6 +1740,29 @@ const sendDefaultPreset = async (presetId, deviceNames) => {
} }
}; };
const sendPresetSelectViaEspNow = async (presetId, deviceNames) => {
if (!presetId) {
return;
}
const nameTargets = Array.isArray(deviceNames)
? deviceNames.map((n) => (n || '').trim()).filter((n) => n.length > 0)
: [];
if (!nameTargets.length) {
return;
}
const select = {};
nameTargets.forEach((name) => {
select[name] = [String(presetId)];
});
const macTargets =
nameTargets.length > 0 &&
typeof window.tabsManager !== 'undefined' &&
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
? await window.tabsManager.resolveTabDeviceMacs(nameTargets)
: [];
await postDriverSequence([{ v: '1', select }], macTargets);
};
// Expose for other scripts (zones.js) so they can reuse the shared WebSocket. // Expose for other scripts (zones.js) so they can reuse the shared WebSocket.
try { try {
window.sendPresetViaEspNow = sendPresetViaEspNow; window.sendPresetViaEspNow = sendPresetViaEspNow;
@@ -1534,6 +1777,8 @@ try {
// Store selected preset per zone // Store selected preset per zone
const selectedPresets = {}; const selectedPresets = {};
// Store selected preset payload per zone for beat-trigger reliability.
const selectedPresetPayloads = {};
// Run vs Edit for zone preset strip (in-memory only — each full page load starts in run mode) // Run vs Edit for zone preset strip (in-memory only — each full page load starts in run mode)
let presetUiMode = 'run'; let presetUiMode = 'run';
@@ -1823,6 +2068,7 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
button.className = 'pattern-button preset-tile-main'; button.className = 'pattern-button preset-tile-main';
if (isSelected) { if (isSelected) {
button.classList.add('active'); button.classList.add('active');
selectedPresetPayloads[zoneId] = preset;
} }
const colors = Array.isArray(preset.colors) ? preset.colors.filter((c) => c) : []; const colors = Array.isArray(preset.colors) ? preset.colors.filter((c) => c) : [];
@@ -1846,6 +2092,49 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
presetNameLabel.className = 'pattern-button-label'; presetNameLabel.className = 'pattern-button-label';
button.appendChild(presetNameLabel); button.appendChild(presetNameLabel);
const bgSwatch = document.createElement('span');
const bgColor = coercePresetBackground(preset);
bgSwatch.title = `Background: ${bgColor}`;
bgSwatch.style.cssText = `
position: absolute;
left: 4px;
bottom: 4px;
width: 12px;
height: 12px;
border-radius: 2px;
background: ${bgColor};
border: 1px solid rgba(255, 255, 255, 0.7);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5);
pointer-events: none;
z-index: 2;
`;
button.appendChild(bgSwatch);
const isManualPreset = preset && typeof preset.auto === 'boolean' ? !preset.auto : false;
if (isManualPreset) {
const manualBadge = document.createElement('span');
manualBadge.textContent = '1';
manualBadge.title = 'Manual preset';
manualBadge.style.cssText = `
position: absolute;
right: 4px;
bottom: 4px;
min-width: 16px;
height: 16px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.72);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.5);
font-size: 11px;
font-weight: 700;
line-height: 14px;
text-align: center;
pointer-events: none;
z-index: 2;
`;
button.appendChild(manualBadge);
}
button.addEventListener('click', () => { button.addEventListener('click', () => {
if (isDraggingPreset) return; if (isDraggingPreset) return;
const presetsListEl = document.getElementById('presets-list-zone'); const presetsListEl = document.getElementById('presets-list-zone');
@@ -1854,6 +2143,7 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
} }
button.classList.add('active'); button.classList.add('active');
selectedPresets[zoneId] = presetId; selectedPresets[zoneId] = presetId;
selectedPresetPayloads[zoneId] = preset;
const section = row.closest('.presets-section'); const section = row.closest('.presets-section');
const deviceNames = tabDeviceNamesFromSection(section); const deviceNames = tabDeviceNamesFromSection(section);
sendPresetViaEspNow(presetId, preset, deviceNames, false, false, '2').catch((err) => { sendPresetViaEspNow(presetId, preset, deviceNames, false, false, '2').catch((err) => {

View File

@@ -105,6 +105,17 @@ header h1 {
font-weight: 600; font-weight: 600;
} }
/* BPM + desktop actions + mobile menu share one row; BPM stays visible on mobile. */
.header-end {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: nowrap;
justify-content: flex-end;
margin-left: auto;
min-width: 0;
}
.header-actions { .header-actions {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@@ -115,6 +126,7 @@ header h1 {
.header-menu-mobile { .header-menu-mobile {
display: none; display: none;
position: relative; position: relative;
align-items: center;
} }
.main-menu-dropdown { .main-menu-dropdown {
@@ -183,6 +195,49 @@ header h1 {
width: 8.5rem; width: 8.5rem;
} }
.audio-top-indicator {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.55rem;
border: 1px solid #4a4a4a;
border-radius: 6px;
background-color: #1a1a1a;
min-width: 6.5rem;
}
.audio-top-indicator-label {
font-size: 0.72rem;
color: #bdbdbd;
letter-spacing: 0.05em;
}
.audio-top-indicator-value {
font-size: 1rem;
font-weight: 700;
color: #ffd54f;
min-width: 2.4rem;
text-align: right;
}
.audio-top-indicator-subvalue {
font-size: 0.75rem;
color: #9e9e9e;
min-width: 2.2rem;
text-align: right;
}
.audio-top-indicator.flash {
background-color: #ff5252;
border-color: #ff8a80;
}
.audio-top-indicator.flash .audio-top-indicator-value,
.audio-top-indicator.flash .audio-top-indicator-label,
.audio-top-indicator.flash .audio-top-indicator-subvalue {
color: #fff;
}
/* Header/menu actions that should only appear in Edit mode */ /* Header/menu actions that should only appear in Edit mode */
body.preset-ui-run .edit-mode-only { body.preset-ui-run .edit-mode-only {
display: none !important; display: none !important;
@@ -550,14 +605,14 @@ body.preset-ui-run .edit-mode-only {
padding: 0; padding: 0;
} }
/* Zone preset selecting area: 3 columns, vertical scroll only */ /* Zone preset selecting area: 8 columns on desktop, vertical scroll only */
#presets-list-zone { #presets-list-zone {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(8, minmax(0, 1fr));
grid-auto-rows: 5rem; grid-auto-rows: 5rem;
column-gap: 0.3rem; column-gap: 0.3rem;
row-gap: 0.3rem; row-gap: 0.3rem;
@@ -710,6 +765,46 @@ body.preset-ui-run .edit-mode-only {
display: block; display: block;
} }
.audio-bpm-readout {
font-size: 2rem;
font-weight: 700;
letter-spacing: 0.05em;
color: #ffd54f;
text-align: center;
padding: 0.4rem;
background-color: #1a1a1a;
border: 1px solid #4a4a4a;
border-radius: 6px;
}
.audio-hit-type-readout {
font-size: 1.1rem;
font-weight: 600;
letter-spacing: 0.04em;
color: #81d4fa;
text-transform: lowercase;
text-align: center;
padding: 0.35rem;
background-color: #1a1a1a;
border: 1px solid #4a4a4a;
border-radius: 6px;
}
.audio-beat-flash {
width: 100%;
height: 56px;
border-radius: 6px;
border: 1px solid #4a4a4a;
background: #202020;
box-shadow: inset 0 0 0 0 rgba(255, 82, 82, 0.5);
transition: background-color 80ms linear, box-shadow 120ms linear;
}
.audio-beat-flash.active {
background: #ff5252;
box-shadow: inset 0 0 24px 6px rgba(255, 255, 255, 0.35);
}
.patterns-list { .patterns-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1003,9 +1098,23 @@ body.preset-ui-run .edit-mode-only {
} }
.header-menu-mobile { .header-menu-mobile {
display: block; display: flex;
flex-direction: row;
align-items: center;
gap: 0.35rem;
margin-top: 0; margin-top: 0;
margin-left: auto; margin-left: 0;
}
.header-end {
gap: 0.35rem;
flex-shrink: 0;
}
.header-end .audio-top-indicator {
min-width: 5rem;
padding: 0.2rem 0.45rem;
flex-shrink: 0;
} }
.btn { .btn {
@@ -1261,8 +1370,8 @@ body.preset-ui-run .edit-mode-only {
.color-swatches-container { .color-swatches-container {
min-height: 80px; min-height: 80px;
} }
/* Presets list: 3 columns and vertical scroll (defined above); mobile same */ /* Presets list: 3 columns on phone-sized screens */
@media (max-width: 1000px) { @media (max-width: 600px) {
#presets-list-zone { #presets-list-zone {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 7rem); padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 7rem);

View File

@@ -17,24 +17,26 @@ function applyBrightnessSliders(val) {
if (menuSlider) menuSlider.value = String(v); if (menuSlider) menuSlider.value = String(v);
} }
async function saveGlobalBrightnessToServer(val) { async function saveZoneBrightnessToServer(zoneId, val) {
if (!zoneId) return;
try { try {
const res = await fetch("/settings/settings", { const res = await fetch(`/zones/${zoneId}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json", Accept: "application/json" }, headers: { "Content-Type": "application/json", Accept: "application/json" },
credentials: "same-origin", credentials: "same-origin",
body: JSON.stringify({ global_brightness: val }), body: JSON.stringify({ brightness: val }),
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
console.warn("global_brightness save failed:", err.error || res.status); console.warn("zone brightness save failed:", err.error || res.status);
} }
} catch (e) { } catch (e) {
console.warn("global_brightness save failed:", e); console.warn("zone brightness save failed:", e);
} }
} }
function sendZoneBrightness(value) { function sendZoneBrightness(zoneId, value) {
if (!zoneId) return;
const val = Math.max(0, Math.min(255, parseInt(value, 10) || 0)); const val = Math.max(0, Math.min(255, parseInt(value, 10) || 0));
const headerSlider = document.getElementById('header-brightness-slider'); const headerSlider = document.getElementById('header-brightness-slider');
const menuSlider = document.getElementById('menu-brightness-slider'); const menuSlider = document.getElementById('menu-brightness-slider');
@@ -50,7 +52,7 @@ function sendZoneBrightness(value) {
brightnessSendTimeout = setTimeout(() => { brightnessSendTimeout = setTimeout(() => {
(async () => { (async () => {
try { try {
await saveGlobalBrightnessToServer(val); await saveZoneBrightnessToServer(zoneId, val);
const section = document.querySelector('.presets-section[data-zone-id]'); const section = document.querySelector('.presets-section[data-zone-id]');
const names = typeof window.parseTabDeviceNames === 'function' const names = typeof window.parseTabDeviceNames === 'function'
? window.parseTabDeviceNames(section) ? window.parseTabDeviceNames(section)
@@ -550,11 +552,16 @@ async function loadZoneContent(zoneId) {
`; `;
// Keep header and menu brightness controls in sync. // Keep header and menu brightness controls in sync.
const brightnessSlider = document.getElementById('header-brightness-slider'); const zoneBrightness =
const menuBrightnessSlider = document.getElementById('menu-brightness-slider'); typeof zone.brightness === 'number'
if (menuBrightnessSlider && brightnessSlider) { ? zone.brightness
menuBrightnessSlider.value = brightnessSlider.value; : parseInt(String(zone.brightness ?? ''), 10);
} const normalizedBrightness = Number.isFinite(zoneBrightness)
? Math.max(0, Math.min(255, Math.round(zoneBrightness)))
: 255;
applyBrightnessSliders(normalizedBrightness);
// Apply this zone's saved brightness when switching zones.
sendZoneBrightness(zoneId, normalizedBrightness);
// Trigger presets loading if the function exists // Trigger presets loading if the function exists
if (typeof renderTabPresets === 'function') { if (typeof renderTabPresets === 'function') {
@@ -1025,38 +1032,17 @@ document.addEventListener('DOMContentLoaded', () => {
const menuBrightnessSlider = document.getElementById('menu-brightness-slider'); const menuBrightnessSlider = document.getElementById('menu-brightness-slider');
const headerBrightnessSlider = document.getElementById('header-brightness-slider'); const headerBrightnessSlider = document.getElementById('header-brightness-slider');
(async () => { (async () => {
let fromServer = null;
try {
const res = await fetch('/settings', {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
if (res.ok) {
const data = await res.json();
const g = data.global_brightness;
if (typeof g === 'number' && g >= 0 && g <= 255) {
fromServer = Math.round(g);
} else if (g != null && g !== '') {
const n = parseInt(String(g), 10);
if (!Number.isNaN(n) && n >= 0 && n <= 255) {
fromServer = n;
}
}
}
} catch (_) {}
if (fromServer !== null) {
applyBrightnessSliders(fromServer);
}
if (menuBrightnessSlider) { if (menuBrightnessSlider) {
menuBrightnessSlider.addEventListener('input', (e) => { menuBrightnessSlider.addEventListener('input', (e) => {
sendZoneBrightness(e.target.value); if (!currentZoneId) return;
sendZoneBrightness(currentZoneId, e.target.value);
}); });
} }
if (headerBrightnessSlider) { if (headerBrightnessSlider) {
headerBrightnessSlider.addEventListener('input', (e) => { headerBrightnessSlider.addEventListener('input', (e) => {
sendZoneBrightness(e.target.value); if (!currentZoneId) return;
sendZoneBrightness(currentZoneId, e.target.value);
}); });
sendZoneBrightness(headerBrightnessSlider.value);
} }
})(); })();

View File

@@ -14,7 +14,13 @@
Loading zones... Loading zones...
</div> </div>
</div> </div>
<div class="header-actions"> <div class="header-end">
<div id="audio-top-indicator" class="audio-top-indicator" title="Live audio BPM">
<span class="audio-top-indicator-label">BPM</span>
<span id="audio-top-bpm-value" class="audio-top-indicator-value">--</span>
<span id="audio-top-beat-count" class="audio-top-indicator-subvalue">#0</span>
</div>
<div class="header-actions">
<div class="header-brightness-control"> <div class="header-brightness-control">
<label for="header-brightness-slider">Brightness</label> <label for="header-brightness-slider">Brightness</label>
<input type="range" id="header-brightness-slider" min="0" max="255" value="255"> <input type="range" id="header-brightness-slider" min="0" max="255" value="255">
@@ -27,10 +33,11 @@
<button class="btn btn-secondary edit-mode-only" id="color-palette-btn">Colour Palette</button> <button class="btn btn-secondary edit-mode-only" id="color-palette-btn">Colour Palette</button>
<button class="btn btn-secondary edit-mode-only" id="send-profile-presets-btn">Send Presets</button> <button class="btn btn-secondary edit-mode-only" id="send-profile-presets-btn">Send Presets</button>
<button class="btn btn-secondary edit-mode-only" id="led-tool-btn">LED Tool</button> <button class="btn btn-secondary edit-mode-only" id="led-tool-btn">LED Tool</button>
<button class="btn btn-secondary" id="audio-btn">Audio</button>
<button class="btn btn-secondary" id="help-btn">Help</button> <button class="btn btn-secondary" id="help-btn">Help</button>
<button type="button" class="btn btn-secondary ui-mode-toggle" id="ui-mode-toggle" aria-pressed="false" title="Switch preset strip mode — label is the mode you will switch to">Edit mode</button> <button type="button" class="btn btn-secondary ui-mode-toggle" id="ui-mode-toggle" aria-pressed="false" title="Switch preset strip mode — label is the mode you will switch to">Edit mode</button>
</div> </div>
<div class="header-menu-mobile"> <div class="header-menu-mobile">
<button class="btn btn-secondary" id="main-menu-btn">Menu</button> <button class="btn btn-secondary" id="main-menu-btn">Menu</button>
<div id="main-menu-dropdown" class="main-menu-dropdown"> <div id="main-menu-dropdown" class="main-menu-dropdown">
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button> <button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
@@ -46,8 +53,10 @@
<button type="button" class="edit-mode-only" data-target="color-palette-btn">Colour Palette</button> <button type="button" class="edit-mode-only" data-target="color-palette-btn">Colour Palette</button>
<button type="button" class="edit-mode-only" data-target="send-profile-presets-btn">Send Presets</button> <button type="button" class="edit-mode-only" data-target="send-profile-presets-btn">Send Presets</button>
<button type="button" class="edit-mode-only" data-target="led-tool-btn">LED Tool</button> <button type="button" class="edit-mode-only" data-target="led-tool-btn">LED Tool</button>
<button type="button" data-target="audio-btn">Audio</button>
<button type="button" data-target="help-btn">Help</button> <button type="button" data-target="help-btn">Help</button>
</div> </div>
</div>
</div> </div>
</header> </header>
@@ -202,6 +211,25 @@
<label for="preset-delay-input">Delay (ms)</label> <label for="preset-delay-input">Delay (ms)</label>
<input type="number" id="preset-delay-input" placeholder="Delay" min="0" max="10000" value="0"> <input type="number" id="preset-delay-input" placeholder="Delay" min="0" max="10000" value="0">
</div> </div>
<div class="preset-editor-field">
<label for="preset-background-input">Background</label>
<div class="profiles-actions" style="gap: 0.4rem;">
<button type="button" class="btn btn-secondary btn-small" id="preset-background-btn" title="Choose background colour">#000000</button>
<input type="color" id="preset-background-input" value="#000000" title="Background colour used in patterns with background support" style="position:absolute;opacity:0;pointer-events:none;width:1px;height:1px;">
</div>
</div>
</div>
<div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;">
<label id="preset-manual-mode-label" style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;">
<input type="checkbox" id="preset-manual-mode-input">
Manual mode (single-shot where supported)
</label>
<p id="preset-manual-mode-hint" class="muted-text" style="display: none; margin-top: 0.35rem; font-size: 0.85em;"></p>
<div id="preset-manual-beat-n-wrap" class="preset-editor-field" style="display: none; margin-top: 0.5rem;">
<label for="preset-manual-beat-n-input">Audio beat: every</label>
<input type="number" id="preset-manual-beat-n-input" min="1" max="64" value="1" style="width: 4rem;" title="Controller only; not sent to pattern logic">
<span class="muted-text" style="font-size: 0.85em;">beats (this app only)</span>
</div>
</div> </div>
<div class="n-params-grid"> <div class="n-params-grid">
<div class="n-param-group"> <div class="n-param-group">
@@ -389,6 +417,49 @@
</div> </div>
</div> </div>
<!-- Audio Modal -->
<div id="audio-modal" class="modal">
<div class="modal-content">
<h2>Audio Beat Detection</h2>
<p class="muted-text">Select an input device and start beat detection.</p>
<div class="form-group">
<label for="audio-device-select">Input device</label>
<div class="profiles-actions">
<select id="audio-device-select" style="flex: 1;">
<option value="">Default input</option>
</select>
<button type="button" class="btn btn-secondary" id="audio-refresh-btn">Refresh</button>
</div>
<small>Tip: for Pulse/pipewire playback capture, use a source containing <code>monitor</code>.</small>
</div>
<div class="form-group">
<label for="audio-device-override">Manual device override (optional)</label>
<input type="text" id="audio-device-override" placeholder='e.g. 3 or "alsa_output....monitor"'>
</div>
<div class="form-group">
<label>Current BPM</label>
<div id="audio-bpm-value" class="audio-bpm-readout">--</div>
</div>
<div class="form-group">
<label>Detected hit type</label>
<div id="audio-hit-type-value" class="audio-hit-type-readout">unknown</div>
</div>
<div class="form-group">
<label>Flash on beat</label>
<div id="audio-beat-flash" class="audio-beat-flash" aria-hidden="true"></div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-primary" id="audio-start-btn">Start</button>
<button type="button" class="btn btn-secondary" id="audio-stop-btn">Stop</button>
<button type="button" class="btn btn-secondary" id="audio-close-btn">Close</button>
</div>
<div class="form-group" style="margin-top: 0.75rem;">
<label for="audio-devices-debug">Detected devices (Python)</label>
<textarea id="audio-devices-debug" rows="8" readonly style="width:100%; font-family:monospace; resize:vertical;"></textarea>
</div>
</div>
</div>
<!-- Settings Modal --> <!-- Settings Modal -->
<div id="settings-modal" class="modal"> <div id="settings-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
@@ -543,5 +614,6 @@
<script src="/static/patterns.js"></script> <script src="/static/patterns.js"></script>
<script src="/static/presets.js"></script> <script src="/static/presets.js"></script>
<script src="/static/devices.js"></script> <script src="/static/devices.js"></script>
<script src="/static/audio.js"></script>
</body> </body>
</html> </html>

282
src/util/audio_detector.py Normal file
View File

@@ -0,0 +1,282 @@
import collections
import importlib.util
import os
import queue
import threading
import time
class AudioBeatDetector:
def __init__(self):
self._lock = threading.Lock()
self._thread = None
self._stream = None
self._running = False
self._stop_event = threading.Event()
self._status = {
"running": False,
"bpm": None,
"last_beat_ts": None,
"beat_seq": 0,
"beat_type": "unknown",
"beat_type_confidence": 0.0,
"error": None,
"device": None,
}
def list_input_devices(self):
import sounddevice as sd
devices = sd.query_devices()
hostapis = sd.query_hostapis()
default_input_idx = None
try:
default_input_idx = int(sd.default.device[0])
except Exception:
default_input_idx = None
out = []
for idx, dev in enumerate(devices):
name = str(dev.get("name", f"Input {idx}"))
chans = int(dev.get("max_input_channels", 0))
is_monitor_named = "monitor" in name.lower()
if chans <= 0 and not is_monitor_named:
continue
sr = int(dev.get("default_samplerate", 44100))
hostapi_idx = int(dev.get("hostapi", -1))
hostapi_name = (
str(hostapis[hostapi_idx].get("name", "unknown"))
if 0 <= hostapi_idx < len(hostapis)
else "unknown"
)
is_default = default_input_idx is not None and idx == default_input_idx
ch_label = f"{chans}ch" if chans > 0 else "0ch?"
label = f"[{idx}] {name} ({ch_label} @ {sr}Hz, {hostapi_name})"
if is_default:
label = f"{label} [default]"
if is_monitor_named:
label = f"{label} [monitor]"
out.append(
{
"id": idx,
"name": name,
"label": label,
"max_input_channels": chans,
"default_samplerate": sr,
"is_default": is_default,
"hostapi": hostapi_name,
}
)
return out
def diagnostics(self):
import sounddevice as sd
devices = sd.query_devices()
hostapis = sd.query_hostapis()
default_input = None
try:
default_input = sd.default.device[0]
except Exception:
default_input = None
return {
"default_input": default_input,
"hostapis": hostapis,
"devices": devices,
}
def start(self, device=None):
should_restart = False
with self._lock:
should_restart = self._running
if should_restart:
self.stop()
with self._lock:
self._stop_event.clear()
self._status.update(
{
"running": True,
"bpm": None,
"last_beat_ts": None,
"beat_seq": 0,
"beat_type": "unknown",
"beat_type_confidence": 0.0,
"error": None,
"device": device,
}
)
self._running = True
self._thread = threading.Thread(
target=self._run_loop, args=(device,), daemon=True
)
self._thread.start()
def stop(self):
with self._lock:
self._stop_event.set()
t = self._thread
stream = self._stream
try:
import sounddevice as sd
sd.stop(ignore_errors=True)
except Exception:
pass
if stream is not None:
try:
stream.abort()
except Exception:
pass
try:
stream.stop()
except Exception:
pass
try:
stream.close()
except Exception:
pass
if t and t.is_alive():
t.join(timeout=3.0)
with self._lock:
self._running = False
self._thread = None
self._stream = None
self._status["running"] = False
def status(self):
with self._lock:
return dict(self._status)
def _set_error(self, msg):
print(f"[audio] {msg}")
with self._lock:
self._status["error"] = msg
self._status["running"] = False
self._running = False
def _record_beat(self, bpm, beat_type="unknown", beat_type_confidence=0.0):
now = time.time()
with self._lock:
self._status["last_beat_ts"] = now
self._status["bpm"] = bpm
self._status["beat_type"] = beat_type
self._status["beat_type_confidence"] = float(beat_type_confidence or 0.0)
self._status["beat_seq"] = int(self._status.get("beat_seq", 0)) + 1
try:
from util.beat_driver_route import notify_beat_detected
notify_beat_detected()
except Exception as e:
print(f"[audio] beat driver route: {e}")
def _run_loop(self, device):
try:
import argparse
import numpy as np
import sounddevice as sd
except Exception as e:
self._set_error(f"audio deps unavailable: {e}")
return
try:
root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
beat_detect_path = os.path.join(root_dir, "tests", "beat_detect.py")
spec = importlib.util.spec_from_file_location("beat_detect_runtime", beat_detect_path)
if spec is None or spec.loader is None:
raise RuntimeError("cannot load tests/beat_detect.py")
beat_mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(beat_mod)
if device is None:
try:
device = int(sd.default.device[0])
except Exception:
device = -1
if device is None or device < 0:
raise RuntimeError(
"no default input device; open Audio, pick an input, then Start"
)
dev_info = sd.query_devices(device, "input")
sample_rate = int(dev_info["default_samplerate"])
args = argparse.Namespace(
mode="aubio",
device=device,
sample_rate=sample_rate,
hop_size=256,
win_mult=2,
min_band_hz=45.0,
max_band_hz=180.0,
energy_weight=0.7,
flux_weight=0.3,
threshold_multiplier=1.35,
ema_alpha=0.08,
min_ioi_ms=85.0,
bpm_window=8,
post_url="",
aubio_method="default",
aubio_threshold=0.12,
silence_gate_db=-58.0,
)
runtime = beat_mod.BeatDetectRuntime(args)
runtime.setup(sample_rate=sample_rate)
hop_size = runtime.frame_size
audio_q = queue.Queue(maxsize=64)
def callback(indata, frames, _time_info, status):
_ = frames
if status:
print(f"[audio] status: {status}")
mono = np.asarray(indata[:, 0], dtype=np.float32)
if not audio_q.full():
audio_q.put_nowait(mono)
stream = sd.InputStream(
device=device,
channels=1,
samplerate=sample_rate,
blocksize=hop_size,
callback=callback,
)
with self._lock:
self._stream = stream
stream.start()
try:
while not self._stop_event.is_set():
try:
frame = audio_q.get(timeout=0.1)
except queue.Empty:
continue
if frame.shape[0] != hop_size:
if frame.shape[0] > hop_size:
frame = frame[:hop_size]
else:
frame = np.pad(frame, (0, hop_size - frame.shape[0]))
event = runtime.process_frame(frame, now_s=time.time())
if event is None:
continue
bpm = event.get("bpm")
self._record_beat(
bpm,
beat_type=event.get("beat_type", "unknown"),
beat_type_confidence=event.get("beat_type_confidence", 0.0),
)
finally:
try:
stream.stop()
except Exception:
pass
try:
stream.close()
except Exception:
pass
with self._lock:
if self._stream is stream:
self._stream = None
except Exception as e:
self._set_error(f"detector failed: {e}")
return
finally:
with self._lock:
self._running = False
self._status["running"] = False

View File

@@ -0,0 +1,263 @@
"""Server-side routing of audio beats to LED drivers (no browser required)."""
from __future__ import annotations
import asyncio
import json
import os
import threading
from typing import Any, Dict, List, Optional, Set
_route_lock = threading.Lock()
_beat_route: Dict[str, Any] = {
"enabled": False,
"device_names": [],
"wire_preset_id": "2",
"is_manual": False,
"pattern": "",
"manual_beat_n": 1,
}
_beat_counter: int = 0
_main_loop: Optional[asyncio.AbstractEventLoop] = None
def set_beat_route_main_loop(loop: asyncio.AbstractEventLoop) -> None:
global _main_loop
_main_loop = loop
def update_beat_route(payload: Dict[str, Any]) -> None:
"""Internal: set or clear routing from explicit fields (tests / future APIs)."""
global _beat_route, _beat_counter
if not isinstance(payload, dict):
return
with _route_lock:
if payload.get("enabled") is False:
_beat_route = {**_beat_route, "enabled": False}
_beat_counter = 0
return
names = payload.get("device_names")
if not isinstance(names, list):
names = []
try:
n_raw = int(payload.get("manual_beat_n", 1))
except (TypeError, ValueError):
n_raw = 1
manual_n = max(1, min(64, n_raw))
_beat_route = {
"enabled": bool(payload.get("enabled", False)),
"device_names": [str(n).strip() for n in names if str(n).strip()],
"wire_preset_id": str(payload.get("wire_preset_id") or "2"),
"is_manual": bool(payload.get("is_manual", False)),
"pattern": str(payload.get("pattern") or "").strip(),
"manual_beat_n": manual_n,
}
_beat_counter = 0
def get_beat_route() -> Dict[str, Any]:
with _route_lock:
return dict(_beat_route)
def _coerce_manual_beat_n(body: Any) -> int:
"""Beats between audio-triggered selects (led-controller only); default 1 = every beat."""
if not isinstance(body, dict):
return 1
raw = body.get("manual_beat_n")
if raw is None:
return 1
try:
n = int(raw)
except (TypeError, ValueError):
return 1
return max(1, min(64, n))
def _coerce_auto_from_body(body: Any) -> bool:
"""Match JS ``coercePresetAuto`` / ``build_preset_dict`` (default: auto-run)."""
if not isinstance(body, dict):
return True
raw = body.get("auto", body.get("a", True))
if isinstance(raw, bool):
return raw
if raw is None:
return True
if isinstance(raw, int):
return raw != 0
if isinstance(raw, str):
lowered = raw.strip().lower()
if lowered in ("false", "0", "no", "off"):
return False
if lowered in ("true", "1", "yes", "on"):
return True
return True
def sync_beat_route_from_push_sequence(sequence: List[Any]) -> None:
"""
Update beat routing from a ``/presets/push`` body ``sequence`` (list of v1 dicts).
When the batch includes a ``select`` and preset bodies, and the selected preset is
manual (auto off), enables the route; otherwise disables it.
"""
merged_presets: Dict[str, Any] = {}
last_select: Optional[Dict[str, Any]] = None
for item in sequence:
if isinstance(item, str):
try:
item = json.loads(item)
except (TypeError, ValueError):
continue
if not isinstance(item, dict) or item.get("v") != "1":
continue
pr = item.get("presets")
if isinstance(pr, dict):
merged_presets.update(pr)
sel = item.get("select")
if isinstance(sel, dict) and sel:
last_select = sel
if not last_select:
return
device_names = [str(k).strip() for k in last_select.keys() if str(k).strip()]
if not device_names:
update_beat_route({"enabled": False})
return
wire_ids: Set[str] = set()
for name in device_names:
val = last_select.get(name)
if isinstance(val, list) and val:
wire_ids.add(str(val[0]).strip())
elif val is not None:
wire_ids.add(str(val).strip())
if len(wire_ids) != 1:
update_beat_route({"enabled": False})
return
wire_preset_id = wire_ids.pop()
preset_body = merged_presets.get(wire_preset_id)
if preset_body is None:
for k, v in merged_presets.items():
if str(k).strip() == wire_preset_id:
preset_body = v
break
if not isinstance(preset_body, dict):
update_beat_route({"enabled": False})
return
if _coerce_auto_from_body(preset_body):
update_beat_route({"enabled": False})
return
pattern = str(preset_body.get("pattern") or preset_body.get("p") or "").strip()
if pattern and not _pattern_supports_manual(pattern):
update_beat_route({"enabled": False})
return
update_beat_route(
{
"enabled": True,
"device_names": device_names,
"wire_preset_id": wire_preset_id,
"is_manual": True,
"pattern": pattern,
"manual_beat_n": _coerce_manual_beat_n(preset_body),
}
)
def _pattern_supports_manual(pattern_key: str) -> bool:
if not pattern_key:
return True
try:
here = os.path.dirname(os.path.abspath(__file__))
root = os.path.abspath(os.path.join(here, "..", ".."))
path = os.path.join(root, "db", "pattern.json")
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
meta = data.get(pattern_key)
if meta is None:
meta = data.get(pattern_key.lower())
if not isinstance(meta, dict):
return True
return meta.get("supports_manual") is not False
except OSError:
return True
def _macs_for_registry_names(device_names: List[str]) -> List[str]:
from models.device import Device
want = {str(n).strip() for n in device_names if str(n).strip()}
if not want:
return []
devices = Device()
macs: List[str] = []
seen = set()
for did in devices.list():
doc = devices.read(did) or {}
nm = str(doc.get("name") or "").strip()
if nm not in want:
continue
key = str(did).strip().lower().replace(":", "").replace("-", "")
if len(key) == 12 and key not in seen:
seen.add(key)
macs.append(key)
return macs
async def _deliver_select(device_names: List[str], wire_preset_id: str) -> None:
from models.device import Device
from models.transport import get_current_sender
from util.driver_delivery import deliver_json_messages
sender = get_current_sender()
if not sender:
return
select = {str(n).strip(): [wire_preset_id] for n in device_names if str(n).strip()}
if not select:
return
msg = json.dumps({"v": "1", "select": select}, separators=(",", ":"))
macs = _macs_for_registry_names(list(select.keys()))
if not macs:
return
devices = Device()
try:
await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05)
except Exception as e:
print(f"[beat-route] deliver failed: {e}")
def notify_beat_detected() -> None:
"""Invoked from the audio thread when a beat is detected."""
global _beat_counter
with _route_lock:
r = dict(_beat_route)
if not r.get("enabled"):
return
if not r.get("is_manual"):
return
pattern = r.get("pattern") or ""
if pattern and not _pattern_supports_manual(pattern):
return
names = r.get("device_names") or []
if not names:
return
try:
n = int(r.get("manual_beat_n") or 1)
except (TypeError, ValueError):
n = 1
n = max(1, min(64, n))
_beat_counter += 1
if ((_beat_counter - 1) % n) != 0:
return
preset_id = str(r.get("wire_preset_id") or "2")
names_copy = list(names)
loop = _main_loop
if loop is None:
return
try:
asyncio.run_coroutine_threadsafe(_deliver_select(names_copy, preset_id), loop)
except Exception as e:
print(f"[beat-route] schedule failed: {e}")

View File

@@ -119,13 +119,40 @@ def build_preset_dict(preset_data):
else: else:
colors = ["#FFFFFF"] colors = ["#FFFFFF"]
def _coerce_auto(raw):
if isinstance(raw, bool):
return raw
if raw is None:
return True
if isinstance(raw, int):
return raw != 0
if isinstance(raw, str):
lowered = raw.strip().lower()
if lowered in ("false", "0", "no", "off"):
return False
if lowered in ("true", "1", "yes", "on"):
return True
return True
auto_raw = preset_data.get("auto", preset_data.get("a", True))
auto_bool = _coerce_auto(auto_raw)
bg_raw = preset_data.get("background", preset_data.get("bg", "#000000"))
if isinstance(bg_raw, str):
bg = bg_raw if bg_raw.startswith("#") else f"#{bg_raw}"
elif isinstance(bg_raw, (list, tuple)) and len(bg_raw) == 3:
bg = f"#{int(bg_raw[0]):02x}{int(bg_raw[1]):02x}{int(bg_raw[2]):02x}"
else:
bg = "#000000"
# Build payload using the short keys expected by led-driver # Build payload using the short keys expected by led-driver
preset = { preset = {
"p": preset_data.get("pattern", preset_data.get("p", "off")), "p": preset_data.get("pattern", preset_data.get("p", "off")),
"c": colors, "c": colors,
"d": preset_data.get("delay", preset_data.get("d", 100)), "d": preset_data.get("delay", preset_data.get("d", 100)),
"b": preset_data.get("brightness", preset_data.get("b", preset_data.get("br", 127))), "b": preset_data.get("brightness", preset_data.get("b", preset_data.get("br", 127))),
"a": preset_data.get("auto", preset_data.get("a", True)), "a": auto_bool,
"bg": bg,
"n1": preset_data.get("n1", 0), "n1": preset_data.get("n1", 0),
"n2": preset_data.get("n2", 0), "n2": preset_data.get("n2", 0),
"n3": preset_data.get("n3", 0), "n3": preset_data.get("n3", 0),

375
tests/beat_detect.py Normal file
View File

@@ -0,0 +1,375 @@
#!/usr/bin/env python3
"""Live beat detection utility with custom/aubio/hybrid modes."""
from __future__ import annotations
import argparse
import collections
import queue
import sys
import time
from typing import Deque
try:
import numpy as np
except ImportError as exc:
raise SystemExit(
"Missing dependency: numpy. Install with `pip install numpy`."
) from exc
try:
import sounddevice as sd
except ImportError as exc:
raise SystemExit(
"Missing dependency: sounddevice. Install with `pip install sounddevice`."
) from exc
try:
import requests
except ImportError:
requests = None
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Beat detector utility")
parser.add_argument(
"--mode",
choices=("custom", "aubio", "hybrid"),
default="aubio",
help="Detection mode",
)
parser.add_argument("--device", default=None, help="Input device name or index")
parser.add_argument(
"--sample-rate",
type=int,
default=0,
help="Audio sample rate (0 = use selected device default)",
)
parser.add_argument("--hop-size", type=int, default=256, help="Frame hop size in samples")
parser.add_argument("--win-mult", type=int, default=2, help="Aubio window size multiplier")
parser.add_argument(
"--min-band-hz",
type=float,
default=45.0,
help="Low frequency bound used for beat energy",
)
parser.add_argument(
"--max-band-hz",
type=float,
default=180.0,
help="High frequency bound used for beat energy",
)
parser.add_argument(
"--energy-weight",
type=float,
default=0.7,
help="Weight for low-band energy component (0..1)",
)
parser.add_argument(
"--flux-weight",
type=float,
default=0.3,
help="Weight for spectral flux component (0..1)",
)
parser.add_argument(
"--threshold-multiplier",
type=float,
default=1.35,
help="Custom-mode threshold multiplier vs adaptive baseline",
)
parser.add_argument(
"--ema-alpha",
type=float,
default=0.08,
help="Adaptive baseline smoothing (higher reacts faster)",
)
parser.add_argument(
"--min-ioi-ms",
type=float,
default=85.0,
help="Minimum time between beats in milliseconds",
)
parser.add_argument(
"--bpm-window",
type=int,
default=8,
help="How many recent beat intervals to use for BPM estimate",
)
parser.add_argument(
"--post-url",
default="",
help="Optional HTTP URL to POST beat events",
)
parser.add_argument(
"--aubio-method",
default="default",
choices=("default", "specdiff", "hfc", "complex", "phase", "energy"),
help="Aubio tempo method",
)
parser.add_argument(
"--aubio-threshold",
type=float,
default=0.12,
help="Aubio detection threshold",
)
parser.add_argument(
"--silence-gate-db",
type=float,
default=-58.0,
help="Ignore beat triggers when frame RMS is below this dB level",
)
return parser.parse_args()
def _estimate_bpm(beat_times: Deque[float]) -> float | None:
if len(beat_times) < 3:
return None
intervals = np.diff(np.array(beat_times, dtype=np.float64))
valid = intervals[(intervals > 0.2) & (intervals < 2.0)]
if valid.size == 0:
return None
return 60.0 / float(np.median(valid))
def _load_aubio_if_needed(mode: str):
if mode == "custom":
return None
try:
import aubio
return aubio
except ImportError:
dist_packages = "/usr/lib/python3/dist-packages"
if dist_packages not in sys.path:
sys.path.append(dist_packages)
try:
import aubio
return aubio
except ImportError:
raise SystemExit("aubio not installed; use --mode custom or install aubio")
class BeatDetectRuntime:
"""Reusable detector runtime so web and CLI can share logic."""
def __init__(self, args):
self.args = args
self.aubio = _load_aubio_if_needed(args.mode)
self.sample_rate = 0
self.frame_size = 0
self.tempo = None
self.band_mask = None
self.freqs = None
self.window = None
self.prev_mag = None
self.kick_mask = None
self.snare_mask = None
self.hat_mask = None
self.baseline = 1e-6
self.beat_times: Deque[float] = collections.deque(
maxlen=max(2, args.bpm_window + 1)
)
self.last_trigger_s = 0.0
self.debounce_s = float(args.min_ioi_ms) / 1000.0
def setup(self, sample_rate: int):
self.sample_rate = int(sample_rate)
self.frame_size = max(128, int(self.args.hop_size))
win_size = max(1024, self.frame_size * max(2, self.args.win_mult))
freqs = np.fft.rfftfreq(self.frame_size, d=1.0 / self.sample_rate)
self.freqs = freqs
self.band_mask = (freqs >= self.args.min_band_hz) & (
freqs <= self.args.max_band_hz
)
self.kick_mask = (freqs >= 40.0) & (freqs <= 140.0)
self.snare_mask = (freqs >= 140.0) & (freqs <= 3000.0)
self.hat_mask = (freqs >= 5000.0) & (freqs <= 12000.0)
if not np.any(self.band_mask):
raise ValueError("Invalid band range for current sample rate")
self.window = np.hanning(self.frame_size).astype(np.float32)
self.prev_mag = np.zeros(freqs.shape[0], dtype=np.float32)
self.baseline = 1e-6
self.last_trigger_s = 0.0
self.beat_times.clear()
self.tempo = None
if self.aubio is not None:
self.tempo = self.aubio.tempo(
self.args.aubio_method, win_size, self.frame_size, self.sample_rate
)
if hasattr(self.tempo, "set_threshold"):
self.tempo.set_threshold(float(self.args.aubio_threshold))
if hasattr(self.tempo, "set_minioi_ms"):
self.tempo.set_minioi_ms(float(self.args.min_ioi_ms))
def _classify_hit(self, mag: np.ndarray):
total = float(np.mean(mag) + 1e-9)
kick = float(np.mean(mag[self.kick_mask])) / total if np.any(self.kick_mask) else 0.0
snare = float(np.mean(mag[self.snare_mask])) / total if np.any(self.snare_mask) else 0.0
hat = float(np.mean(mag[self.hat_mask])) / total if np.any(self.hat_mask) else 0.0
scores = {
"kick": kick,
"snare": snare,
"hat": hat,
}
label, value = max(scores.items(), key=lambda kv: kv[1])
if value < 1.15:
return "unknown", value
return label, value
def process_frame(self, frame: np.ndarray, now_s: float | None = None):
if self.window is None or self.band_mask is None:
raise RuntimeError("Runtime not setup")
if frame.shape[0] != self.frame_size:
if frame.shape[0] > self.frame_size:
frame = frame[: self.frame_size]
else:
frame = np.pad(frame, (0, self.frame_size - frame.shape[0]))
f32 = frame.astype(np.float32)
rms = float(np.sqrt(np.mean(f32 * f32) + 1e-12))
db = 20.0 * np.log10(max(rms, 1e-12))
if db < float(self.args.silence_gate_db):
return None
mag = np.abs(np.fft.rfft(f32 * self.window)).astype(np.float32)
band_energy = float(np.mean(mag[self.band_mask]))
flux = float(np.mean(np.maximum(0.0, mag - self.prev_mag)))
self.prev_mag[:] = mag
weight_sum = max(1e-6, self.args.energy_weight + self.args.flux_weight)
score = ((self.args.energy_weight * band_energy) + (self.args.flux_weight * flux)) / weight_sum
self.baseline = ((1.0 - self.args.ema_alpha) * self.baseline) + (
self.args.ema_alpha * score
)
threshold = self.baseline * self.args.threshold_multiplier
custom_hit = score > threshold
aubio_hit = False
aubio_bpm = None
if self.tempo is not None:
aubio_hit = bool(self.tempo(f32)[0])
val = float(self.tempo.get_bpm())
aubio_bpm = val if val > 0 else None
if now_s is None:
now_s = time.time()
if (now_s - self.last_trigger_s) < self.debounce_s:
return None
if self.args.mode == "custom":
should_trigger = custom_hit
elif self.args.mode == "aubio":
should_trigger = aubio_hit
else:
should_trigger = custom_hit or aubio_hit
if not should_trigger:
return None
self.last_trigger_s = now_s
self.beat_times.append(now_s)
bpm = aubio_bpm if aubio_bpm is not None else _estimate_bpm(self.beat_times)
strength = score / max(1e-9, self.baseline)
beat_type, beat_type_conf = self._classify_hit(mag)
if self.args.mode == "custom":
src = "custom"
elif self.args.mode == "aubio":
src = "aubio"
elif custom_hit and aubio_hit:
src = "both"
elif custom_hit:
src = "custom"
else:
src = "aubio"
return {
"ts": now_s,
"bpm": bpm,
"src": src,
"score": score,
"threshold": threshold,
"strength": strength,
"beat_type": beat_type,
"beat_type_confidence": beat_type_conf,
"db": db,
}
def main() -> int:
args = parse_args()
runtime = BeatDetectRuntime(args)
if args.post_url and requests is None:
raise SystemExit("`requests` is required for --post-url (pip install requests)")
if args.sample_rate > 0:
sample_rate = args.sample_rate
else:
dev_info = sd.query_devices(args.device, "input")
sample_rate = int(dev_info["default_samplerate"])
runtime.setup(sample_rate=sample_rate)
frame_size = runtime.frame_size
audio_q: "queue.Queue[np.ndarray]" = queue.Queue(maxsize=64)
def audio_callback(indata, frames, _time_info, status):
_ = frames
if status:
print(f"audio status: {status}")
mono = np.asarray(indata[:, 0], dtype=np.float32)
if not audio_q.full():
audio_q.put_nowait(mono)
print(
"Listening... Ctrl+C to stop. "
f"mode={args.mode} sr={sample_rate} hop={frame_size} "
f"band={args.min_band_hz:.0f}-{args.max_band_hz:.0f}Hz "
f"custom_th={args.threshold_multiplier:.2f} aubio_th={args.aubio_threshold:.2f} "
f"min_ioi={args.min_ioi_ms:.0f}ms"
)
with sd.InputStream(
device=args.device,
channels=1,
samplerate=sample_rate,
blocksize=frame_size,
callback=audio_callback,
):
try:
while True:
try:
frame = audio_q.get(timeout=0.1)
except queue.Empty:
continue
if frame.shape[0] != frame_size:
if frame.shape[0] > frame_size:
frame = frame[:frame_size]
else:
frame = np.pad(frame, (0, frame_size - frame.shape[0]))
event = runtime.process_frame(frame, now_s=time.time())
if event is None:
continue
now_s = event["ts"]
bpm = event["bpm"]
bpm_text = f"{bpm:.1f}" if isinstance(bpm, (float, int)) else "--"
src = event["src"]
print(
f"[{args.mode}] BEAT bpm={bpm_text} src={src} type={event['beat_type']} "
f"type_conf={event['beat_type_confidence']:.2f} strength={event['strength']:.2f} "
f"db={event['db']:.1f} "
f"score={event['score']:.3e} threshold={event['threshold']:.3e}"
)
if args.post_url and requests is not None:
try:
requests.post(
args.post_url,
json={"beat": True, "source": src, "ts": now_s, "bpm": bpm},
timeout=0.5,
)
except Exception as exc:
print(f"post failed: {exc}")
except KeyboardInterrupt:
print("\nStopped.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Generate a click-track WAV file at a known BPM.
Example:
python tests/make_bpm_test_audio.py --bpm 128 --seconds 60 --output tests/audio_128bpm.wav
"""
from __future__ import annotations
import argparse
import math
import struct
import wave
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Generate known-BPM click track")
parser.add_argument("--bpm", type=float, required=True, help="Target BPM (e.g. 120)")
parser.add_argument("--seconds", type=float, default=30.0, help="Audio duration in seconds")
parser.add_argument("--sample-rate", type=int, default=44100, help="Sample rate")
parser.add_argument("--click-ms", type=float, default=25.0, help="Click length in milliseconds")
parser.add_argument(
"--output",
default="tests/bpm_test.wav",
help="Output WAV path",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
if args.bpm <= 0:
raise SystemExit("--bpm must be > 0")
if args.seconds <= 0:
raise SystemExit("--seconds must be > 0")
sample_rate = int(args.sample_rate)
total_samples = int(args.seconds * sample_rate)
beat_interval = 60.0 / float(args.bpm)
click_samples = max(1, int((args.click_ms / 1000.0) * sample_rate))
data = [0.0] * total_samples
beat_index = 0
t = 0.0
while t < args.seconds:
start = int(t * sample_rate)
# Slight accent every 4 beats to help human counting.
freq = 1760.0 if beat_index % 4 == 0 else 1320.0
amp = 0.9 if beat_index % 4 == 0 else 0.6
for i in range(click_samples):
idx = start + i
if idx >= total_samples:
break
env = math.exp(-8.0 * (i / click_samples))
s = amp * env * math.sin((2.0 * math.pi * freq * i) / sample_rate)
data[idx] += s
t += beat_interval
beat_index += 1
with wave.open(args.output, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sample_rate)
frames = bytearray()
for s in data:
clipped = max(-1.0, min(1.0, s))
frames.extend(struct.pack("<h", int(clipped * 32767)))
wf.writeframes(bytes(frames))
print(f"Wrote {args.output} at {args.bpm:.2f} BPM for {args.seconds:.1f}s")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Play a click track with tempo variation for BPM detector testing.
Examples:
python tests/play_varying_click_track.py --start-bpm 90 --end-bpm 150 --seconds 60
python tests/play_varying_click_track.py --pattern steps --step-bpms 100,120,140,160 --step-seconds 8
"""
from __future__ import annotations
import argparse
import math
import time
import numpy as np
import sounddevice as sd
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Play varying-BPM click track")
parser.add_argument("--device", default=None, help="Output audio device name or index")
parser.add_argument("--sample-rate", type=int, default=44100, help="Output sample rate")
parser.add_argument("--seconds", type=float, default=60.0, help="Playback duration")
parser.add_argument(
"--stabilize-seconds",
type=float,
default=5.0,
help="Play initial BPM for this many seconds before varying",
)
parser.add_argument("--pattern", choices=("sweep", "steps"), default="sweep")
# Sweep options
parser.add_argument("--start-bpm", type=float, default=90.0, help="Sweep start BPM")
parser.add_argument("--end-bpm", type=float, default=150.0, help="Sweep end BPM")
# Step options
parser.add_argument(
"--step-bpms",
default="100,120,140,160",
help="Comma-separated BPMs for step mode",
)
parser.add_argument("--step-seconds", type=float, default=8.0, help="Seconds per BPM step")
# Click tone options
parser.add_argument("--click-ms", type=float, default=25.0, help="Click duration")
parser.add_argument("--accent-every", type=int, default=4, help="Accent every N beats")
parser.add_argument(
"--hold-beats",
type=int,
default=1,
help="Hold BPM for this many beats before recalculating",
)
return parser.parse_args()
def bpm_for_time(t_s: float, args: argparse.Namespace, step_bpms: list[float]) -> float:
if t_s < args.stabilize_seconds:
return args.start_bpm if args.pattern == "sweep" else (step_bpms[0] if step_bpms else 120.0)
adj_t = t_s - args.stabilize_seconds
if args.pattern == "sweep":
active_seconds = max(1e-6, args.seconds - args.stabilize_seconds)
if active_seconds <= 0:
return args.start_bpm
alpha = min(1.0, max(0.0, adj_t / active_seconds))
return args.start_bpm + (args.end_bpm - args.start_bpm) * alpha
if not step_bpms:
return 120.0
if args.step_seconds <= 0:
return step_bpms[0]
idx = int(adj_t // args.step_seconds) % len(step_bpms)
return step_bpms[idx]
def main() -> int:
args = parse_args()
if args.seconds <= 0:
raise SystemExit("--seconds must be > 0")
if args.sample_rate <= 0:
raise SystemExit("--sample-rate must be > 0")
step_bpms = [float(x.strip()) for x in args.step_bpms.split(",") if x.strip()]
click_samples = max(1, int(args.click_ms * args.sample_rate / 1000.0))
state = {
"t": 0.0, # playback time in seconds
"next_beat": 0.0,
"beat_idx": 0,
"current_bpm": 0.0,
"held_bpm": 0.0,
"hold_counter": 0,
"click_remaining": 0,
"click_phase": 0,
"click_freq": 1320.0,
"click_amp": 0.6,
}
def callback(outdata, frames, _time_info, status):
if status:
print(f"audio status: {status}")
block = np.zeros(frames, dtype=np.float32)
for i in range(frames):
t = state["t"]
dynamic_bpm = bpm_for_time(t, args, step_bpms)
state["current_bpm"] = dynamic_bpm
bpm = state["held_bpm"] if state["held_bpm"] > 0 else dynamic_bpm
beat_interval = 60.0 / max(1e-6, bpm)
if t >= state["next_beat"]:
hold_beats = max(1, int(args.hold_beats))
if state["hold_counter"] <= 0:
state["held_bpm"] = dynamic_bpm
state["hold_counter"] = hold_beats
state["hold_counter"] -= 1
bpm = state["held_bpm"]
beat_interval = 60.0 / max(1e-6, bpm)
state["beat_idx"] += 1
state["next_beat"] = t + beat_interval
state["click_remaining"] = click_samples
state["click_phase"] = 0
accented = (
args.accent_every > 0 and state["beat_idx"] % args.accent_every == 1
)
state["click_freq"] = 1760.0 if accented else 1320.0
state["click_amp"] = 0.9 if accented else 0.6
if state["click_remaining"] > 0:
p = state["click_phase"]
env = math.exp(-8.0 * (p / click_samples))
sample = state["click_amp"] * env * math.sin(
2.0 * math.pi * state["click_freq"] * (p / args.sample_rate)
)
block[i] = sample
state["click_phase"] += 1
state["click_remaining"] -= 1
state["t"] += 1.0 / args.sample_rate
outdata[:, 0] = block
print(
f"Playing varying click track for {args.seconds:.1f}s ({args.pattern}), "
f"stabilize={args.stabilize_seconds:.1f}s"
)
with sd.OutputStream(
samplerate=args.sample_rate,
channels=1,
dtype="float32",
callback=callback,
device=args.device,
blocksize=0,
):
start = time.time()
last_printed_beat = 0
while (time.time() - start) < args.seconds:
beat_idx = int(state["beat_idx"])
if beat_idx != last_printed_beat:
last_printed_beat = beat_idx
print(
f"beat={beat_idx:04d} bpm={state['held_bpm']:.2f} "
f"(target={state['current_bpm']:.2f})"
)
time.sleep(0.05)
print("Done.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -2,7 +2,7 @@
""" """
Browser automation tests using Selenium. Browser automation tests using Selenium.
Tests run against the device in an actual browser. Target host defaults to Tests run against the device in an actual browser. Target host defaults to
``192.168.4.1``; override with ``LED_CONTROLLER_DEVICE_IP`` (IP or hostname, ``127.0.0.1:5000``; override with ``LED_CONTROLLER_DEVICE_IP`` (IP or hostname,
or a full ``http://`` / ``https://`` base URL). or a full ``http://`` / ``https://`` base URL).
Fixed delays between UI steps use ``LED_CONTROLLER_BROWSER_SLEEP_SCALE`` Fixed delays between UI steps use ``LED_CONTROLLER_BROWSER_SLEEP_SCALE``
@@ -49,7 +49,7 @@ from selenium.common.exceptions import (
ElementNotInteractableException, ElementNotInteractableException,
) )
_DEFAULT_DEVICE_HOST = "192.168.4.1" _DEFAULT_DEVICE_HOST = "127.0.0.1:5000"
def _device_base_url() -> str: def _device_base_url() -> str: