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>
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
3
Pipfile
3
Pipfile
@@ -14,6 +14,8 @@ selenium = "*"
|
|||||||
adafruit-ampy = "*"
|
adafruit-ampy = "*"
|
||||||
microdot = "*"
|
microdot = "*"
|
||||||
websockets = "*"
|
websockets = "*"
|
||||||
|
numpy = "*"
|
||||||
|
sounddevice = "*"
|
||||||
|
|
||||||
[dev-packages]
|
[dev-packages]
|
||||||
pytest = "*"
|
pytest = "*"
|
||||||
@@ -29,4 +31,3 @@ dev = "python -m watchfiles \"sh -c 'cd src && python main.py'\" src"
|
|||||||
test = "python -m pytest"
|
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 = "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'"
|
test-browser-device = "sh -c 'LED_CONTROLLER_RUN_BROWSER_TESTS=1 python -m pytest tests/test_browser.py'"
|
||||||
|
|
||||||
|
|||||||
224
Pipfile.lock
generated
224
Pipfile.lock
generated
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
292
db/pattern.json
292
db/pattern.json
@@ -1 +1,291 @@
|
|||||||
{"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":true},"blink":{"min_delay":10,"max_delay":10000,"max_colors":10,"has_background":true,"supports_manual":true},"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 10\u201330 s, -1=off)","n4":"Spark gap max (ms)","min_delay":10,"max_delay":10000,"max_colors":10,"supports_manual":false},"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,"has_background":true,"supports_manual":true},"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":false},"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":true},"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}}
|
{
|
||||||
|
"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 10–30 s, -1=off)",
|
||||||
|
"n4": "Spark gap max (ms)",
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10,
|
||||||
|
"supports_manual": false
|
||||||
|
},
|
||||||
|
"twinkle": {
|
||||||
|
"n1": "Twinkle activity (1–255, higher = more changes)",
|
||||||
|
"n2": "Density (0–255, 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
Submodule led-driver updated: fbebe9f4f9...170a0e05ab
@@ -368,6 +368,7 @@ async def create_driver_pattern(request):
|
|||||||
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),
|
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).
|
||||||
"""
|
"""
|
||||||
@@ -413,6 +414,9 @@ async def create_driver_pattern(request):
|
|||||||
if "has_background" in data:
|
if "has_background" in data:
|
||||||
meta["has_background"] = bool(data.get("has_background"))
|
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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
52
src/main.py
52
src/main.py
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
218
src/static/audio.js
Normal file
218
src/static/audio.js
Normal 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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -255,18 +357,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return Number.isFinite(n) ? n : 0;
|
return Number.isFinite(n) ? n : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const patternSupportsBackgroundColor = () => {
|
|
||||||
if (!presetPatternInput || !presetPatternInput.value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const pattern = String(presetPatternInput.value).trim();
|
|
||||||
const meta =
|
|
||||||
(cachedPatterns && cachedPatterns[pattern]) ||
|
|
||||||
(cachedPatterns && cachedPatterns[pattern.toLowerCase()]) ||
|
|
||||||
null;
|
|
||||||
return !!(meta && typeof meta === 'object' && meta.has_background === true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderPresetColors = (colors, paletteRefs) => {
|
const renderPresetColors = (colors, paletteRefs) => {
|
||||||
if (!presetColorsContainer) return;
|
if (!presetColorsContainer) return;
|
||||||
|
|
||||||
@@ -311,18 +401,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
swatchContainer.style.cssText = 'display: flex; flex-wrap: nowrap; gap: 0.5rem; align-items: flex-start; overflow-x: auto;';
|
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');
|
||||||
|
|
||||||
const showBackgroundLabel = patternSupportsBackgroundColor() && currentPresetColors.length > 1;
|
|
||||||
currentPresetColors.forEach((color, index) => {
|
currentPresetColors.forEach((color, index) => {
|
||||||
const isBackgroundColor = showBackgroundLabel && index === currentPresetColors.length - 1;
|
|
||||||
const swatchWrapper = document.createElement('div');
|
const swatchWrapper = document.createElement('div');
|
||||||
swatchWrapper.style.cssText = 'position: relative; display: inline-block;';
|
swatchWrapper.style.cssText = 'position: relative; display: inline-block;';
|
||||||
if (isBackgroundColor) {
|
|
||||||
// Keep the background color swatch at the far right.
|
|
||||||
swatchWrapper.style.marginLeft = 'auto';
|
|
||||||
}
|
|
||||||
swatchWrapper.draggable = true;
|
swatchWrapper.draggable = true;
|
||||||
swatchWrapper.dataset.colorIndex = index;
|
swatchWrapper.dataset.colorIndex = index;
|
||||||
swatchWrapper.dataset.backgroundColor = isBackgroundColor ? '1' : '0';
|
|
||||||
const refAtIndex = currentPresetPaletteRefs[index];
|
const refAtIndex = currentPresetPaletteRefs[index];
|
||||||
swatchWrapper.dataset.paletteRef = Number.isInteger(refAtIndex) ? String(refAtIndex) : '';
|
swatchWrapper.dataset.paletteRef = Number.isInteger(refAtIndex) ? String(refAtIndex) : '';
|
||||||
swatchWrapper.classList.add('draggable-color-swatch');
|
swatchWrapper.classList.add('draggable-color-swatch');
|
||||||
@@ -443,18 +526,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
swatchWrapper.appendChild(swatch);
|
swatchWrapper.appendChild(swatch);
|
||||||
swatchWrapper.appendChild(colorPicker);
|
swatchWrapper.appendChild(colorPicker);
|
||||||
swatchWrapper.appendChild(removeBtn);
|
swatchWrapper.appendChild(removeBtn);
|
||||||
if (isBackgroundColor) {
|
|
||||||
const bgLabel = document.createElement('div');
|
|
||||||
bgLabel.textContent = 'Background';
|
|
||||||
bgLabel.style.cssText = `
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
color: #cfcfcf;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
`;
|
|
||||||
swatchWrapper.appendChild(bgLabel);
|
|
||||||
}
|
|
||||||
swatchContainer.appendChild(swatchWrapper);
|
swatchContainer.appendChild(swatchWrapper);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -476,10 +547,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const dragging = swatchContainer.querySelector('.dragging-color');
|
const dragging = swatchContainer.querySelector('.dragging-color');
|
||||||
if (!dragging) return;
|
if (!dragging) return;
|
||||||
const backgroundEl = swatchContainer.querySelector('.draggable-color-swatch[data-background-color="1"]');
|
|
||||||
if (backgroundEl) {
|
|
||||||
swatchContainer.appendChild(backgroundEl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get new order of colors from DOM
|
// Get new order of colors from DOM
|
||||||
const colorElements = [...swatchContainer.querySelectorAll('.draggable-color-swatch')];
|
const colorElements = [...swatchContainer.querySelectorAll('.draggable-color-swatch')];
|
||||||
@@ -527,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();
|
||||||
@@ -587,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();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -609,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;
|
||||||
@@ -687,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.
|
||||||
@@ -847,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) => {
|
||||||
@@ -1220,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
|
||||||
@@ -1452,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)
|
||||||
@@ -1473,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),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1555,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;
|
||||||
@@ -1569,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';
|
||||||
|
|
||||||
@@ -1858,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) : [];
|
||||||
@@ -1881,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');
|
||||||
@@ -1889,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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
Loading zones...
|
Loading zones...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<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-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>
|
||||||
@@ -27,6 +33,7 @@
|
|||||||
<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>
|
||||||
@@ -46,9 +53,11 @@
|
|||||||
<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>
|
||||||
|
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
@@ -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
282
src/util/audio_detector.py
Normal 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
|
||||||
263
src/util/beat_driver_route.py
Normal file
263
src/util/beat_driver_route.py
Normal 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}")
|
||||||
@@ -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
375
tests/beat_detect.py
Normal 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())
|
||||||
75
tests/make_bpm_test_audio.py
Normal file
75
tests/make_bpm_test_audio.py
Normal 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())
|
||||||
171
tests/play_varying_click_track.py
Normal file
171
tests/play_varying_click_track.py
Normal 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())
|
||||||
Reference in New Issue
Block a user