Compare commits
3 Commits
0ae39ab94b
...
c1c3e5d71b
| Author | SHA1 | Date | |
|---|---|---|---|
| c1c3e5d71b | |||
| c64dd736f2 | |||
| cad0aa7e59 |
2
Pipfile
2
Pipfile
@@ -27,7 +27,7 @@ python_version = "3.11"
|
|||||||
web = "python tests/web.py"
|
web = "python tests/web.py"
|
||||||
watch = "python -m watchfiles \"python tests/web.py\" src tests"
|
watch = "python -m watchfiles \"python tests/web.py\" src tests"
|
||||||
run = "sh -c 'cd src && python main.py'"
|
run = "sh -c 'cd src && python main.py'"
|
||||||
dev = "python -m watchfiles \"sh -c 'cd src && python main.py'\" src"
|
dev = "python -m watchfiles \"sh -c 'cd src && LED_CONTROLLER_LIVE_RELOAD=1 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'"
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"1": {"name": "Main Group", "devices": ["188b0e1560a8"], "wifi_driver_display_name": "desk", "wifi_driver_num_leds": 59, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "output_brightness": 255}}
|
{"1": {"name": "group1", "devices": ["e8f60a16fb00", "e8f60a170794"], "wifi_driver_display_name": "desk", "wifi_driver_num_leds": 59, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "output_brightness": 255}, "2": {"name": "group2", "devices": ["188b0e1560a8"], "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0}}
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
"n3": "In time (ms)",
|
"n3": "In time (ms)",
|
||||||
"min_delay": 10,
|
"min_delay": 10,
|
||||||
"max_delay": 10000,
|
"max_delay": 10000,
|
||||||
"max_colors": 2,
|
"max_colors": 10,
|
||||||
"has_background": true,
|
"has_background": true,
|
||||||
"supports_manual": true
|
"supports_manual": true
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
{"1": {"group_name": "Main Group", "presets": ["1", "2"], "sequence_duration": 3000, "sequence_transition": 500, "sequence_loop": true, "sequence_repeat_count": 0, "sequence_active": false, "sequence_index": 0, "sequence_start_time": 0}, "2": {"group_name": "Accent Group", "presets": ["2", "3"], "sequence_duration": 2000, "sequence_transition": 300, "sequence_loop": true, "sequence_repeat_count": 0, "sequence_active": false, "sequence_index": 0, "sequence_start_time": 0}}
|
{"1": {"group_name": "Main Group", "presets": ["1", "2"], "sequence_duration": 3000, "sequence_transition": 500, "sequence_loop": true, "sequence_repeat_count": 0, "sequence_active": false, "sequence_index": 0, "sequence_start_time": 0, "steps": [], "step_duration_ms": 3000, "loop": true, "name": "Main Group", "profile_id": "1", "lanes": [[{"preset_id": "42", "beats": 6}, {"preset_id": "5", "beats": 2}], [{"preset_id": "6", "beats": 1}]], "group_ids": ["1"], "advance_mode": "beats", "lanes_group_ids": [["1"], ["2"]]}, "2": {"group_name": "Accent Group", "presets": ["2", "3"], "sequence_duration": 2000, "sequence_transition": 300, "sequence_loop": true, "sequence_repeat_count": 0, "sequence_active": false, "sequence_index": 0, "sequence_start_time": 0, "steps": [{"preset_id": "2", "group_ids": [], "beats": 1}, {"preset_id": "3", "group_ids": [], "beats": 1}], "step_duration_ms": 2000, "loop": true, "name": "Accent Group", "profile_id": "1", "lanes": [[{"preset_id": "2", "group_ids": [], "beats": 1}, {"preset_id": "3", "group_ids": [], "beats": 1}]], "group_ids": [], "advance_mode": "time", "lanes_group_ids": [[]]}}
|
||||||
@@ -167,6 +167,107 @@ async def _identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, nam
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def send_identify_to_device(dev_id: str) -> tuple[int, str]:
|
||||||
|
"""
|
||||||
|
Send the same identify blink as ``POST /devices/<id>/identify``.
|
||||||
|
|
||||||
|
Returns ``(http_status, "")`` on success, or ``(status, error_message)`` on failure
|
||||||
|
(status matches the single-device route).
|
||||||
|
"""
|
||||||
|
dev = devices.read(dev_id)
|
||||||
|
if not dev:
|
||||||
|
return 404, "Device not found"
|
||||||
|
sender = get_current_sender()
|
||||||
|
if not sender:
|
||||||
|
return 503, "Transport not configured"
|
||||||
|
name = str(dev.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
return 400, "Device must have a name to identify"
|
||||||
|
|
||||||
|
transport = dev.get("transport") or "espnow"
|
||||||
|
wifi_ip = None
|
||||||
|
if transport == "wifi":
|
||||||
|
wifi_ip = dev.get("address")
|
||||||
|
if not wifi_ip:
|
||||||
|
return 400, "Device has no IP address"
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = _compact_v1_json(
|
||||||
|
presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
|
||||||
|
select={name: [_IDENTIFY_PRESET_KEY]},
|
||||||
|
)
|
||||||
|
if transport == "wifi":
|
||||||
|
ok = await send_json_line_to_ip(wifi_ip, msg)
|
||||||
|
if not ok:
|
||||||
|
return 503, "Wi-Fi driver not connected"
|
||||||
|
else:
|
||||||
|
await sender.send(msg, addr=dev_id)
|
||||||
|
|
||||||
|
asyncio.create_task(
|
||||||
|
_identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return 503, str(e)
|
||||||
|
return 200, ""
|
||||||
|
|
||||||
|
|
||||||
|
async def send_identify_to_group_devices(macs: list[str]) -> tuple[int, list[dict]]:
|
||||||
|
"""
|
||||||
|
Identify every listed registry MAC in one delivery round: merged ``select`` and a single
|
||||||
|
ESP-NOW split envelope when multiple peers share the serial bridge (avoids per-device
|
||||||
|
``SerialSender`` lock serialisation). Wi-Fi peers are sent in parallel as in
|
||||||
|
``deliver_json_messages``.
|
||||||
|
"""
|
||||||
|
from util.driver_delivery import deliver_json_messages
|
||||||
|
|
||||||
|
errors: list[dict] = []
|
||||||
|
sender = get_current_sender()
|
||||||
|
if not sender:
|
||||||
|
return 0, [{"mac": "*", "error": "Transport not configured"}]
|
||||||
|
|
||||||
|
merged_select: dict[str, list[str]] = {}
|
||||||
|
valid_macs: list[str] = []
|
||||||
|
for dev_id in macs:
|
||||||
|
dev = devices.read(dev_id)
|
||||||
|
if not dev:
|
||||||
|
errors.append({"mac": dev_id, "error": "Device not found"})
|
||||||
|
continue
|
||||||
|
name = str(dev.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
errors.append({"mac": dev_id, "error": "Device must have a name to identify"})
|
||||||
|
continue
|
||||||
|
transport = (dev.get("transport") or "espnow").strip().lower()
|
||||||
|
if transport == "wifi":
|
||||||
|
if not dev.get("address"):
|
||||||
|
errors.append({"mac": dev_id, "error": "Device has no IP address"})
|
||||||
|
continue
|
||||||
|
merged_select[name] = [_IDENTIFY_PRESET_KEY]
|
||||||
|
valid_macs.append(dev_id)
|
||||||
|
|
||||||
|
if not merged_select:
|
||||||
|
return 0, errors
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = _compact_v1_json(
|
||||||
|
presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
|
||||||
|
select=merged_select,
|
||||||
|
)
|
||||||
|
await deliver_json_messages(sender, [msg], valid_macs, devices, delay_s=0)
|
||||||
|
except Exception as e:
|
||||||
|
return 0, errors + [{"mac": "*", "error": str(e)}]
|
||||||
|
|
||||||
|
for dev_id in valid_macs:
|
||||||
|
dev = devices.read(dev_id) or {}
|
||||||
|
name = str(dev.get("name") or "").strip()
|
||||||
|
transport = (dev.get("transport") or "espnow").strip().lower()
|
||||||
|
wifi_ip = dev.get("address") if transport == "wifi" else None
|
||||||
|
asyncio.create_task(
|
||||||
|
_identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name)
|
||||||
|
)
|
||||||
|
|
||||||
|
return len(valid_macs), errors
|
||||||
|
|
||||||
|
|
||||||
@controller.get("")
|
@controller.get("")
|
||||||
async def list_devices(request):
|
async def list_devices(request):
|
||||||
"""List all devices (includes ``connected`` for live Wi-Fi WebSocket presence)."""
|
"""List all devices (includes ``connected`` for live Wi-Fi WebSocket presence)."""
|
||||||
@@ -341,53 +442,12 @@ async def identify_device(request, id):
|
|||||||
this device name — same combined shape as profile sends the driver already accepts over TCP
|
this device name — same combined shape as profile sends the driver already accepts over TCP
|
||||||
/ ESP-NOW. No ``save``. After ``IDENTIFY_OFF_DELAY_S``, a background task selects ``off``.
|
/ ESP-NOW. No ``save``. After ``IDENTIFY_OFF_DELAY_S``, a background task selects ``off``.
|
||||||
"""
|
"""
|
||||||
dev = devices.read(id)
|
status, err = await send_identify_to_device(id)
|
||||||
if not dev:
|
if status == 200:
|
||||||
return json.dumps({"error": "Device not found"}), 404, {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
sender = get_current_sender()
|
|
||||||
if not sender:
|
|
||||||
return json.dumps({"error": "Transport not configured"}), 503, {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
name = str(dev.get("name") or "").strip()
|
|
||||||
if not name:
|
|
||||||
return json.dumps({"error": "Device must have a name to identify"}), 400, {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
transport = dev.get("transport") or "espnow"
|
|
||||||
wifi_ip = None
|
|
||||||
if transport == "wifi":
|
|
||||||
wifi_ip = dev.get("address")
|
|
||||||
if not wifi_ip:
|
|
||||||
return json.dumps({"error": "Device has no IP address"}), 400, {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
msg = _compact_v1_json(
|
|
||||||
presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
|
|
||||||
select={name: [_IDENTIFY_PRESET_KEY]},
|
|
||||||
)
|
|
||||||
if transport == "wifi":
|
|
||||||
ok = await send_json_line_to_ip(wifi_ip, msg)
|
|
||||||
if not ok:
|
|
||||||
return json.dumps({"error": "Wi-Fi driver not connected"}), 503, {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
await sender.send(msg, addr=id)
|
|
||||||
|
|
||||||
asyncio.create_task(
|
|
||||||
_identify_send_off_after_delay(sender, transport, wifi_ip, id, name)
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
|
|
||||||
return json.dumps({"message": "Identify sent"}), 200, {
|
return json.dumps({"message": "Identify sent"}), 200, {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
return json.dumps({"error": err}), status, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
@controller.post("/<id>/brightness")
|
@controller.post("/<id>/brightness")
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from microdot import Microdot
|
from microdot import Microdot
|
||||||
|
import asyncio
|
||||||
from models.group import Group
|
from models.group import Group
|
||||||
from models.device import Device
|
from models.device import Device
|
||||||
from models.transport import get_current_sender
|
from models.transport import get_current_sender
|
||||||
@@ -116,6 +117,11 @@ async def push_group_driver_config(request, id):
|
|||||||
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
|
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
|
||||||
sent = 0
|
sent = 0
|
||||||
errors = []
|
errors = []
|
||||||
|
msg = json.dumps(
|
||||||
|
{"v": "1", "device_config": dc, "save": True}, separators=(",", ":")
|
||||||
|
)
|
||||||
|
tasks = []
|
||||||
|
meta_macs = []
|
||||||
for mac in mac_list:
|
for mac in mac_list:
|
||||||
m = str(mac).strip().lower().replace(":", "").replace("-", "")
|
m = str(mac).strip().lower().replace(":", "").replace("-", "")
|
||||||
if len(m) != 12:
|
if len(m) != 12:
|
||||||
@@ -130,12 +136,15 @@ async def push_group_driver_config(request, id):
|
|||||||
if not ip:
|
if not ip:
|
||||||
errors.append({"mac": m, "error": "no IP"})
|
errors.append({"mac": m, "error": "no IP"})
|
||||||
continue
|
continue
|
||||||
msg = json.dumps(
|
tasks.append(send_json_line_to_ip(ip, msg))
|
||||||
{"v": "1", "device_config": dc, "save": True}, separators=(",", ":")
|
meta_macs.append(m)
|
||||||
)
|
if tasks:
|
||||||
ok = await send_json_line_to_ip(ip, msg)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
if ok:
|
for m, r in zip(meta_macs, results):
|
||||||
|
if r is True:
|
||||||
sent += 1
|
sent += 1
|
||||||
|
elif isinstance(r, Exception):
|
||||||
|
errors.append({"mac": m, "error": str(r)})
|
||||||
else:
|
else:
|
||||||
errors.append({"mac": m, "error": "driver not connected"})
|
errors.append({"mac": m, "error": "driver not connected"})
|
||||||
|
|
||||||
@@ -161,14 +170,9 @@ async def push_group_output_brightness(request, id):
|
|||||||
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
|
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
|
||||||
sent = 0
|
sent = 0
|
||||||
errors = []
|
errors = []
|
||||||
for mac in mac_list:
|
sender = get_current_sender()
|
||||||
m = str(mac).strip().lower().replace(":", "").replace("-", "")
|
|
||||||
if len(m) != 12:
|
async def _push_brightness_one(m: str, dev: dict) -> tuple[str, bool, str | None]:
|
||||||
continue
|
|
||||||
dev = devices.read(m)
|
|
||||||
if not dev:
|
|
||||||
errors.append({"mac": m, "error": "not in registry"})
|
|
||||||
continue
|
|
||||||
b_val = effective_brightness_for_mac(
|
b_val = effective_brightness_for_mac(
|
||||||
_pi_settings,
|
_pi_settings,
|
||||||
groups,
|
groups,
|
||||||
@@ -181,24 +185,79 @@ async def push_group_output_brightness(request, id):
|
|||||||
if transport == "wifi":
|
if transport == "wifi":
|
||||||
ip = normalize_tcp_peer_ip(str(dev.get("address") or ""))
|
ip = normalize_tcp_peer_ip(str(dev.get("address") or ""))
|
||||||
if not ip:
|
if not ip:
|
||||||
errors.append({"mac": m, "error": "no IP"})
|
return m, False, "no IP"
|
||||||
continue
|
|
||||||
ok = await send_json_line_to_ip(ip, msg)
|
ok = await send_json_line_to_ip(ip, msg)
|
||||||
if ok:
|
return m, bool(ok), None if ok else "driver not connected"
|
||||||
sent += 1
|
|
||||||
else:
|
|
||||||
errors.append({"mac": m, "error": "driver not connected"})
|
|
||||||
else:
|
|
||||||
sender = get_current_sender()
|
|
||||||
if not sender:
|
if not sender:
|
||||||
errors.append({"mac": m, "error": "transport not configured"})
|
return m, False, "transport not configured"
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
await sender.send(msg, addr=m)
|
await sender.send(msg, addr=m)
|
||||||
sent += 1
|
return m, True, None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append({"mac": m, "error": str(e)})
|
return m, False, str(e)
|
||||||
|
|
||||||
|
tasks: list = []
|
||||||
|
for mac in mac_list:
|
||||||
|
m = str(mac).strip().lower().replace(":", "").replace("-", "")
|
||||||
|
if len(m) != 12:
|
||||||
|
continue
|
||||||
|
dev = devices.read(m)
|
||||||
|
if not dev:
|
||||||
|
errors.append({"mac": m, "error": "not in registry"})
|
||||||
|
continue
|
||||||
|
tasks.append(_push_brightness_one(m, dev))
|
||||||
|
|
||||||
|
if tasks:
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
for r in results:
|
||||||
|
if isinstance(r, Exception):
|
||||||
|
errors.append({"mac": "*", "error": str(r)})
|
||||||
|
continue
|
||||||
|
m, ok, err = r
|
||||||
|
if ok:
|
||||||
|
sent += 1
|
||||||
|
elif err:
|
||||||
|
errors.append({"mac": m, "error": err})
|
||||||
|
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
{"message": "brightness sent", "sent": sent, "errors": errors}
|
{"message": "brightness sent", "sent": sent, "errors": errors}
|
||||||
), 200, {"Content-Type": "application/json"}
|
), 200, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post("/<id>/identify")
|
||||||
|
async def identify_group_devices(request, id):
|
||||||
|
"""
|
||||||
|
Run the same identify blink as ``POST /devices/<id>/identify`` for every registry member
|
||||||
|
in parallel so all drivers in the group blink together.
|
||||||
|
"""
|
||||||
|
_ = request
|
||||||
|
gdoc = groups.read(id)
|
||||||
|
if not gdoc:
|
||||||
|
return json.dumps({"error": "Group not found"}), 404, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
|
||||||
|
if not mac_list:
|
||||||
|
return json.dumps({"error": "Group has no devices"}), 400, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
from controllers.device import send_identify_to_group_devices
|
||||||
|
|
||||||
|
normalized: list[str] = []
|
||||||
|
errors: list[dict] = []
|
||||||
|
for mac in mac_list:
|
||||||
|
m = str(mac).strip().lower().replace(":", "").replace("-", "")
|
||||||
|
if len(m) != 12:
|
||||||
|
errors.append({"mac": str(mac), "error": "invalid MAC"})
|
||||||
|
continue
|
||||||
|
normalized.append(m)
|
||||||
|
|
||||||
|
if not normalized:
|
||||||
|
return json.dumps(
|
||||||
|
{"message": "identify group done", "sent": 0, "errors": errors}
|
||||||
|
), 200, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
sent, batch_errors = await send_identify_to_group_devices(normalized)
|
||||||
|
errors.extend(batch_errors)
|
||||||
|
|
||||||
|
return json.dumps(
|
||||||
|
{"message": "identify group done", "sent": sent, "errors": errors}
|
||||||
|
), 200, {"Content-Type": "application/json"}
|
||||||
|
|||||||
@@ -1,51 +1,207 @@
|
|||||||
from microdot import Microdot
|
from microdot import Microdot
|
||||||
from models.squence import Sequence
|
from microdot.session import with_session
|
||||||
|
from models.sequence import Sequence
|
||||||
|
from models.profile import Profile
|
||||||
|
from models.transport import get_current_sender
|
||||||
import json
|
import json
|
||||||
|
|
||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
sequences = Sequence()
|
sequences = Sequence()
|
||||||
|
profiles = Profile()
|
||||||
|
|
||||||
@controller.get('')
|
|
||||||
async def list_sequences(request):
|
|
||||||
"""List all sequences."""
|
|
||||||
return json.dumps(sequences), 200, {'Content-Type': 'application/json'}
|
|
||||||
|
|
||||||
@controller.get('/<id>')
|
def get_current_profile_id(session=None):
|
||||||
async def get_sequence(request, id):
|
"""Get the current active profile ID from session or fallback to first."""
|
||||||
"""Get a specific sequence by ID."""
|
profile_list = profiles.list()
|
||||||
sequence = sequences.read(id)
|
session_profile = None
|
||||||
if sequence:
|
if session is not None:
|
||||||
return json.dumps(sequence), 200, {'Content-Type': 'application/json'}
|
session_profile = session.get("current_profile")
|
||||||
|
if session_profile and session_profile in profile_list:
|
||||||
|
return session_profile
|
||||||
|
if profile_list:
|
||||||
|
return profile_list[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get("")
|
||||||
|
@with_session
|
||||||
|
async def list_sequences(request, session):
|
||||||
|
"""List sequences for the current profile."""
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
if not current_profile_id:
|
||||||
|
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
||||||
|
scoped = {
|
||||||
|
sid: sdata
|
||||||
|
for sid, sdata in sequences.items()
|
||||||
|
if isinstance(sdata, dict)
|
||||||
|
and str(sdata.get("profile_id")) == str(current_profile_id)
|
||||||
|
}
|
||||||
|
return json.dumps(scoped), 200, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get("/<id>")
|
||||||
|
@with_session
|
||||||
|
async def get_sequence(request, session, id):
|
||||||
|
"""Get a specific sequence by ID (current profile only)."""
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
seq = sequences.read(id)
|
||||||
|
if (
|
||||||
|
seq
|
||||||
|
and current_profile_id
|
||||||
|
and str(seq.get("profile_id")) == str(current_profile_id)
|
||||||
|
):
|
||||||
|
return json.dumps(seq), 200, {"Content-Type": "application/json"}
|
||||||
return json.dumps({"error": "Sequence not found"}), 404
|
return json.dumps({"error": "Sequence not found"}), 404
|
||||||
|
|
||||||
@controller.post('')
|
|
||||||
async def create_sequence(request):
|
@controller.post("")
|
||||||
"""Create a new sequence."""
|
@with_session
|
||||||
|
async def create_sequence(request, session):
|
||||||
|
"""Create a new sequence for the current profile."""
|
||||||
|
try:
|
||||||
try:
|
try:
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
group_name = data.get("group_name", "")
|
except Exception:
|
||||||
preset_names = data.get("presets", None)
|
return (
|
||||||
sequence_id = sequences.create(group_name, preset_names)
|
json.dumps({"error": "Invalid JSON"}),
|
||||||
if data:
|
400,
|
||||||
sequences.update(sequence_id, data)
|
{"Content-Type": "application/json"},
|
||||||
return json.dumps(sequences.read(sequence_id)), 201, {'Content-Type': 'application/json'}
|
)
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
if not current_profile_id:
|
||||||
|
return (
|
||||||
|
json.dumps({"error": "No profile available"}),
|
||||||
|
404,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
sequence_id = sequences.create(current_profile_id)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
data = {}
|
||||||
|
data = dict(data)
|
||||||
|
data["profile_id"] = str(current_profile_id)
|
||||||
|
if sequences.update(sequence_id, data):
|
||||||
|
seq_data = sequences.read(sequence_id)
|
||||||
|
return (
|
||||||
|
json.dumps({sequence_id: seq_data}),
|
||||||
|
201,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
json.dumps({"error": "Failed to create sequence"}),
|
||||||
|
400,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": str(e)}), 400
|
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
@controller.put('/<id>')
|
|
||||||
async def update_sequence(request, id):
|
@controller.put("/<id>")
|
||||||
"""Update an existing sequence."""
|
@with_session
|
||||||
|
async def update_sequence(request, session, id):
|
||||||
|
"""Update an existing sequence (current profile only)."""
|
||||||
try:
|
try:
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
seq = sequences.read(id)
|
||||||
|
if not seq or str(seq.get("profile_id")) != str(current_profile_id):
|
||||||
|
return json.dumps({"error": "Sequence not found"}), 404
|
||||||
data = request.json
|
data = request.json
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return (
|
||||||
|
json.dumps({"error": "Invalid JSON"}),
|
||||||
|
400,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
data = dict(data)
|
||||||
|
data["profile_id"] = str(current_profile_id)
|
||||||
if sequences.update(id, data):
|
if sequences.update(id, data):
|
||||||
return json.dumps(sequences.read(id)), 200, {'Content-Type': 'application/json'}
|
try:
|
||||||
|
from util.sequence_playback import stop_if_playing_sequence
|
||||||
|
|
||||||
|
stop_if_playing_sequence(str(id))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return json.dumps(sequences.read(id)), 200, {"Content-Type": "application/json"}
|
||||||
return json.dumps({"error": "Sequence not found"}), 404
|
return json.dumps({"error": "Sequence not found"}), 404
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": str(e)}), 400
|
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
@controller.delete('/<id>')
|
|
||||||
async def delete_sequence(request, id):
|
@controller.delete("/<id>")
|
||||||
"""Delete a sequence."""
|
@with_session
|
||||||
if sequences.delete(id):
|
async def delete_sequence(request, session, id):
|
||||||
return json.dumps({"message": "Sequence deleted successfully"}), 200
|
"""Delete a sequence (current profile only)."""
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
seq = sequences.read(id)
|
||||||
|
if not seq or str(seq.get("profile_id")) != str(current_profile_id):
|
||||||
return json.dumps({"error": "Sequence not found"}), 404
|
return json.dumps({"error": "Sequence not found"}), 404
|
||||||
|
try:
|
||||||
|
from util.sequence_playback import stop_if_playing_sequence
|
||||||
|
|
||||||
|
stop_if_playing_sequence(str(id))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if sequences.delete(id):
|
||||||
|
return (
|
||||||
|
json.dumps({"message": "Sequence deleted successfully"}),
|
||||||
|
200,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
return json.dumps({"error": "Sequence not found"}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post("/stop")
|
||||||
|
@with_session
|
||||||
|
async def stop_sequence_playback(request, session):
|
||||||
|
"""Stop server-driven zone sequence playback."""
|
||||||
|
_ = request
|
||||||
|
try:
|
||||||
|
from util.sequence_playback import stop
|
||||||
|
|
||||||
|
stop()
|
||||||
|
return json.dumps({"ok": True}), 200, {"Content-Type": "application/json"}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post("/<id>/play")
|
||||||
|
@with_session
|
||||||
|
async def play_sequence(request, session, id):
|
||||||
|
"""Start server-driven playback for a sequence in a zone (body: {\"zone_id\": \"...\"})."""
|
||||||
|
if not get_current_sender():
|
||||||
|
return (
|
||||||
|
json.dumps({"error": "Transport not configured"}),
|
||||||
|
503,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
if not current_profile_id:
|
||||||
|
return (
|
||||||
|
json.dumps({"error": "No profile available"}),
|
||||||
|
404,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
except Exception:
|
||||||
|
data = {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
data = {}
|
||||||
|
zone_id = data.get("zone_id") or data.get("zoneId")
|
||||||
|
if zone_id is None or str(zone_id).strip() == "":
|
||||||
|
return (
|
||||||
|
json.dumps({"error": "zone_id required"}),
|
||||||
|
400,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
zone_id = str(zone_id).strip()
|
||||||
|
try:
|
||||||
|
from util.sequence_playback import start
|
||||||
|
|
||||||
|
await start(zone_id, str(id), str(current_profile_id))
|
||||||
|
return json.dumps({"ok": True}), 200, {"Content-Type": "application/json"}
|
||||||
|
except ValueError as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||||
|
except RuntimeError as e:
|
||||||
|
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||||
|
|||||||
94
src/main.py
94
src/main.py
@@ -2,6 +2,7 @@ import asyncio
|
|||||||
import errno
|
import errno
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
import signal
|
import signal
|
||||||
import socket
|
import socket
|
||||||
import threading
|
import threading
|
||||||
@@ -38,6 +39,11 @@ _tcp_device_lock = threading.Lock()
|
|||||||
DISCOVERY_UDP_PORT = 8766
|
DISCOVERY_UDP_PORT = 8766
|
||||||
|
|
||||||
|
|
||||||
|
def _live_reload_enabled() -> bool:
|
||||||
|
v = os.environ.get("LED_CONTROLLER_LIVE_RELOAD", "").strip().lower()
|
||||||
|
return v not in ("", "0", "false", "no")
|
||||||
|
|
||||||
|
|
||||||
def _register_udp_device_sync(
|
def _register_udp_device_sync(
|
||||||
device_name: str, peer_ip: str, mac, device_type=None
|
device_name: str, peer_ip: str, mac, device_type=None
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -248,9 +254,22 @@ async def main(port=80):
|
|||||||
|
|
||||||
app = Microdot()
|
app = Microdot()
|
||||||
audio_detector = AudioBeatDetector()
|
audio_detector = AudioBeatDetector()
|
||||||
|
try:
|
||||||
|
from util.audio_run_persist import coerce_audio_device, read_audio_run_state
|
||||||
|
|
||||||
|
persisted = read_audio_run_state()
|
||||||
|
if persisted.get("enabled"):
|
||||||
|
dev = coerce_audio_device(persisted.get("device"))
|
||||||
|
audio_detector.start(device=dev)
|
||||||
|
print("[startup] audio beat detector started from saved run state")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[startup] audio auto-start skipped: {e!r}")
|
||||||
from util import beat_driver_route
|
from util import beat_driver_route
|
||||||
|
|
||||||
beat_driver_route.set_beat_route_main_loop(asyncio.get_running_loop())
|
beat_driver_route.set_beat_route_main_loop(asyncio.get_running_loop())
|
||||||
|
from util import sequence_playback as seq_pb
|
||||||
|
|
||||||
|
seq_pb.ensure_beat_consumer_started()
|
||||||
|
|
||||||
# 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')
|
||||||
@@ -284,11 +303,42 @@ async def main(port=80):
|
|||||||
tcp_client_registry.set_settings(settings)
|
tcp_client_registry.set_settings(settings)
|
||||||
tcp_client_registry.set_tcp_status_broadcaster(broadcast_device_tcp_status)
|
tcp_client_registry.set_tcp_status_broadcaster(broadcast_device_tcp_status)
|
||||||
|
|
||||||
|
live_reload = _live_reload_enabled()
|
||||||
|
dev_build_id = secrets.token_hex(12) if live_reload else None
|
||||||
|
if live_reload:
|
||||||
|
print(
|
||||||
|
"[dev] LED_CONTROLLER_LIVE_RELOAD: browser refreshes when the server process restarts"
|
||||||
|
)
|
||||||
|
|
||||||
|
if dev_build_id:
|
||||||
|
|
||||||
|
@app.route("/__dev/build-id")
|
||||||
|
def dev_build_id_route(request):
|
||||||
|
_ = request
|
||||||
|
return (
|
||||||
|
dev_build_id,
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"Content-Type": "text/plain; charset=utf-8",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# Serve index.html at root (cwd is src/ when run via pipenv run run)
|
# Serve index.html at root (cwd is src/ when run via pipenv run run)
|
||||||
@app.route('/')
|
@app.route("/")
|
||||||
def index(request):
|
def index(request):
|
||||||
"""Serve the main web UI."""
|
"""Serve the main web UI."""
|
||||||
return send_file('templates/index.html')
|
if dev_build_id:
|
||||||
|
try:
|
||||||
|
with open("templates/index.html", encoding="utf-8") as f:
|
||||||
|
html = f.read()
|
||||||
|
tag = '<script src="/static/dev-live-reload.js" defer></script>'
|
||||||
|
if "</body>" in html:
|
||||||
|
html = html.replace("</body>", tag + "\n</body>", 1)
|
||||||
|
return html, 200, {"Content-Type": "text/html; charset=utf-8"}
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return send_file("templates/index.html")
|
||||||
|
|
||||||
# Favicon: avoid 404 in browser console (no file needed)
|
# Favicon: avoid 404 in browser console (no file needed)
|
||||||
@app.route('/favicon.ico')
|
@app.route('/favicon.ico')
|
||||||
@@ -319,6 +369,9 @@ async def main(port=80):
|
|||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
audio_detector.start(device=device)
|
audio_detector.start(device=device)
|
||||||
|
from util.audio_run_persist import write_audio_run_state
|
||||||
|
|
||||||
|
write_audio_run_state(enabled=True, device=device)
|
||||||
return {"ok": True, "status": audio_detector.status()}
|
return {"ok": True, "status": audio_detector.status()}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"ok": False, "error": str(e)}, 500
|
return {"ok": False, "error": str(e)}, 500
|
||||||
@@ -327,12 +380,47 @@ async def main(port=80):
|
|||||||
async def audio_stop(request):
|
async def audio_stop(request):
|
||||||
_ = request
|
_ = request
|
||||||
audio_detector.stop()
|
audio_detector.stop()
|
||||||
|
from util.audio_run_persist import write_audio_run_state
|
||||||
|
|
||||||
|
write_audio_run_state(enabled=False)
|
||||||
return {"ok": True, "status": audio_detector.status()}
|
return {"ok": True, "status": audio_detector.status()}
|
||||||
|
|
||||||
@app.route('/api/audio/status')
|
@app.route('/api/audio/status')
|
||||||
async def audio_status(request):
|
async def audio_status(request):
|
||||||
_ = request
|
_ = request
|
||||||
return {"status": audio_detector.status()}
|
from util import beat_driver_route
|
||||||
|
from util import sequence_playback
|
||||||
|
|
||||||
|
st = audio_detector.status()
|
||||||
|
st["sequence"] = sequence_playback.playback_status()
|
||||||
|
st["manual_beat_stride"] = beat_driver_route.manual_beat_stride_status()
|
||||||
|
seq = st.get("sequence")
|
||||||
|
beat_readout = ""
|
||||||
|
if isinstance(seq, dict) and str(seq.get("beat_readout") or "").strip():
|
||||||
|
beat_readout = str(seq.get("beat_readout") or "").strip()
|
||||||
|
elif st.get("running"):
|
||||||
|
mb = st.get("manual_beat_stride")
|
||||||
|
if isinstance(mb, dict) and mb.get("active"):
|
||||||
|
try:
|
||||||
|
n = int(mb.get("stride_n") or 1)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
n = 1
|
||||||
|
n = max(1, min(64, n))
|
||||||
|
try:
|
||||||
|
bi = int(mb.get("beat_in_stride") or 1)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
bi = 1
|
||||||
|
pos = min(n, max(1, bi))
|
||||||
|
beat_readout = f"{pos}/{n}"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
bs = int(st.get("beat_seq") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
bs = 0
|
||||||
|
if bs > 0:
|
||||||
|
beat_readout = str(bs)
|
||||||
|
st["beat_readout"] = beat_readout
|
||||||
|
return {"status": st}
|
||||||
|
|
||||||
# Static file route
|
# Static file route
|
||||||
@app.route("/static/<path:path>")
|
@app.route("/static/<path:path>")
|
||||||
|
|||||||
148
src/models/sequence.py
Normal file
148
src/models/sequence.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
from models.model import Model
|
||||||
|
|
||||||
|
|
||||||
|
class Sequence(Model):
|
||||||
|
def load(self):
|
||||||
|
super().load()
|
||||||
|
self._migrate_after_load()
|
||||||
|
|
||||||
|
def _migrate_after_load(self):
|
||||||
|
try:
|
||||||
|
from models.profile import Profile
|
||||||
|
|
||||||
|
profiles = Profile()
|
||||||
|
profile_list = profiles.list()
|
||||||
|
default_profile_id = profile_list[0] if profile_list else None
|
||||||
|
except Exception:
|
||||||
|
default_profile_id = None
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
for _sid, doc in list(self.items()):
|
||||||
|
if not isinstance(doc, dict):
|
||||||
|
continue
|
||||||
|
if not isinstance(doc.get("steps"), list):
|
||||||
|
presets = doc.get("presets")
|
||||||
|
if isinstance(presets, list) and presets:
|
||||||
|
doc["steps"] = [
|
||||||
|
{"preset_id": str(p), "group_ids": []} for p in presets
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
doc["steps"] = []
|
||||||
|
changed = True
|
||||||
|
if "step_duration_ms" not in doc:
|
||||||
|
dur = doc.get("sequence_duration")
|
||||||
|
doc["step_duration_ms"] = (
|
||||||
|
int(dur) if isinstance(dur, (int, float)) else 3000
|
||||||
|
)
|
||||||
|
changed = True
|
||||||
|
if "loop" not in doc:
|
||||||
|
doc["loop"] = bool(doc.get("sequence_loop", False))
|
||||||
|
changed = True
|
||||||
|
if "name" not in doc:
|
||||||
|
doc["name"] = str(doc.get("group_name") or "")
|
||||||
|
changed = True
|
||||||
|
if "profile_id" not in doc and default_profile_id is not None:
|
||||||
|
doc["profile_id"] = str(default_profile_id)
|
||||||
|
changed = True
|
||||||
|
if not isinstance(doc.get("lanes"), list):
|
||||||
|
steps = doc.get("steps")
|
||||||
|
if isinstance(steps, list) and steps:
|
||||||
|
doc["lanes"] = [list(steps)]
|
||||||
|
else:
|
||||||
|
doc["lanes"] = [[]]
|
||||||
|
changed = True
|
||||||
|
if "group_ids" not in doc or not isinstance(doc.get("group_ids"), list):
|
||||||
|
doc["group_ids"] = []
|
||||||
|
changed = True
|
||||||
|
if doc.get("advance_mode") not in ("time", "beats"):
|
||||||
|
doc["advance_mode"] = "time"
|
||||||
|
changed = True
|
||||||
|
if "sequence_transition" not in doc:
|
||||||
|
doc["sequence_transition"] = 500
|
||||||
|
changed = True
|
||||||
|
# Ensure each step has beats (beat-based advance); default 1
|
||||||
|
for lane in doc.get("lanes") or []:
|
||||||
|
if not isinstance(lane, list):
|
||||||
|
continue
|
||||||
|
for step in lane:
|
||||||
|
if not isinstance(step, dict):
|
||||||
|
continue
|
||||||
|
if "beats" not in step:
|
||||||
|
step["beats"] = 1
|
||||||
|
changed = True
|
||||||
|
# Per-lane group ids (parallel to ``lanes``)
|
||||||
|
lanes_list = [x for x in (doc.get("lanes") or []) if isinstance(x, list)]
|
||||||
|
n_lanes = len(lanes_list)
|
||||||
|
lg = doc.get("lanes_group_ids")
|
||||||
|
if n_lanes and (not isinstance(lg, list) or len(lg) != n_lanes):
|
||||||
|
shared = doc.get("group_ids") if isinstance(doc.get("group_ids"), list) else []
|
||||||
|
shared_s = [str(x).strip() for x in shared if x is not None and str(x).strip()]
|
||||||
|
if n_lanes == 1 and lanes_list[0]:
|
||||||
|
first = lanes_list[0][0] if isinstance(lanes_list[0][0], dict) else {}
|
||||||
|
step_g = (
|
||||||
|
first.get("group_ids")
|
||||||
|
if isinstance(first.get("group_ids"), list)
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
step_s = [
|
||||||
|
str(x).strip() for x in step_g if x is not None and str(x).strip()
|
||||||
|
]
|
||||||
|
doc["lanes_group_ids"] = [step_s if step_s else list(shared_s)]
|
||||||
|
else:
|
||||||
|
doc["lanes_group_ids"] = [list(shared_s) for _ in range(n_lanes)]
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def create(self, profile_id=None):
|
||||||
|
next_id = self.get_next_id()
|
||||||
|
self[next_id] = {
|
||||||
|
"name": "",
|
||||||
|
"profile_id": str(profile_id) if profile_id is not None else None,
|
||||||
|
"group_ids": [],
|
||||||
|
"lanes": [[]],
|
||||||
|
"lanes_group_ids": [[]],
|
||||||
|
"advance_mode": "time",
|
||||||
|
"steps": [],
|
||||||
|
"step_duration_ms": 3000,
|
||||||
|
"sequence_transition": 500,
|
||||||
|
"loop": True,
|
||||||
|
}
|
||||||
|
self.save()
|
||||||
|
return next_id
|
||||||
|
|
||||||
|
def read(self, id):
|
||||||
|
id_str = str(id)
|
||||||
|
return self.get(id_str, None)
|
||||||
|
|
||||||
|
def update(self, id, data):
|
||||||
|
id_str = str(id)
|
||||||
|
if id_str not in self:
|
||||||
|
return False
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return False
|
||||||
|
data = dict(data)
|
||||||
|
steps = data.get("steps")
|
||||||
|
lanes = data.get("lanes")
|
||||||
|
if isinstance(steps, list) and steps:
|
||||||
|
lanes_ok = (
|
||||||
|
isinstance(lanes, list)
|
||||||
|
and lanes
|
||||||
|
and any(isinstance(x, list) and len(x) > 0 for x in lanes)
|
||||||
|
)
|
||||||
|
if not lanes_ok:
|
||||||
|
data["lanes"] = [list(steps)]
|
||||||
|
self[id_str].update(data)
|
||||||
|
self.save()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delete(self, id):
|
||||||
|
id_str = str(id)
|
||||||
|
if id_str not in self:
|
||||||
|
return False
|
||||||
|
self.pop(id_str)
|
||||||
|
self.save()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def list(self):
|
||||||
|
return list(self.keys())
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
from models.model import Model
|
|
||||||
|
|
||||||
class Sequence(Model):
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
def create(self, group_name="", preset_names=None):
|
|
||||||
next_id = self.get_next_id()
|
|
||||||
self[next_id] = {
|
|
||||||
"group_name": group_name,
|
|
||||||
"presets": preset_names if preset_names else [],
|
|
||||||
"sequence_duration": 3000, # Duration per preset in ms
|
|
||||||
"sequence_transition": 500, # Transition time in ms
|
|
||||||
"sequence_loop": False,
|
|
||||||
"sequence_repeat_count": 0, # 0 = infinite
|
|
||||||
"sequence_active": False,
|
|
||||||
"sequence_index": 0,
|
|
||||||
"sequence_start_time": 0
|
|
||||||
}
|
|
||||||
self.save()
|
|
||||||
return next_id
|
|
||||||
|
|
||||||
def read(self, id):
|
|
||||||
id_str = str(id)
|
|
||||||
return self.get(id_str, None)
|
|
||||||
|
|
||||||
def update(self, id, data):
|
|
||||||
id_str = str(id)
|
|
||||||
if id_str not in self:
|
|
||||||
return False
|
|
||||||
self[id_str].update(data)
|
|
||||||
self.save()
|
|
||||||
return True
|
|
||||||
|
|
||||||
def delete(self, id):
|
|
||||||
id_str = str(id)
|
|
||||||
if id_str not in self:
|
|
||||||
return False
|
|
||||||
self.pop(id_str)
|
|
||||||
self.save()
|
|
||||||
return True
|
|
||||||
|
|
||||||
def list(self):
|
|
||||||
return list(self.keys())
|
|
||||||
@@ -36,6 +36,9 @@ class Zone(Model):
|
|||||||
if "group_ids" not in doc:
|
if "group_ids" not in doc:
|
||||||
doc["group_ids"] = []
|
doc["group_ids"] = []
|
||||||
changed = True
|
changed = True
|
||||||
|
if "preset_group_ids" not in doc or not isinstance(doc.get("preset_group_ids"), dict):
|
||||||
|
doc["preset_group_ids"] = {}
|
||||||
|
changed = True
|
||||||
if changed:
|
if changed:
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
@@ -48,6 +51,7 @@ class Zone(Model):
|
|||||||
"name": name,
|
"name": name,
|
||||||
"names": names if names else [],
|
"names": names if names else [],
|
||||||
"group_ids": gid_list,
|
"group_ids": gid_list,
|
||||||
|
"preset_group_ids": {},
|
||||||
"presets": presets if presets else [],
|
"presets": presets if presets else [],
|
||||||
"default_preset": None,
|
"default_preset": None,
|
||||||
"brightness": 255,
|
"brightness": 255,
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
(() => {
|
(() => {
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
let lastBeatSeq = 0;
|
let lastBeatSeq = 0;
|
||||||
|
let lastLoggedSequenceBeatFractions = "";
|
||||||
|
/** Prior poll had server zone sequence playback active (`status.sequence.active === true`). */
|
||||||
|
let prevZoneSequencePlaybackActive = false;
|
||||||
|
/**
|
||||||
|
* After sequence playback ends/stops while audio keeps running, keep header # idle until the
|
||||||
|
* next beat bumps `beat_seq` (avoids the stuck final cumulative value vs sequence readout).
|
||||||
|
*/
|
||||||
|
let headerBeatStickyIdleAfterSeq = false;
|
||||||
|
/** Suppresses duplicate `console.log` when the same `beat_seq` + server `beat_readout` repeats. */
|
||||||
|
let lastBeatConsoleKey = "";
|
||||||
|
/** @type {Set<ReturnType<typeof setTimeout>>} */
|
||||||
|
const pendingBeatPhaseTimers = new Set();
|
||||||
|
|
||||||
const STORAGE_KEY = "led-controller-audio-restore";
|
const STORAGE_KEY = "led-controller-audio-restore";
|
||||||
|
const PHASE_MS_KEY = "led-controller-audio-beat-phase-ms";
|
||||||
const STORAGE_VERSION = 1;
|
const STORAGE_VERSION = 1;
|
||||||
|
|
||||||
function readRestorePrefs() {
|
function readRestorePrefs() {
|
||||||
@@ -48,6 +61,45 @@
|
|||||||
return document.getElementById(id);
|
return document.getElementById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param {Record<string, unknown>} status */
|
||||||
|
function updateBeatReadoutDisplays(status) {
|
||||||
|
const text = String((status && status.beat_readout) || "").trim();
|
||||||
|
for (const id of ["audio-top-beat-readout", "audio-modal-beat-readout"]) {
|
||||||
|
const n = el(id);
|
||||||
|
if (n) n.textContent = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On each new audio `beat_seq`, log server `beat_readout` once (deduped when poll repeats the
|
||||||
|
* same `beat_seq` + line).
|
||||||
|
* @param {Record<string, unknown>} status
|
||||||
|
*/
|
||||||
|
function logServerBeatConsoleOnPollEdge(status) {
|
||||||
|
const beatSeq = Number((status && status.beat_seq) || 0);
|
||||||
|
const line = String((status && status.beat_readout) || "").trim();
|
||||||
|
const key = `${beatSeq}\t${line}`;
|
||||||
|
if (key !== lastBeatConsoleKey) {
|
||||||
|
lastBeatConsoleKey = key;
|
||||||
|
if (!line) return;
|
||||||
|
const seq = /** @type {Record<string, unknown>|undefined} */ (status && status.sequence);
|
||||||
|
const seqBeats =
|
||||||
|
!!seq &&
|
||||||
|
!!seq.active &&
|
||||||
|
String(seq.advance_mode || "").toLowerCase() === "beats";
|
||||||
|
let out = line;
|
||||||
|
if (seqBeats) {
|
||||||
|
const nLanes = Number(seq && seq.num_lanes);
|
||||||
|
const lanesNote =
|
||||||
|
Number.isFinite(nLanes) && nLanes > 1
|
||||||
|
? `lane 1 of ${nLanes} (readout is for this lane only)`
|
||||||
|
: "lane 1";
|
||||||
|
out = `${line} — ${lanesNote}`;
|
||||||
|
}
|
||||||
|
console.log(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateBpmDisplay(bpm) {
|
function updateBpmDisplay(bpm) {
|
||||||
const node = el("audio-bpm-value");
|
const node = el("audio-bpm-value");
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
@@ -58,11 +110,45 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateBeatCounter(seq) {
|
/** Zone sequence playback (server); only when `active === true` is beat X/Y meaningful. */
|
||||||
const topNode = el("audio-top-beat-count");
|
function sequencePlaybackActiveFromStatus(status) {
|
||||||
if (!topNode) return;
|
const seq = /** @type {Record<string, unknown>|undefined} */ (
|
||||||
const n = Number(seq);
|
status && status.sequence
|
||||||
topNode.textContent = Number.isFinite(n) && n >= 0 ? `#${Math.floor(n)}` : "#0";
|
);
|
||||||
|
return !!(seq && seq.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build sequence beat fractions for debug logging (browser console only). */
|
||||||
|
function formatSequenceBeatFractionsForLog(status) {
|
||||||
|
const seq = /** @type {Record<string, unknown>|undefined} */ (status && status.sequence);
|
||||||
|
if (!seq || !seq.active) return null;
|
||||||
|
if (seq.advance_mode !== "beats") return null;
|
||||||
|
|
||||||
|
const laneBeatAt = Number(seq.lane0_beat_in_step);
|
||||||
|
const laneBeatsPerStep = Number(seq.lane0_beats_per_step);
|
||||||
|
if (
|
||||||
|
!Number.isFinite(laneBeatAt) ||
|
||||||
|
laneBeatAt <= 0 ||
|
||||||
|
!Number.isFinite(laneBeatsPerStep) ||
|
||||||
|
laneBeatsPerStep <= 0
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const presetFraction = `${Math.floor(laneBeatAt)}/${Math.floor(laneBeatsPerStep)}`;
|
||||||
|
|
||||||
|
const sequenceBeatAt = Number(seq.sequence_beat_at);
|
||||||
|
const sequenceBeatsPerPass = Number(seq.sequence_beats_per_pass);
|
||||||
|
if (
|
||||||
|
!Number.isFinite(sequenceBeatAt) ||
|
||||||
|
sequenceBeatAt <= 0 ||
|
||||||
|
!Number.isFinite(sequenceBeatsPerPass) ||
|
||||||
|
sequenceBeatsPerPass <= 0
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const sequenceFraction = `${Math.floor(sequenceBeatAt)}/${Math.floor(sequenceBeatsPerPass)}`;
|
||||||
|
|
||||||
|
return `${presetFraction} ${sequenceFraction}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateHitTypeDisplay(hitType, confidence) {
|
function updateHitTypeDisplay(hitType, confidence) {
|
||||||
@@ -91,15 +177,67 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearBeatPhaseTimers() {
|
||||||
|
pendingBeatPhaseTimers.forEach((t) => clearTimeout(t));
|
||||||
|
pendingBeatPhaseTimers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBeatPhaseDelayMs() {
|
||||||
|
const inp = el("audio-beat-phase-ms");
|
||||||
|
if (inp && String(inp.value).trim() !== "") {
|
||||||
|
const n = parseInt(String(inp.value).trim(), 10);
|
||||||
|
if (Number.isFinite(n)) return Math.min(500, Math.max(0, n));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const v = parseInt(localStorage.getItem(PHASE_MS_KEY) || "0", 10);
|
||||||
|
return Number.isFinite(v) ? Math.min(500, Math.max(0, v)) : 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistBeatPhaseMs() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PHASE_MS_KEY, String(getBeatPhaseDelayMs()));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("beat phase ms save failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleBeatPhaseFire(seq, delayMs) {
|
||||||
|
let tid = null;
|
||||||
|
const run = () => {
|
||||||
|
if (tid != null) pendingBeatPhaseTimers.delete(tid);
|
||||||
|
flashBeat();
|
||||||
|
try {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("ledControllerAudioBeat", { detail: { beatSeq: seq } }),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (delayMs <= 0) {
|
||||||
|
run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tid = setTimeout(run, delayMs);
|
||||||
|
pendingBeatPhaseTimers.add(tid);
|
||||||
|
}
|
||||||
|
|
||||||
/** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */
|
/** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */
|
||||||
async function stopAudioOnly() {
|
async function stopAudioOnly() {
|
||||||
setTopBpmVisible(false);
|
setTopBpmVisible(false);
|
||||||
|
clearBeatPhaseTimers();
|
||||||
if (pollTimer) {
|
if (pollTimer) {
|
||||||
clearInterval(pollTimer);
|
clearInterval(pollTimer);
|
||||||
pollTimer = null;
|
pollTimer = null;
|
||||||
}
|
}
|
||||||
lastBeatSeq = 0;
|
lastBeatSeq = 0;
|
||||||
updateBeatCounter(0);
|
prevZoneSequencePlaybackActive = false;
|
||||||
|
headerBeatStickyIdleAfterSeq = false;
|
||||||
|
lastBeatConsoleKey = "";
|
||||||
|
updateBeatReadoutDisplays({});
|
||||||
try {
|
try {
|
||||||
await fetch("/api/audio/stop", { method: "POST" });
|
await fetch("/api/audio/stop", { method: "POST" });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -115,7 +253,7 @@
|
|||||||
|
|
||||||
async function pollStatus() {
|
async function pollStatus() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/audio/status");
|
const res = await fetch("/api/audio/status", { cache: "no-store" });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const status = data?.status || {};
|
const status = data?.status || {};
|
||||||
if (status.error && String(status.error).trim()) {
|
if (status.error && String(status.error).trim()) {
|
||||||
@@ -123,6 +261,7 @@
|
|||||||
if (node) {
|
if (node) {
|
||||||
node.textContent = String(status.error).trim().slice(0, 120);
|
node.textContent = String(status.error).trim().slice(0, 120);
|
||||||
}
|
}
|
||||||
|
updateBeatReadoutDisplays({});
|
||||||
updateBpmDisplay(null);
|
updateBpmDisplay(null);
|
||||||
setTopBpmVisible(!!status.running);
|
setTopBpmVisible(!!status.running);
|
||||||
if (!status.running && pollTimer) {
|
if (!status.running && pollTimer) {
|
||||||
@@ -134,12 +273,46 @@
|
|||||||
setTopBpmVisible(!!status.running);
|
setTopBpmVisible(!!status.running);
|
||||||
updateBpmDisplay(status.bpm);
|
updateBpmDisplay(status.bpm);
|
||||||
updateHitTypeDisplay(status.beat_type, Number(status.beat_type_confidence));
|
updateHitTypeDisplay(status.beat_type, Number(status.beat_type_confidence));
|
||||||
const seq = Number(status.beat_seq || 0);
|
/*
|
||||||
updateBeatCounter(seq);
|
* `status.beat_seq` is cumulative since Audio Start — used only for flash / sticky idle
|
||||||
if (seq > lastBeatSeq) {
|
* after sequence ends. Preset and sequence loop counts come from `manual_beat_stride` /
|
||||||
lastBeatSeq = seq;
|
* `sequence` on each poll.
|
||||||
flashBeat();
|
*/
|
||||||
|
const beatSeq = Number(status.beat_seq || 0);
|
||||||
|
const zoneSeqActive = sequencePlaybackActiveFromStatus(status);
|
||||||
|
const endedSeq = prevZoneSequencePlaybackActive && !zoneSeqActive;
|
||||||
|
const startedSeq = !prevZoneSequencePlaybackActive && zoneSeqActive;
|
||||||
|
prevZoneSequencePlaybackActive = zoneSeqActive;
|
||||||
|
if (startedSeq) {
|
||||||
|
headerBeatStickyIdleAfterSeq = false;
|
||||||
|
lastLoggedSequenceBeatFractions = "";
|
||||||
}
|
}
|
||||||
|
if (endedSeq) {
|
||||||
|
headerBeatStickyIdleAfterSeq = true;
|
||||||
|
clearBeatPhaseTimers();
|
||||||
|
lastBeatSeq = beatSeq;
|
||||||
|
}
|
||||||
|
if (!zoneSeqActive && headerBeatStickyIdleAfterSeq) {
|
||||||
|
if (beatSeq > lastBeatSeq) {
|
||||||
|
lastBeatSeq = beatSeq;
|
||||||
|
logServerBeatConsoleOnPollEdge(status);
|
||||||
|
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs());
|
||||||
|
headerBeatStickyIdleAfterSeq = false;
|
||||||
|
}
|
||||||
|
} else if (beatSeq > lastBeatSeq) {
|
||||||
|
lastBeatSeq = beatSeq;
|
||||||
|
logServerBeatConsoleOnPollEdge(status);
|
||||||
|
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs());
|
||||||
|
}
|
||||||
|
const beatFractions = formatSequenceBeatFractionsForLog(status);
|
||||||
|
if (beatFractions) {
|
||||||
|
if (beatFractions !== lastLoggedSequenceBeatFractions) {
|
||||||
|
lastLoggedSequenceBeatFractions = beatFractions;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lastLoggedSequenceBeatFractions = "";
|
||||||
|
}
|
||||||
|
updateBeatReadoutDisplays(status);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("audio status poll failed", e);
|
console.warn("audio status poll failed", e);
|
||||||
}
|
}
|
||||||
@@ -164,7 +337,6 @@
|
|||||||
writeRestorePrefs(override, selected);
|
writeRestorePrefs(override, selected);
|
||||||
updateBpmDisplay(null);
|
updateBpmDisplay(null);
|
||||||
updateHitTypeDisplay("unknown", NaN);
|
updateHitTypeDisplay("unknown", NaN);
|
||||||
updateBeatCounter(0);
|
|
||||||
pollTimer = setInterval(pollStatus, 250);
|
pollTimer = setInterval(pollStatus, 250);
|
||||||
await pollStatus();
|
await pollStatus();
|
||||||
}
|
}
|
||||||
@@ -252,17 +424,30 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const phaseInp = el("audio-beat-phase-ms");
|
||||||
|
if (phaseInp) {
|
||||||
|
try {
|
||||||
|
const stored = parseInt(localStorage.getItem(PHASE_MS_KEY) || "0", 10);
|
||||||
|
if (Number.isFinite(stored)) {
|
||||||
|
phaseInp.value = String(Math.min(500, Math.max(0, stored)));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
phaseInp.addEventListener("change", () => persistBeatPhaseMs());
|
||||||
|
phaseInp.addEventListener("input", () => persistBeatPhaseMs());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resumePollingIfDetectorRunning() {
|
async function resumePollingIfDetectorRunning() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/audio/status");
|
const res = await fetch("/api/audio/status", { cache: "no-store" });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const status = data?.status || {};
|
const status = data?.status || {};
|
||||||
if (status.running && !pollTimer) {
|
if (status.running && !pollTimer) {
|
||||||
pollTimer = setInterval(pollStatus, 250);
|
pollTimer = setInterval(pollStatus, 250);
|
||||||
lastBeatSeq = Number(status.beat_seq || 0);
|
lastBeatSeq = Number(status.beat_seq || 0);
|
||||||
updateBeatCounter(lastBeatSeq);
|
prevZoneSequencePlaybackActive = sequencePlaybackActiveFromStatus(status);
|
||||||
await pollStatus();
|
await pollStatus();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -270,8 +455,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function restoreAudioIfNeeded() {
|
/**
|
||||||
if (pollTimer) return;
|
* Apply browser-stored device fields only (GET /devices list); does not start detection.
|
||||||
|
* Beat detector run/stop is server-owned (`db/audio_run.json` + explicit Start/Stop in UI).
|
||||||
|
*/
|
||||||
|
async function applySavedAudioDeviceFormOnly() {
|
||||||
const prefs = readRestorePrefs();
|
const prefs = readRestorePrefs();
|
||||||
if (!prefs) return;
|
if (!prefs) return;
|
||||||
const ov = el("audio-device-override");
|
const ov = el("audio-device-override");
|
||||||
@@ -280,20 +468,14 @@
|
|||||||
try {
|
try {
|
||||||
await refreshDevices();
|
await refreshDevices();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("audio restore refresh devices failed", e);
|
console.warn("audio device list refresh failed", e);
|
||||||
}
|
}
|
||||||
if (sel && prefs.select) sel.value = prefs.select;
|
if (sel && prefs.select) sel.value = prefs.select;
|
||||||
try {
|
|
||||||
await startAudio();
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("audio auto-restart failed", e);
|
|
||||||
clearRestorePrefs();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", async () => {
|
document.addEventListener("DOMContentLoaded", async () => {
|
||||||
bind();
|
bind();
|
||||||
await resumePollingIfDetectorRunning();
|
await resumePollingIfDetectorRunning();
|
||||||
await restoreAudioIfNeeded();
|
await applySavedAudioDeviceFormOnly();
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
25
src/static/dev-live-reload.js
Normal file
25
src/static/dev-live-reload.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/* Polls server build id; full reload when watchfiles restarts Python (new process = new id). */
|
||||||
|
(function () {
|
||||||
|
var prev = null;
|
||||||
|
function tick() {
|
||||||
|
fetch('/__dev/build-id', { cache: 'no-store', credentials: 'same-origin' })
|
||||||
|
.then(function (r) {
|
||||||
|
return r.ok ? r.text() : '';
|
||||||
|
})
|
||||||
|
.then(function (id) {
|
||||||
|
id = (id || '').trim();
|
||||||
|
if (!id) return;
|
||||||
|
if (prev === null) {
|
||||||
|
prev = id;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (id !== prev) {
|
||||||
|
prev = id;
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {});
|
||||||
|
}
|
||||||
|
setInterval(tick, 750);
|
||||||
|
tick();
|
||||||
|
})();
|
||||||
@@ -326,6 +326,16 @@ function renderGroupsList(groups) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const identifyBtn = document.createElement('button');
|
||||||
|
identifyBtn.className = 'btn btn-secondary btn-small';
|
||||||
|
identifyBtn.type = 'button';
|
||||||
|
identifyBtn.textContent = 'Identify';
|
||||||
|
identifyBtn.title =
|
||||||
|
'Identify all devices in this group at once (red blink at 10 Hz)';
|
||||||
|
identifyBtn.addEventListener('click', async () => {
|
||||||
|
await identifyGroupById(gid);
|
||||||
|
});
|
||||||
|
|
||||||
const delBtn = document.createElement('button');
|
const delBtn = document.createElement('button');
|
||||||
delBtn.className = 'btn btn-danger btn-small';
|
delBtn.className = 'btn btn-danger btn-small';
|
||||||
delBtn.textContent = 'Delete';
|
delBtn.textContent = 'Delete';
|
||||||
@@ -348,11 +358,40 @@ function renderGroupsList(groups) {
|
|||||||
row.appendChild(editBtn);
|
row.appendChild(editBtn);
|
||||||
row.appendChild(brightBtn);
|
row.appendChild(brightBtn);
|
||||||
row.appendChild(applyBtn);
|
row.appendChild(applyBtn);
|
||||||
|
row.appendChild(identifyBtn);
|
||||||
row.appendChild(delBtn);
|
row.appendChild(delBtn);
|
||||||
container.appendChild(row);
|
container.appendChild(row);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function identifyGroupById(gid) {
|
||||||
|
if (!gid) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/groups/${encodeURIComponent(gid)}/identify`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
alert(data.error || 'Identify failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = typeof data.sent === 'number' ? data.sent : 0;
|
||||||
|
const errs = Array.isArray(data.errors) ? data.errors : [];
|
||||||
|
const failed = errs.filter((e) => e && e.error).length;
|
||||||
|
let msg = n ? `Identify sent to ${n} device(s).` : 'No devices received identify.';
|
||||||
|
if (failed) {
|
||||||
|
msg += ` ${failed} failed — see console for details.`;
|
||||||
|
console.warn('Group identify errors', errs);
|
||||||
|
}
|
||||||
|
alert(msg);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert('Identify failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const groupsBtn = document.getElementById('groups-btn');
|
const groupsBtn = document.getElementById('groups-btn');
|
||||||
const groupsModal = document.getElementById('groups-modal');
|
const groupsModal = document.getElementById('groups-modal');
|
||||||
@@ -381,6 +420,16 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const editIdentifyBtn = document.getElementById('edit-group-identify-btn');
|
||||||
|
if (editIdentifyBtn) {
|
||||||
|
editIdentifyBtn.addEventListener('click', async () => {
|
||||||
|
const idInput = document.getElementById('edit-group-id');
|
||||||
|
const gid = idInput && idInput.value;
|
||||||
|
if (!gid) return;
|
||||||
|
await identifyGroupById(gid);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const createHandler = async () => {
|
const createHandler = async () => {
|
||||||
const name = newNameInput && newNameInput.value.trim();
|
const name = newNameInput && newNameInput.value.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
@@ -449,4 +498,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (editCloseBtn && editModal) {
|
if (editCloseBtn && editModal) {
|
||||||
editCloseBtn.addEventListener('click', () => editModal.classList.remove('active'));
|
editCloseBtn.addEventListener('click', () => editModal.classList.remove('active'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.openDeviceGroupsModal = async () => {
|
||||||
|
const gm = document.getElementById('groups-modal');
|
||||||
|
if (!gm) return;
|
||||||
|
gm.classList.add('active');
|
||||||
|
try {
|
||||||
|
await loadGroupsModal();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('openDeviceGroupsModal', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ const filterPresetsForCurrentProfile = async (presetsObj) => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
try {
|
||||||
|
window.filterPresetsForCurrentProfile = filterPresetsForCurrentProfile;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
const getCurrentProfileData = async () => {
|
const getCurrentProfileData = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -154,7 +157,44 @@ function tabDeviceNamesFromSection(section) {
|
|||||||
: [];
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postDriverSequence(sequence, targetMacs, delayS) {
|
/** Device names for ``presetId`` on the current zone tab (per-preset groups or zone default). */
|
||||||
|
async function deviceNamesForPresetOnCurrentZone(presetId) {
|
||||||
|
const section = document.querySelector('.presets-section[data-zone-id]');
|
||||||
|
const fallback = tabDeviceNamesFromSection(section);
|
||||||
|
if (!section || !presetId) return fallback;
|
||||||
|
const zm = window.zonesManager;
|
||||||
|
if (!zm || typeof zm.resolveDeviceNamesForZonePreset !== 'function') return fallback;
|
||||||
|
const zoneId = section.dataset.zoneId;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
|
||||||
|
if (!res.ok) return fallback;
|
||||||
|
const zd = await res.json();
|
||||||
|
const names = await zm.resolveDeviceNamesForZonePreset(zd, String(presetId));
|
||||||
|
return names.length ? names : fallback;
|
||||||
|
} catch (_) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPresetTargetGroupsLine(zoneDoc, presetId, groupsMap) {
|
||||||
|
const zm = window.zonesManager;
|
||||||
|
const gids =
|
||||||
|
zm && typeof zm.effectiveGroupIdsForZonePreset === 'function'
|
||||||
|
? zm.effectiveGroupIdsForZonePreset(zoneDoc, presetId)
|
||||||
|
: Array.isArray(zoneDoc && zoneDoc.group_ids)
|
||||||
|
? zoneDoc.group_ids.map((x) => String(x).trim()).filter((x) => x.length > 0)
|
||||||
|
: [];
|
||||||
|
const parts = (gids || [])
|
||||||
|
.map((id) => {
|
||||||
|
const g = groupsMap && groupsMap[id];
|
||||||
|
const gn = g && g.name ? String(g.name).trim() : '';
|
||||||
|
return gn;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
return parts.length ? parts.join(', ') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postDriverSequence(sequence, targetMacs, delayS, pushOptions) {
|
||||||
const body = {
|
const body = {
|
||||||
sequence,
|
sequence,
|
||||||
targets: Array.isArray(targetMacs) && targetMacs.length ? [...new Set(targetMacs)] : undefined,
|
targets: Array.isArray(targetMacs) && targetMacs.length ? [...new Set(targetMacs)] : undefined,
|
||||||
@@ -1169,7 +1209,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
// Create modal
|
// Create modal
|
||||||
const modal = document.createElement('div');
|
const modal = document.createElement('div');
|
||||||
modal.className = 'modal active';
|
modal.className = 'modal active modal-child-overlay';
|
||||||
modal.id = 'add-preset-to-zone-modal';
|
modal.id = 'add-preset-to-zone-modal';
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -1284,6 +1324,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const newGrid = arrayToGrid(flat, 3);
|
const newGrid = arrayToGrid(flat, 3);
|
||||||
tabData.presets = newGrid;
|
tabData.presets = newGrid;
|
||||||
tabData.presets_flat = flat;
|
tabData.presets_flat = flat;
|
||||||
|
if (!tabData.preset_group_ids || typeof tabData.preset_group_ids !== 'object') {
|
||||||
|
tabData.preset_group_ids = {};
|
||||||
|
}
|
||||||
|
|
||||||
// Update zone
|
// Update zone
|
||||||
const updateResponse = await fetch(`/zones/${zoneId}`, {
|
const updateResponse = await fetch(`/zones/${zoneId}`, {
|
||||||
@@ -1378,7 +1421,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const modal = document.createElement('div');
|
const modal = document.createElement('div');
|
||||||
modal.className = 'modal active';
|
modal.className = 'modal active modal-child-overlay';
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<h2>Pick Palette Color</h2>
|
<h2>Pick Palette Color</h2>
|
||||||
@@ -1449,12 +1492,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
alert('Preset name is required to send.');
|
alert('Preset name is required to send.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Send current editor values and then select on all devices in the current zone (if any)
|
// Send current editor values to zone devices (if any); never persist on device.
|
||||||
const section = document.querySelector('.presets-section[data-zone-id]');
|
|
||||||
const deviceNames = tabDeviceNamesFromSection(section);
|
|
||||||
// Work out the preset ID: for existing presets use currentEditId, otherwise fall back to name
|
|
||||||
const presetId = currentEditId || payload.name;
|
const presetId = currentEditId || payload.name;
|
||||||
// Try sends preset first, then select; never persist on device.
|
const deviceNames = await deviceNamesForPresetOnCurrentZone(presetId);
|
||||||
|
// Auto: load + immediate select. Manual: load only; first advance on the next audio beat.
|
||||||
await sendPresetViaEspNow(presetId, payload, deviceNames, false, false, '2');
|
await sendPresetViaEspNow(presetId, payload, deviceNames, false, false, '2');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1466,9 +1507,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
alert('Preset name is required.');
|
alert('Preset name is required.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const section = document.querySelector('.presets-section[data-zone-id]');
|
|
||||||
const deviceNames = tabDeviceNamesFromSection(section);
|
|
||||||
const presetId = currentEditId || payload.name;
|
const presetId = currentEditId || payload.name;
|
||||||
|
const deviceNames = await deviceNamesForPresetOnCurrentZone(presetId);
|
||||||
await sendPresetViaEspNow(presetId, payload, deviceNames, true, true, '1');
|
await sendPresetViaEspNow(presetId, payload, deviceNames, true, true, '1');
|
||||||
await updateTabDefaultPreset(presetId);
|
await updateTabDefaultPreset(presetId);
|
||||||
await sendDefaultPreset('1', deviceNames);
|
await sendDefaultPreset('1', deviceNames);
|
||||||
@@ -1503,9 +1543,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
throw new Error('Failed to save preset');
|
throw new Error('Failed to save preset');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same device targeting as Try: zone tab supplies names and selection without persistence.
|
// Same device targeting as Try: per-preset zone groups when in a zone tab.
|
||||||
const section = document.querySelector('.presets-section[data-zone-id]');
|
const presetIdForSend = currentEditId || payload.name;
|
||||||
const deviceNames = tabDeviceNamesFromSection(section);
|
const deviceNames = await deviceNamesForPresetOnCurrentZone(presetIdForSend);
|
||||||
|
|
||||||
// Use saved preset from server response for sending
|
// Use saved preset from server response for sending
|
||||||
const saved = await response.json().catch(() => null);
|
const saved = await response.json().catch(() => null);
|
||||||
@@ -1644,6 +1684,7 @@ const sendPresetViaEspNow = async (
|
|||||||
saveToDevice = true,
|
saveToDevice = true,
|
||||||
setDefault = false,
|
setDefault = false,
|
||||||
devicePresetId = null,
|
devicePresetId = null,
|
||||||
|
pushOptions = null,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const baseColors = Array.isArray(preset.colors) && preset.colors.length
|
const baseColors = Array.isArray(preset.colors) && preset.colors.length
|
||||||
@@ -1707,7 +1748,7 @@ const sendPresetViaEspNow = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await postDriverSequence(sequence, targetMacs, 0.05);
|
await postDriverSequence(sequence, targetMacs, 0.05, pushOptions);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to send preset to devices:', error);
|
console.error('Failed to send preset to devices:', error);
|
||||||
alert('Failed to send preset to devices.');
|
alert('Failed to send preset to devices.');
|
||||||
@@ -1776,6 +1817,48 @@ try {
|
|||||||
// window may not exist in some environments; ignore.
|
// window may not exist in some environments; ignore.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store selected preset(s) per zone (multi-select; merge send order = click order, last wins on device).
|
||||||
|
const zoneSelectedPresetIds = {};
|
||||||
|
const zonePresetSelectionOrder = {};
|
||||||
|
|
||||||
|
function ensureZonePresetSelection(zoneId) {
|
||||||
|
const z = String(zoneId);
|
||||||
|
if (!zoneSelectedPresetIds[z]) zoneSelectedPresetIds[z] = new Set();
|
||||||
|
if (!zonePresetSelectionOrder[z]) zonePresetSelectionOrder[z] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneZonePresetSelection(zoneId, validIdSet) {
|
||||||
|
const z = String(zoneId);
|
||||||
|
ensureZonePresetSelection(z);
|
||||||
|
const set = zoneSelectedPresetIds[z];
|
||||||
|
for (const id of [...set]) {
|
||||||
|
if (!validIdSet.has(String(id))) set.delete(id);
|
||||||
|
}
|
||||||
|
zonePresetSelectionOrder[z] = (zonePresetSelectionOrder[z] || []).filter((id) => set.has(String(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrderedZonePresetSelection(zoneId) {
|
||||||
|
const z = String(zoneId);
|
||||||
|
ensureZonePresetSelection(z);
|
||||||
|
const set = zoneSelectedPresetIds[z];
|
||||||
|
return (zonePresetSelectionOrder[z] || []).filter((id) => set.has(String(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMergedZonePresetSelection(zoneId, tabData, allPresets) {
|
||||||
|
const ids = getOrderedZonePresetSelection(zoneId);
|
||||||
|
if (!ids.length) return;
|
||||||
|
for (let i = 0; i < ids.length; i += 1) {
|
||||||
|
const pid = ids[i];
|
||||||
|
const preset = allPresets[pid];
|
||||||
|
if (!preset) continue;
|
||||||
|
const names =
|
||||||
|
window.zonesManager && typeof window.zonesManager.resolveDeviceNamesForZonePreset === 'function'
|
||||||
|
? await window.zonesManager.resolveDeviceNamesForZonePreset(tabData, pid)
|
||||||
|
: [];
|
||||||
|
await sendPresetViaEspNow(pid, preset, names, false, false, '2');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Store selected preset per zone
|
// Store selected preset per zone
|
||||||
const selectedPresets = {};
|
const selectedPresets = {};
|
||||||
// Store selected preset payload per zone for beat-trigger reliability.
|
// Store selected preset payload per zone for beat-trigger reliability.
|
||||||
@@ -1920,19 +2003,37 @@ const insertDraggingOntoTarget = (presetsList, dragging, dropTarget) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Function to render presets for a specific zone in 2D grid
|
// Function to render presets for a specific zone in 2D grid
|
||||||
const renderTabPresets = async (zoneId) => {
|
/**
|
||||||
|
* @param {string} zoneId
|
||||||
|
* @param {{ stopSequencePlayback?: boolean }} [options] - pass `{ stopSequencePlayback: true }` only when
|
||||||
|
* the UI action should stop server zone sequence playback (default: do not POST /sequences/stop).
|
||||||
|
*/
|
||||||
|
const renderTabPresets = async (zoneId, options = {}) => {
|
||||||
const presetsList = document.getElementById('presets-list-zone');
|
const presetsList = document.getElementById('presets-list-zone');
|
||||||
if (!presetsList) return;
|
if (!presetsList) return;
|
||||||
|
|
||||||
|
const stopSeq = options.stopSequencePlayback === true;
|
||||||
|
if (stopSeq && typeof window.stopZoneSequencePlayback === 'function') {
|
||||||
|
// Pass false: an earlier render's stop() can finish after this pass rebuilds the DOM and
|
||||||
|
// would otherwise clear .active from new sequence tiles (breaks edit/run selection).
|
||||||
|
await window.stopZoneSequencePlayback(false);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get zone data to see which presets are associated
|
const [tabResponse, groupsStripRes, presetsResponse] = await Promise.all([
|
||||||
const tabResponse = await fetch(`/zones/${zoneId}`, {
|
fetch(`/zones/${zoneId}`, {
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
});
|
}),
|
||||||
|
fetch('/groups', { headers: { Accept: 'application/json' } }),
|
||||||
|
fetch('/presets', {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
if (!tabResponse.ok) {
|
if (!tabResponse.ok) {
|
||||||
throw new Error('Failed to load zone');
|
throw new Error('Failed to load zone');
|
||||||
}
|
}
|
||||||
const tabData = await tabResponse.json();
|
const tabData = await tabResponse.json();
|
||||||
|
const groupsMapStrip = groupsStripRes.ok ? await groupsStripRes.json() : {};
|
||||||
|
|
||||||
// Get presets - support both 2D grid and flat array (for backward compatibility)
|
// Get presets - support both 2D grid and flat array (for backward compatibility)
|
||||||
let presetGrid = tabData.presets;
|
let presetGrid = tabData.presets;
|
||||||
@@ -1945,10 +2046,6 @@ const renderTabPresets = async (zoneId) => {
|
|||||||
presetGrid = arrayToGrid(presetGrid, 3);
|
presetGrid = arrayToGrid(presetGrid, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all presets
|
|
||||||
const presetsResponse = await fetch('/presets', {
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
});
|
|
||||||
if (!presetsResponse.ok) {
|
if (!presetsResponse.ok) {
|
||||||
throw new Error('Failed to load presets');
|
throw new Error('Failed to load presets');
|
||||||
}
|
}
|
||||||
@@ -2021,12 +2118,9 @@ const renderTabPresets = async (zoneId) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the currently selected preset for this zone
|
|
||||||
const selectedPresetId = selectedPresets[zoneId];
|
|
||||||
|
|
||||||
// Render presets in grid layout
|
|
||||||
// Flatten the grid and render all presets (grid CSS will handle layout)
|
|
||||||
const flatPresets = presetGrid.flat().filter(id => id);
|
const flatPresets = presetGrid.flat().filter(id => id);
|
||||||
|
const validIdSet = new Set(flatPresets.map((id) => String(id)));
|
||||||
|
pruneZonePresetSelection(zoneId, validIdSet);
|
||||||
|
|
||||||
if (flatPresets.length === 0) {
|
if (flatPresets.length === 0) {
|
||||||
// Show empty message if this zone has no presets
|
// Show empty message if this zone has no presets
|
||||||
@@ -2039,23 +2133,36 @@ const renderTabPresets = async (zoneId) => {
|
|||||||
flatPresets.forEach((presetId) => {
|
flatPresets.forEach((presetId) => {
|
||||||
const preset = allPresets[presetId];
|
const preset = allPresets[presetId];
|
||||||
if (preset) {
|
if (preset) {
|
||||||
const isSelected = presetId === selectedPresetId;
|
ensureZonePresetSelection(zoneId);
|
||||||
|
const isSelected = zoneSelectedPresetIds[String(zoneId)].has(String(presetId));
|
||||||
const displayPreset = {
|
const displayPreset = {
|
||||||
...preset,
|
...preset,
|
||||||
colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors),
|
colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors),
|
||||||
};
|
};
|
||||||
const wrapper = createPresetButton(presetId, displayPreset, zoneId, isSelected);
|
const wrapper = createPresetButton(
|
||||||
|
presetId,
|
||||||
|
displayPreset,
|
||||||
|
zoneId,
|
||||||
|
isSelected,
|
||||||
|
tabData,
|
||||||
|
groupsMapStrip,
|
||||||
|
allPresets,
|
||||||
|
);
|
||||||
presetsList.appendChild(wrapper);
|
presetsList.appendChild(wrapper);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof window.appendZoneSequenceTiles === 'function') {
|
||||||
|
await window.appendZoneSequenceTiles(zoneId, tabData, allPresets, paletteColors, presetsList);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to render zone presets:', error);
|
console.error('Failed to render zone presets:', error);
|
||||||
presetsList.innerHTML = '<p class="muted-text">Failed to load presets.</p>';
|
presetsList.innerHTML = '<p class="muted-text">Failed to load presets.</p>';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
|
const createPresetButton = (presetId, preset, zoneId, isSelected, tabData, groupsMap, allPresets) => {
|
||||||
const uiMode = getPresetUiMode();
|
const uiMode = getPresetUiMode();
|
||||||
|
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
@@ -2069,7 +2176,6 @@ 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) : [];
|
||||||
@@ -2093,6 +2199,14 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
|
|||||||
presetNameLabel.className = 'pattern-button-label';
|
presetNameLabel.className = 'pattern-button-label';
|
||||||
button.appendChild(presetNameLabel);
|
button.appendChild(presetNameLabel);
|
||||||
|
|
||||||
|
const groupsText = formatPresetTargetGroupsLine(tabData || {}, presetId, groupsMap || {});
|
||||||
|
if (groupsText) {
|
||||||
|
const groupsSpan = document.createElement('span');
|
||||||
|
groupsSpan.className = 'preset-tile-groups';
|
||||||
|
groupsSpan.textContent = groupsText;
|
||||||
|
button.appendChild(groupsSpan);
|
||||||
|
}
|
||||||
|
|
||||||
const bgSwatch = document.createElement('span');
|
const bgSwatch = document.createElement('span');
|
||||||
const bgColor = coercePresetBackground(preset);
|
const bgColor = coercePresetBackground(preset);
|
||||||
bgSwatch.title = `Background: ${bgColor}`;
|
bgSwatch.title = `Background: ${bgColor}`;
|
||||||
@@ -2111,7 +2225,7 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
|
|||||||
`;
|
`;
|
||||||
button.appendChild(bgSwatch);
|
button.appendChild(bgSwatch);
|
||||||
|
|
||||||
const isManualPreset = preset && typeof preset.auto === 'boolean' ? !preset.auto : false;
|
const isManualPreset = preset && !coercePresetAuto(preset);
|
||||||
if (isManualPreset) {
|
if (isManualPreset) {
|
||||||
const manualBadge = document.createElement('span');
|
const manualBadge = document.createElement('span');
|
||||||
manualBadge.textContent = '1';
|
manualBadge.textContent = '1';
|
||||||
@@ -2138,18 +2252,42 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
|
|||||||
|
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => {
|
||||||
if (isDraggingPreset) return;
|
if (isDraggingPreset) return;
|
||||||
const presetsListEl = document.getElementById('presets-list-zone');
|
console.info('Preset button pressed', { zoneId, presetId, name: (preset && preset.name) || presetId });
|
||||||
if (presetsListEl) {
|
if (typeof window.stopZoneSequencePlayback === 'function') {
|
||||||
presetsListEl.querySelectorAll('.pattern-button').forEach((btn) => btn.classList.remove('active'));
|
window.stopZoneSequencePlayback();
|
||||||
}
|
}
|
||||||
button.classList.add('active');
|
const presetsListEl = document.getElementById('presets-list-zone');
|
||||||
selectedPresets[zoneId] = presetId;
|
ensureZonePresetSelection(zoneId);
|
||||||
selectedPresetPayloads[zoneId] = preset;
|
const z = String(zoneId);
|
||||||
const section = row.closest('.presets-section');
|
const set = zoneSelectedPresetIds[z];
|
||||||
const deviceNames = tabDeviceNamesFromSection(section);
|
const order = zonePresetSelectionOrder[z];
|
||||||
sendPresetViaEspNow(presetId, preset, deviceNames, false, false, '2').catch((err) => {
|
const idStr = String(presetId);
|
||||||
console.error(err);
|
if (set.has(idStr)) {
|
||||||
|
set.delete(idStr);
|
||||||
|
zonePresetSelectionOrder[z] = order.filter((x) => String(x) !== idStr);
|
||||||
|
} else {
|
||||||
|
set.add(idStr);
|
||||||
|
order.push(idStr);
|
||||||
|
}
|
||||||
|
if (presetsListEl) {
|
||||||
|
presetsListEl.querySelectorAll('.preset-tile-row:not(.sequence-tile-row)').forEach((rw) => {
|
||||||
|
const pid = rw.dataset.presetId;
|
||||||
|
const btnEl = rw.querySelector('.preset-tile-main');
|
||||||
|
if (!btnEl || !pid) return;
|
||||||
|
if (set.has(String(pid))) btnEl.classList.add('active');
|
||||||
|
else btnEl.classList.remove('active');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
const orderList = getOrderedZonePresetSelection(zoneId);
|
||||||
|
if (orderList.length) {
|
||||||
|
const lastPid = orderList[orderList.length - 1];
|
||||||
|
selectedPresets[zoneId] = lastPid;
|
||||||
|
selectedPresetPayloads[zoneId] = (allPresets && allPresets[lastPid]) || preset;
|
||||||
|
} else {
|
||||||
|
delete selectedPresets[zoneId];
|
||||||
|
delete selectedPresetPayloads[zoneId];
|
||||||
|
}
|
||||||
|
void sendMergedZonePresetSelection(zoneId, tabData, allPresets);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (canDrag) {
|
if (canDrag) {
|
||||||
@@ -2173,7 +2311,9 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
row.appendChild(button);
|
const top = document.createElement('div');
|
||||||
|
top.className = 'preset-tile-row-top';
|
||||||
|
top.appendChild(button);
|
||||||
|
|
||||||
if (uiMode === 'edit') {
|
if (uiMode === 'edit') {
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
@@ -2192,9 +2332,11 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
actions.appendChild(editBtn);
|
actions.appendChild(editBtn);
|
||||||
row.appendChild(actions);
|
top.appendChild(actions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
row.appendChild(top);
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2279,6 +2421,12 @@ const removePresetFromTab = async (zoneId, presetId) => {
|
|||||||
tabData.presets = newGrid;
|
tabData.presets = newGrid;
|
||||||
tabData.presets_flat = flat;
|
tabData.presets_flat = flat;
|
||||||
|
|
||||||
|
if (tabData.preset_group_ids && typeof tabData.preset_group_ids === 'object') {
|
||||||
|
const pg = { ...tabData.preset_group_ids };
|
||||||
|
delete pg[String(presetId)];
|
||||||
|
tabData.preset_group_ids = pg;
|
||||||
|
}
|
||||||
|
|
||||||
const updateResponse = await fetch(`/zones/${zoneId}`, {
|
const updateResponse = await fetch(`/zones/${zoneId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -2297,6 +2445,10 @@ const removePresetFromTab = async (zoneId, presetId) => {
|
|||||||
try {
|
try {
|
||||||
window.removePresetFromTab = removePresetFromTab;
|
window.removePresetFromTab = removePresetFromTab;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
window.renderTabPresets = renderTabPresets;
|
||||||
|
window.getPresetUiMode = getPresetUiMode;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
// Listen for HTMX swaps to render presets
|
// Listen for HTMX swaps to render presets
|
||||||
document.body.addEventListener('htmx:afterSwap', (event) => {
|
document.body.addEventListener('htmx:afterSwap', (event) => {
|
||||||
@@ -2327,10 +2479,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
const mainMenu = document.getElementById('main-menu-dropdown');
|
const mainMenu = document.getElementById('main-menu-dropdown');
|
||||||
if (mainMenu) mainMenu.classList.remove('open');
|
if (mainMenu) mainMenu.classList.remove('open');
|
||||||
const leftPanel = document.querySelector('.presets-section[data-zone-id]');
|
// Preset strip re-renders from `zones.js` after `loadZones()` (no driver/playback side effects).
|
||||||
if (leftPanel) {
|
|
||||||
renderTabPresets(leftPanel.dataset.zoneId);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
1115
src/static/sequences.js
Normal file
1115
src/static/sequences.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -199,13 +199,31 @@ header h1 {
|
|||||||
|
|
||||||
.audio-top-indicator {
|
.audio-top-indicator {
|
||||||
display: none;
|
display: none;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 0.4rem;
|
align-items: stretch;
|
||||||
|
gap: 0.15rem;
|
||||||
padding: 0.25rem 0.55rem;
|
padding: 0.25rem 0.55rem;
|
||||||
border: 1px solid #4a4a4a;
|
border: 1px solid #4a4a4a;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background-color: #1a1a1a;
|
background-color: #1a1a1a;
|
||||||
min-width: 6.5rem;
|
min-width: 9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-top-indicator-main {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-top-indicator-extra {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
color: #9e9e9e;
|
||||||
|
line-height: 1.25;
|
||||||
|
text-align: right;
|
||||||
|
max-width: 16rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio-top-indicator.audio-running {
|
.audio-top-indicator.audio-running {
|
||||||
@@ -226,6 +244,19 @@ header h1 {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.audio-top-beat-readout {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
color: #b0bec5;
|
||||||
|
line-height: 1.25;
|
||||||
|
max-width: 12rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.audio-top-indicator-subvalue {
|
.audio-top-indicator-subvalue {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #9e9e9e;
|
color: #9e9e9e;
|
||||||
@@ -240,7 +271,9 @@ header h1 {
|
|||||||
|
|
||||||
.audio-top-indicator.flash .audio-top-indicator-value,
|
.audio-top-indicator.flash .audio-top-indicator-value,
|
||||||
.audio-top-indicator.flash .audio-top-indicator-label,
|
.audio-top-indicator.flash .audio-top-indicator-label,
|
||||||
.audio-top-indicator.flash .audio-top-indicator-subvalue {
|
.audio-top-indicator.flash .audio-top-indicator-subvalue,
|
||||||
|
.audio-top-indicator.flash .audio-top-indicator-extra,
|
||||||
|
.audio-top-indicator.flash .audio-top-beat-readout {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,7 +653,8 @@ body.preset-ui-run .edit-mode-only {
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||||
grid-auto-rows: 5rem;
|
/* min-content height prevents taller tiles (edit actions, wrapping) from overlapping the next row and stealing clicks */
|
||||||
|
grid-auto-rows: minmax(5rem, auto);
|
||||||
column-gap: 0.3rem;
|
column-gap: 0.3rem;
|
||||||
row-gap: 0.3rem;
|
row-gap: 0.3rem;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
@@ -784,6 +818,26 @@ body.preset-ui-run .edit-mode-only {
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.audio-bpm-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-bpm-row .audio-bpm-readout {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-modal-beat-readout {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 10rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.audio-hit-type-readout {
|
.audio-hit-type-readout {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -851,17 +905,43 @@ body.preset-ui-run .edit-mode-only {
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-tile-row--run .preset-tile-actions {
|
.preset-tile-row-top {
|
||||||
display: none;
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: stretch;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-tile-main {
|
.preset-tile-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 5rem;
|
height: 5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-tile-main .preset-tile-groups {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.15;
|
||||||
|
opacity: 0.88;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0 0.35rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-tile-row--run .preset-tile-actions {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Edit only beside the preset tile in edit mode. */
|
/* Edit only beside the preset tile in edit mode. */
|
||||||
@@ -1030,6 +1110,46 @@ body.preset-ui-run .edit-mode-only {
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Stack sequence modals below groups / preset editor so in-modal actions stay visible */
|
||||||
|
#sequence-editor-modal.active,
|
||||||
|
#sequences-modal.active {
|
||||||
|
z-index: 1040;
|
||||||
|
}
|
||||||
|
#groups-modal.active,
|
||||||
|
#edit-group-modal.active,
|
||||||
|
#presets-modal.active {
|
||||||
|
z-index: 1050;
|
||||||
|
}
|
||||||
|
#preset-editor-modal.active {
|
||||||
|
z-index: 1060;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Child / overlay modals: must paint above preset editor (1060) and list modals (1050). */
|
||||||
|
#color-palette-modal.active,
|
||||||
|
#pattern-editor-modal.active,
|
||||||
|
#edit-device-modal.active,
|
||||||
|
#edit-zone-modal.active {
|
||||||
|
z-index: 1070;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Patterns library (often used next to presets); below preset editor, above sequences. */
|
||||||
|
#patterns-modal.active {
|
||||||
|
z-index: 1055;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header / global dialogs */
|
||||||
|
#help-modal.active,
|
||||||
|
#audio-modal.active,
|
||||||
|
#settings-modal.active,
|
||||||
|
#led-tool-modal.active {
|
||||||
|
z-index: 1080;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* JS-appended overlays (e.g. preset “From Palette”, add-preset-to-zone) — must sit above #preset-editor-modal */
|
||||||
|
.modal.modal-child-overlay.active {
|
||||||
|
z-index: 1080;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background-color: #2e2e2e;
|
background-color: #2e2e2e;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
// Zone management JavaScript
|
// Zone management JavaScript
|
||||||
let currentZoneId = null;
|
let currentZoneId = null;
|
||||||
let brightnessSendTimeout = null;
|
let brightnessSendTimeout = null;
|
||||||
|
/**
|
||||||
|
* When true, the next `loadZoneContent` skips `sendZoneBrightness` (run/edit toggle: same zone, UI only).
|
||||||
|
*/
|
||||||
|
let suppressZoneContentDriverSideEffects = false;
|
||||||
|
/** First successful `loadZoneContent` after open: skip hardware brightness push (read-only hydration). */
|
||||||
|
let isFirstZoneContentHydration = true;
|
||||||
|
|
||||||
function clamp255(n) {
|
function clamp255(n) {
|
||||||
const v = parseInt(n, 10);
|
const v = parseInt(n, 10);
|
||||||
@@ -202,8 +208,160 @@ async function computeZoneTargets(zone) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeDeviceMac(raw) {
|
||||||
|
return String(raw || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/:/g, "")
|
||||||
|
.replace(/-/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flat preset ids on a zone document (grid or flat). */
|
||||||
|
function tabPresetIdsInZoneDoc(zoneDoc) {
|
||||||
|
let ids = [];
|
||||||
|
if (Array.isArray(zoneDoc && zoneDoc.presets_flat)) {
|
||||||
|
ids = zoneDoc.presets_flat.slice();
|
||||||
|
} else if (Array.isArray(zoneDoc && zoneDoc.presets)) {
|
||||||
|
if (zoneDoc.presets.length && typeof zoneDoc.presets[0] === "string") {
|
||||||
|
ids = zoneDoc.presets.slice();
|
||||||
|
} else if (zoneDoc.presets.length && Array.isArray(zoneDoc.presets[0])) {
|
||||||
|
ids = zoneDoc.presets.flat();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (ids || []).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Group ids for a preset: explicit ``preset_group_ids[presetId]`` when non-empty, else zone ``group_ids``. */
|
||||||
|
function effectiveGroupIdsForZonePreset(zoneDoc, presetId) {
|
||||||
|
const pid = String(presetId);
|
||||||
|
const raw = zoneDoc && zoneDoc.preset_group_ids && zoneDoc.preset_group_ids[pid];
|
||||||
|
if (Array.isArray(raw) && raw.length > 0) {
|
||||||
|
return raw.map((x) => String(x).trim()).filter((x) => x.length > 0);
|
||||||
|
}
|
||||||
|
return Array.isArray(zoneDoc && zoneDoc.group_ids)
|
||||||
|
? zoneDoc.group_ids.map((x) => String(x).trim()).filter((x) => x.length > 0)
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve device names + MACs from a list of group ids (same rules as zone group expansion). */
|
||||||
|
async function resolveTargetsFromGroupIds(groupIds) {
|
||||||
|
const dm = await fetchDevicesMap();
|
||||||
|
const gids = Array.isArray(groupIds)
|
||||||
|
? groupIds.map((x) => String(x).trim()).filter((x) => x.length > 0)
|
||||||
|
: [];
|
||||||
|
if (!gids.length) {
|
||||||
|
return { names: [], macs: [] };
|
||||||
|
}
|
||||||
|
const gm = await fetchGroupsMap();
|
||||||
|
const seen = new Set();
|
||||||
|
const names = [];
|
||||||
|
const macs = [];
|
||||||
|
for (const gid of gids) {
|
||||||
|
const g = gm[gid];
|
||||||
|
if (!g || !Array.isArray(g.devices)) continue;
|
||||||
|
for (const raw of g.devices) {
|
||||||
|
const m = normalizeDeviceMac(raw);
|
||||||
|
if (m.length !== 12) continue;
|
||||||
|
if (seen.has(m)) continue;
|
||||||
|
seen.add(m);
|
||||||
|
const d = dm[m];
|
||||||
|
const n = d && String((d.name || "").trim()) ? String(d.name).trim() : m;
|
||||||
|
names.push(n);
|
||||||
|
macs.push(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { names, macs };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Device names for one zone preset slot (effective groups, or whole zone by name when no groups). */
|
||||||
|
async function resolveDeviceNamesForZonePreset(zoneDoc, presetId) {
|
||||||
|
const gids = effectiveGroupIdsForZonePreset(zoneDoc, presetId);
|
||||||
|
if (gids.length) {
|
||||||
|
const t = await resolveTargetsFromGroupIds(gids);
|
||||||
|
if (t.names.length) return t.names;
|
||||||
|
}
|
||||||
|
const zt = await computeZoneTargets(zoneDoc);
|
||||||
|
return Array.isArray(zt.names) ? zt.names.slice() : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Union of all devices targeted by any preset on the zone (for tab strip + sequence scope). */
|
||||||
|
async function computeZonePresetUnionTargets(zoneDoc) {
|
||||||
|
const ids = tabPresetIdsInZoneDoc(zoneDoc);
|
||||||
|
if (!ids.length) {
|
||||||
|
return await computeZoneTargets(zoneDoc);
|
||||||
|
}
|
||||||
|
const seen = new Set();
|
||||||
|
const names = [];
|
||||||
|
const macs = [];
|
||||||
|
for (const pid of ids) {
|
||||||
|
const gids = effectiveGroupIdsForZonePreset(zoneDoc, pid);
|
||||||
|
let t;
|
||||||
|
if (gids.length) {
|
||||||
|
t = await resolveTargetsFromGroupIds(gids);
|
||||||
|
} else {
|
||||||
|
t = await computeZoneTargets(zoneDoc);
|
||||||
|
}
|
||||||
|
const tn = Array.isArray(t.names) ? t.names : [];
|
||||||
|
const tm = Array.isArray(t.macs) ? t.macs : [];
|
||||||
|
for (let i = 0; i < tm.length; i++) {
|
||||||
|
const m = normalizeDeviceMac(tm[i]);
|
||||||
|
if (m.length !== 12 || seen.has(m)) continue;
|
||||||
|
seen.add(m);
|
||||||
|
macs.push(tm[i]);
|
||||||
|
names.push(tn[i] || m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!names.length) {
|
||||||
|
return await computeZoneTargets(zoneDoc);
|
||||||
|
}
|
||||||
|
return { names, macs };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device names for one sequence step. Empty stepGroupIds => all zone names.
|
||||||
|
* Otherwise: devices in those groups intersected with the zone's target MACs.
|
||||||
|
*/
|
||||||
|
async function resolveSequenceStepDeviceNames(zone, stepGroupIds) {
|
||||||
|
const zoneT = await computeZonePresetUnionTargets(zone);
|
||||||
|
const names = Array.isArray(zoneT.names) ? zoneT.names : [];
|
||||||
|
const macs = Array.isArray(zoneT.macs) ? zoneT.macs : [];
|
||||||
|
const gids = Array.isArray(stepGroupIds)
|
||||||
|
? stepGroupIds.map((x) => String(x).trim()).filter((x) => x.length > 0)
|
||||||
|
: [];
|
||||||
|
if (!gids.length) {
|
||||||
|
return names.slice();
|
||||||
|
}
|
||||||
|
const zoneMacSet = new Set(
|
||||||
|
macs.map((m) => normalizeDeviceMac(m)).filter((m) => m.length === 12),
|
||||||
|
);
|
||||||
|
const zoneNameByMac = new Map();
|
||||||
|
for (let i = 0; i < macs.length; i++) {
|
||||||
|
const m = normalizeDeviceMac(macs[i]);
|
||||||
|
if (m.length === 12 && !zoneNameByMac.has(m)) {
|
||||||
|
zoneNameByMac.set(m, names[i] || m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const gm = await fetchGroupsMap();
|
||||||
|
const stepMacs = new Set();
|
||||||
|
for (const gid of gids) {
|
||||||
|
const g = gm[gid];
|
||||||
|
if (!g || !Array.isArray(g.devices)) continue;
|
||||||
|
for (const raw of g.devices) {
|
||||||
|
const m = normalizeDeviceMac(raw);
|
||||||
|
if (m.length !== 12 || !zoneMacSet.has(m)) continue;
|
||||||
|
stepMacs.add(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
for (const m of stepMacs) {
|
||||||
|
const n = zoneNameByMac.get(m);
|
||||||
|
if (n) out.push(n);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveZoneDeviceMacsFromZoneData(zone) {
|
async function resolveZoneDeviceMacsFromZoneData(zone) {
|
||||||
const t = await computeZoneTargets(zone);
|
const t = await computeZonePresetUnionTargets(zone);
|
||||||
return t.macs;
|
return t.macs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -710,7 +868,7 @@ async function loadZoneContent(zoneId) {
|
|||||||
|
|
||||||
// Render zone content (presets section)
|
// Render zone content (presets section)
|
||||||
const tabName = zone.name || `Zone ${zoneId}`;
|
const tabName = zone.name || `Zone ${zoneId}`;
|
||||||
const targets = await computeZoneTargets(zone);
|
const targets = await computeZonePresetUnionTargets(zone);
|
||||||
const namesJsonAttr = encodeURIComponent(JSON.stringify(targets.names));
|
const namesJsonAttr = encodeURIComponent(JSON.stringify(targets.names));
|
||||||
const macsJsonAttr = encodeURIComponent(JSON.stringify(targets.macs));
|
const macsJsonAttr = encodeURIComponent(JSON.stringify(targets.macs));
|
||||||
const legacyOk =
|
const legacyOk =
|
||||||
@@ -735,8 +893,14 @@ async function loadZoneContent(zoneId) {
|
|||||||
? Math.max(0, Math.min(255, Math.round(zoneBrightness)))
|
? Math.max(0, Math.min(255, Math.round(zoneBrightness)))
|
||||||
: 255;
|
: 255;
|
||||||
applyBrightnessSliders(normalizedBrightness);
|
applyBrightnessSliders(normalizedBrightness);
|
||||||
// Apply this zone's saved brightness when switching zones.
|
const initialHydration = isFirstZoneContentHydration;
|
||||||
|
if (isFirstZoneContentHydration) {
|
||||||
|
isFirstZoneContentHydration = false;
|
||||||
|
}
|
||||||
|
if (!suppressZoneContentDriverSideEffects && !initialHydration) {
|
||||||
|
// Apply this zone's saved brightness when switching zones (not initial page load or UI-only strip refresh).
|
||||||
sendZoneBrightness(zoneId, normalizedBrightness);
|
sendZoneBrightness(zoneId, normalizedBrightness);
|
||||||
|
}
|
||||||
|
|
||||||
// Trigger presets loading if the function exists
|
// Trigger presets loading if the function exists
|
||||||
if (typeof renderTabPresets === 'function') {
|
if (typeof renderTabPresets === 'function') {
|
||||||
@@ -857,17 +1021,46 @@ async function sendProfilePresets() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function tabPresetIdsInOrder(tabData) {
|
function tabPresetIdsInOrder(tabData) {
|
||||||
let ids = [];
|
return tabPresetIdsInZoneDoc(tabData);
|
||||||
if (Array.isArray(tabData.presets_flat)) {
|
|
||||||
ids = tabData.presets_flat.slice();
|
|
||||||
} else if (Array.isArray(tabData.presets)) {
|
|
||||||
if (tabData.presets.length && typeof tabData.presets[0] === "string") {
|
|
||||||
ids = tabData.presets.slice();
|
|
||||||
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
|
|
||||||
ids = tabData.presets.flat();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveZonePresetGroupOverride(zoneId, presetId, useDefault, selectedGids) {
|
||||||
|
const tabRes = await fetch(`/zones/${zoneId}`, { headers: { Accept: "application/json" } });
|
||||||
|
if (!tabRes.ok) {
|
||||||
|
alert("Failed to load zone.");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
return (ids || []).filter(Boolean);
|
const tabData = await tabRes.json();
|
||||||
|
const pg =
|
||||||
|
tabData.preset_group_ids && typeof tabData.preset_group_ids === "object"
|
||||||
|
? { ...tabData.preset_group_ids }
|
||||||
|
: {};
|
||||||
|
if (useDefault) {
|
||||||
|
delete pg[String(presetId)];
|
||||||
|
} else {
|
||||||
|
const gids = Array.isArray(selectedGids)
|
||||||
|
? selectedGids.map((x) => String(x).trim()).filter((x) => x.length > 0)
|
||||||
|
: [];
|
||||||
|
if (!gids.length) {
|
||||||
|
alert("Select at least one group, or use zone default.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
pg[String(presetId)] = gids;
|
||||||
|
}
|
||||||
|
tabData.preset_group_ids = pg;
|
||||||
|
const up = await fetch(`/zones/${zoneId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(tabData),
|
||||||
|
});
|
||||||
|
if (!up.ok) {
|
||||||
|
alert("Failed to save preset groups.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (typeof window.renderTabPresets === "function") {
|
||||||
|
await window.renderTabPresets(zoneId);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Presets already on the zone (remove) and presets available to add (select).
|
// Presets already on the zone (remove) and presets available to add (select).
|
||||||
@@ -891,7 +1084,10 @@ async function refreshEditTabPresetsUi(zoneId) {
|
|||||||
const inTabIds = tabPresetIdsInOrder(tabData);
|
const inTabIds = tabPresetIdsInOrder(tabData);
|
||||||
const inTabSet = new Set(inTabIds.map((id) => String(id)));
|
const inTabSet = new Set(inTabIds.map((id) => String(id)));
|
||||||
|
|
||||||
const presetsRes = await fetch("/presets", { headers: { Accept: "application/json" } });
|
const [presetsRes, groupsMapEdit] = await Promise.all([
|
||||||
|
fetch("/presets", { headers: { Accept: "application/json" } }),
|
||||||
|
fetchGroupsMap(),
|
||||||
|
]);
|
||||||
const allPresets = presetsRes.ok ? await presetsRes.json() : {};
|
const allPresets = presetsRes.ok ? await presetsRes.json() : {};
|
||||||
|
|
||||||
const makeRow = () => {
|
const makeRow = () => {
|
||||||
@@ -911,8 +1107,12 @@ async function refreshEditTabPresetsUi(zoneId) {
|
|||||||
for (const presetId of inTabIds) {
|
for (const presetId of inTabIds) {
|
||||||
const preset = allPresets[presetId] || {};
|
const preset = allPresets[presetId] || {};
|
||||||
const name = preset.name || presetId;
|
const name = preset.name || presetId;
|
||||||
const row = makeRow();
|
const block = document.createElement("div");
|
||||||
|
block.style.cssText =
|
||||||
|
"border:1px solid rgba(255,255,255,0.12);border-radius:6px;padding:0.5rem 0.65rem;margin-bottom:0.65rem;";
|
||||||
|
const top = makeRow();
|
||||||
const label = document.createElement("span");
|
const label = document.createElement("span");
|
||||||
|
label.style.fontWeight = "600";
|
||||||
label.textContent = name;
|
label.textContent = name;
|
||||||
const removeBtn = document.createElement("button");
|
const removeBtn = document.createElement("button");
|
||||||
removeBtn.type = "button";
|
removeBtn.type = "button";
|
||||||
@@ -924,9 +1124,90 @@ async function refreshEditTabPresetsUi(zoneId) {
|
|||||||
await window.removePresetFromTab(zoneId, presetId);
|
await window.removePresetFromTab(zoneId, presetId);
|
||||||
await refreshEditTabPresetsUi(zoneId);
|
await refreshEditTabPresetsUi(zoneId);
|
||||||
});
|
});
|
||||||
row.appendChild(label);
|
top.appendChild(label);
|
||||||
row.appendChild(removeBtn);
|
top.appendChild(removeBtn);
|
||||||
currentEl.appendChild(row);
|
block.appendChild(top);
|
||||||
|
|
||||||
|
const hasExplicit =
|
||||||
|
tabData.preset_group_ids &&
|
||||||
|
typeof tabData.preset_group_ids === "object" &&
|
||||||
|
Array.isArray(tabData.preset_group_ids[presetId]) &&
|
||||||
|
tabData.preset_group_ids[presetId].length > 0;
|
||||||
|
const zoneG = Array.isArray(tabData.group_ids)
|
||||||
|
? tabData.group_ids.map((x) => String(x).trim()).filter((x) => x.length > 0)
|
||||||
|
: [];
|
||||||
|
const initialChecked = new Set(
|
||||||
|
hasExplicit
|
||||||
|
? tabData.preset_group_ids[presetId].map((x) => String(x).trim())
|
||||||
|
: zoneG,
|
||||||
|
);
|
||||||
|
|
||||||
|
const useRow = document.createElement("div");
|
||||||
|
useRow.className = "profiles-row";
|
||||||
|
useRow.style.marginTop = "0.35rem";
|
||||||
|
const useDefCb = document.createElement("input");
|
||||||
|
useDefCb.type = "checkbox";
|
||||||
|
useDefCb.id = `edit-zone-preset-use-def-${presetId}`;
|
||||||
|
useDefCb.checked = !hasExplicit;
|
||||||
|
const useDefLbl = document.createElement("label");
|
||||||
|
useDefLbl.htmlFor = useDefCb.id;
|
||||||
|
useDefLbl.style.marginLeft = "0.25rem";
|
||||||
|
useDefLbl.style.fontSize = "0.9em";
|
||||||
|
useDefLbl.textContent = "Use zone default groups";
|
||||||
|
useRow.appendChild(useDefCb);
|
||||||
|
useRow.appendChild(useDefLbl);
|
||||||
|
block.appendChild(useRow);
|
||||||
|
|
||||||
|
const boxHost = document.createElement("div");
|
||||||
|
boxHost.style.cssText = `display:${hasExplicit ? "flex" : "none"};flex-wrap:wrap;gap:0.4rem;margin-top:0.35rem;align-items:center;`;
|
||||||
|
const entries = Object.keys(groupsMapEdit || {})
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
.map((gid) => {
|
||||||
|
const g = groupsMapEdit[gid];
|
||||||
|
const gn = g && g.name ? String(g.name).trim() : "";
|
||||||
|
return { gid, label: gn ? `${gn} (${gid})` : `Group ${gid}` };
|
||||||
|
});
|
||||||
|
entries.forEach(({ gid, label: glabel }) => {
|
||||||
|
const id = `zpg-${zoneId}-${presetId}-${gid}`;
|
||||||
|
const lbl = document.createElement("label");
|
||||||
|
lbl.style.cssText = "display:inline-flex;align-items:center;gap:0.2rem;font-size:0.85em;";
|
||||||
|
const cb = document.createElement("input");
|
||||||
|
cb.type = "checkbox";
|
||||||
|
cb.className = "edit-zone-preset-group-cb";
|
||||||
|
cb.value = gid;
|
||||||
|
cb.id = id;
|
||||||
|
cb.checked = initialChecked.has(String(gid));
|
||||||
|
const sp = document.createElement("span");
|
||||||
|
sp.textContent = glabel;
|
||||||
|
lbl.appendChild(cb);
|
||||||
|
lbl.appendChild(sp);
|
||||||
|
boxHost.appendChild(lbl);
|
||||||
|
});
|
||||||
|
block.appendChild(boxHost);
|
||||||
|
|
||||||
|
useDefCb.addEventListener("change", () => {
|
||||||
|
boxHost.style.display = useDefCb.checked ? "none" : "flex";
|
||||||
|
});
|
||||||
|
|
||||||
|
const applyBtn = document.createElement("button");
|
||||||
|
applyBtn.type = "button";
|
||||||
|
applyBtn.className = "btn btn-primary btn-small";
|
||||||
|
applyBtn.style.marginTop = "0.4rem";
|
||||||
|
applyBtn.textContent = "Apply preset groups";
|
||||||
|
applyBtn.addEventListener("click", async () => {
|
||||||
|
const useD = !!useDefCb.checked;
|
||||||
|
const sel = [];
|
||||||
|
if (!useD) {
|
||||||
|
boxHost.querySelectorAll(".edit-zone-preset-group-cb:checked").forEach((c) => {
|
||||||
|
if (c.value) sel.push(String(c.value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const ok = await saveZonePresetGroupOverride(zoneId, presetId, useD, sel);
|
||||||
|
if (ok) await refreshEditTabPresetsUi(zoneId);
|
||||||
|
});
|
||||||
|
block.appendChild(applyBtn);
|
||||||
|
|
||||||
|
currentEl.appendChild(block);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1016,6 +1297,9 @@ async function openEditZoneModal(zoneId, zone) {
|
|||||||
|
|
||||||
if (modal) modal.classList.add("active");
|
if (modal) modal.classList.add("active");
|
||||||
await refreshEditTabPresetsUi(zoneId);
|
await refreshEditTabPresetsUi(zoneId);
|
||||||
|
if (typeof window.refreshEditTabSequencesUi === "function") {
|
||||||
|
await window.refreshEditTabSequencesUi(zoneId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update an existing zone
|
// Update an existing zone
|
||||||
@@ -1220,10 +1504,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately.
|
// When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately.
|
||||||
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
|
suppressZoneContentDriverSideEffects = true;
|
||||||
|
try {
|
||||||
await loadZones();
|
await loadZones();
|
||||||
if (zonesModal && zonesModal.classList.contains("active")) {
|
if (zonesModal && zonesModal.classList.contains("active")) {
|
||||||
await loadZonesModal();
|
await loadZonesModal();
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
suppressZoneContentDriverSideEffects = false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1240,6 +1529,11 @@ window.zonesManager = {
|
|||||||
resolveZoneDeviceMacsFromZoneData,
|
resolveZoneDeviceMacsFromZoneData,
|
||||||
resolveTabDeviceMacs: resolveZoneDeviceMacs,
|
resolveTabDeviceMacs: resolveZoneDeviceMacs,
|
||||||
getCurrentZoneId: () => currentZoneId,
|
getCurrentZoneId: () => currentZoneId,
|
||||||
|
computeZoneTargets,
|
||||||
|
computeZonePresetUnionTargets,
|
||||||
|
effectiveGroupIdsForZonePreset,
|
||||||
|
resolveDeviceNamesForZonePreset,
|
||||||
|
resolveSequenceStepDeviceNames,
|
||||||
};
|
};
|
||||||
window.tabsManager = window.zonesManager;
|
window.tabsManager = window.zonesManager;
|
||||||
window.tabsManager.getCurrentTabId = () => currentZoneId;
|
window.tabsManager.getCurrentTabId = () => currentZoneId;
|
||||||
|
|||||||
@@ -16,9 +16,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="header-end">
|
<div class="header-end">
|
||||||
<div id="audio-top-indicator" class="audio-top-indicator" title="Live audio BPM">
|
<div id="audio-top-indicator" class="audio-top-indicator" title="Live audio BPM">
|
||||||
|
<div class="audio-top-indicator-main">
|
||||||
<span class="audio-top-indicator-label">BPM</span>
|
<span class="audio-top-indicator-label">BPM</span>
|
||||||
<span id="audio-top-bpm-value" class="audio-top-indicator-value">--</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>
|
<span id="audio-top-beat-readout" class="audio-top-beat-readout" aria-live="polite"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<div class="header-brightness-control">
|
<div class="header-brightness-control">
|
||||||
@@ -30,6 +32,7 @@
|
|||||||
<button class="btn btn-secondary edit-mode-only" id="groups-btn">Groups</button>
|
<button class="btn btn-secondary edit-mode-only" id="groups-btn">Groups</button>
|
||||||
<button class="btn btn-secondary edit-mode-only" id="zones-btn">Zones</button>
|
<button class="btn btn-secondary edit-mode-only" id="zones-btn">Zones</button>
|
||||||
<button class="btn btn-secondary edit-mode-only" id="presets-btn">Presets</button>
|
<button class="btn btn-secondary edit-mode-only" id="presets-btn">Presets</button>
|
||||||
|
<button class="btn btn-secondary edit-mode-only" id="sequences-btn">Sequences</button>
|
||||||
<button class="btn btn-secondary edit-mode-only" id="patterns-btn">Patterns</button>
|
<button class="btn btn-secondary edit-mode-only" id="patterns-btn">Patterns</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="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>
|
||||||
@@ -51,6 +54,7 @@
|
|||||||
<button type="button" class="edit-mode-only" data-target="groups-btn">Groups</button>
|
<button type="button" class="edit-mode-only" data-target="groups-btn">Groups</button>
|
||||||
<button type="button" class="edit-mode-only" data-target="zones-btn">Tabs</button>
|
<button type="button" class="edit-mode-only" data-target="zones-btn">Tabs</button>
|
||||||
<button type="button" class="edit-mode-only" data-target="presets-btn">Presets</button>
|
<button type="button" class="edit-mode-only" data-target="presets-btn">Presets</button>
|
||||||
|
<button type="button" class="edit-mode-only" data-target="sequences-btn">Sequences</button>
|
||||||
<button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button>
|
<button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</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="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>
|
||||||
@@ -104,6 +108,10 @@
|
|||||||
<div id="edit-zone-presets-current" class="profiles-list edit-zone-presets-scroll"></div>
|
<div id="edit-zone-presets-current" class="profiles-list edit-zone-presets-scroll"></div>
|
||||||
<label class="zone-presets-section-label">Add presets to this zone</label>
|
<label class="zone-presets-section-label">Add presets to this zone</label>
|
||||||
<div id="edit-zone-presets-list" class="profiles-list edit-zone-presets-scroll"></div>
|
<div id="edit-zone-presets-list" class="profiles-list edit-zone-presets-scroll"></div>
|
||||||
|
<label class="zone-presets-section-label">Sequences on this zone</label>
|
||||||
|
<div id="edit-zone-sequences-current" class="profiles-list edit-zone-presets-scroll"></div>
|
||||||
|
<label class="zone-presets-section-label">Add a sequence to this zone</label>
|
||||||
|
<div id="edit-zone-sequences-list" class="profiles-list edit-zone-presets-scroll"></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -169,6 +177,10 @@
|
|||||||
<input type="text" id="edit-group-name" required autocomplete="off">
|
<input type="text" id="edit-group-name" required autocomplete="off">
|
||||||
<label class="zone-devices-label">Devices in this group</label>
|
<label class="zone-devices-label">Devices in this group</label>
|
||||||
<div id="edit-group-devices-editor" class="zone-devices-editor"></div>
|
<div id="edit-group-devices-editor" class="zone-devices-editor"></div>
|
||||||
|
<div class="profiles-actions" style="margin-top: 0.5rem;">
|
||||||
|
<button type="button" class="btn btn-secondary btn-small" id="edit-group-identify-btn">Identify devices in group</button>
|
||||||
|
</div>
|
||||||
|
<p class="muted-text" style="margin-top:0.25rem;">Runs identify on every driver in the group at the same time so they blink together.</p>
|
||||||
<label for="edit-group-output-brightness" style="margin-top:0.75rem;display:block;">Group output brightness (0–255)</label>
|
<label for="edit-group-output-brightness" style="margin-top:0.75rem;display:block;">Group output brightness (0–255)</label>
|
||||||
<div class="profiles-actions" style="align-items: center; gap: 0.75rem;">
|
<div class="profiles-actions" style="align-items: center; gap: 0.75rem;">
|
||||||
<input type="range" id="edit-group-output-brightness" min="0" max="255" value="255" style="flex:1;">
|
<input type="range" id="edit-group-output-brightness" min="0" max="255" value="255" style="flex:1;">
|
||||||
@@ -280,6 +292,62 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Sequences Modal -->
|
||||||
|
<div id="sequences-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Sequences</h2>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn btn-primary" id="sequence-add-btn">Add</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="sequences-open-presets-btn">Presets</button>
|
||||||
|
</div>
|
||||||
|
<div id="sequences-list" class="profiles-list"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" id="sequences-close-btn">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sequence Editor Modal -->
|
||||||
|
<div id="sequence-editor-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Sequence</h2>
|
||||||
|
<div class="preset-editor-field">
|
||||||
|
<label for="sequence-editor-name">Name</label>
|
||||||
|
<input type="text" id="sequence-editor-name" placeholder="Sequence name" style="width:100%;max-width:24rem;">
|
||||||
|
</div>
|
||||||
|
<div class="preset-editor-field">
|
||||||
|
<label for="sequence-editor-advance-mode">Advance</label>
|
||||||
|
<select id="sequence-editor-advance-mode" style="max-width:16rem;">
|
||||||
|
<option value="time">Time (ms between steps)</option>
|
||||||
|
<option value="beats">Audio beats (requires Audio detector)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="preset-editor-field" id="sequence-editor-duration-wrap">
|
||||||
|
<label for="sequence-editor-duration">Step duration (ms), all lanes together</label>
|
||||||
|
<div style="display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap;">
|
||||||
|
<input type="number" id="sequence-editor-duration" min="200" max="600000" value="3000" style="width:8rem;">
|
||||||
|
<span id="sequence-editor-time-bpm-hint" class="muted-text" style="font-size:0.9em;"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="preset-editor-field" id="sequence-editor-transition-wrap">
|
||||||
|
<label for="sequence-editor-transition">Pause before next step (ms)</label>
|
||||||
|
<input type="number" id="sequence-editor-transition" min="0" max="60000" value="500" style="width:8rem;">
|
||||||
|
</div>
|
||||||
|
<div id="sequence-editor-beats-panel" style="display:none;margin:0 0 0.75rem 0;">
|
||||||
|
<p id="sequence-editor-bpm-live" class="muted-text" style="font-size:0.85em;margin:0;">—</p>
|
||||||
|
</div>
|
||||||
|
<div id="sequence-editor-lanes"></div>
|
||||||
|
<div class="modal-actions" style="margin-top:0.75rem;">
|
||||||
|
<button type="button" class="btn btn-secondary btn-small" id="sequence-editor-add-lane-btn">Add lane</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions preset-editor-modal-actions">
|
||||||
|
<button type="button" class="btn btn-danger" id="sequence-editor-delete-btn">Delete</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="sequence-editor-save-btn">Save</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="sequence-editor-close-btn">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Preset Editor Modal -->
|
<!-- Preset Editor Modal -->
|
||||||
<div id="preset-editor-modal" class="modal">
|
<div id="preset-editor-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -321,7 +389,7 @@
|
|||||||
<p id="preset-manual-mode-hint" class="muted-text" style="display: none; margin-top: 0.35rem; font-size: 0.85em;"></p>
|
<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;">
|
<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>
|
<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">
|
<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" autocomplete="off">
|
||||||
<span class="muted-text" style="font-size: 0.85em;">beats (this app only)</span>
|
<span class="muted-text" style="font-size: 0.85em;">beats (this app only)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -533,7 +601,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Current BPM</label>
|
<label>Current BPM</label>
|
||||||
|
<div class="audio-bpm-row">
|
||||||
<div id="audio-bpm-value" class="audio-bpm-readout">--</div>
|
<div id="audio-bpm-value" class="audio-bpm-readout">--</div>
|
||||||
|
<div id="audio-modal-beat-readout" class="audio-modal-beat-readout muted-text" aria-live="polite"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Detected hit type</label>
|
<label>Detected hit type</label>
|
||||||
@@ -543,6 +614,11 @@
|
|||||||
<label>Flash on beat</label>
|
<label>Flash on beat</label>
|
||||||
<div id="audio-beat-flash" class="audio-beat-flash" aria-hidden="true"></div>
|
<div id="audio-beat-flash" class="audio-beat-flash" aria-hidden="true"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="audio-beat-phase-ms">Beat phase shift (ms)</label>
|
||||||
|
<input type="number" id="audio-beat-phase-ms" min="0" max="500" step="5" value="0" style="width:6rem;">
|
||||||
|
<small class="muted-text">Delays beat flashes and sequenced beats so they line up with what you hear (saved in this browser).</small>
|
||||||
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" class="btn btn-primary" id="audio-start-btn">Start</button>
|
<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-stop-btn">Stop</button>
|
||||||
@@ -709,6 +785,7 @@
|
|||||||
<script src="/static/zone_palette.js"></script>
|
<script src="/static/zone_palette.js"></script>
|
||||||
<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/sequences.js"></script>
|
||||||
<script src="/static/devices.js"></script>
|
<script src="/static/devices.js"></script>
|
||||||
<script src="/static/audio.js"></script>
|
<script src="/static/audio.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -161,11 +161,11 @@ class AudioBeatDetector:
|
|||||||
self._status["beat_type_confidence"] = float(beat_type_confidence or 0.0)
|
self._status["beat_type_confidence"] = float(beat_type_confidence or 0.0)
|
||||||
self._status["beat_seq"] = int(self._status.get("beat_seq", 0)) + 1
|
self._status["beat_seq"] = int(self._status.get("beat_seq", 0)) + 1
|
||||||
try:
|
try:
|
||||||
from util.beat_driver_route import notify_beat_detected
|
from util import sequence_playback as seq_pb
|
||||||
|
|
||||||
notify_beat_detected()
|
seq_pb.push_thread_beat()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[audio] beat driver route: {e}")
|
print(f"[audio] sequence beat queue: {e}")
|
||||||
|
|
||||||
def _run_loop(self, device):
|
def _run_loop(self, device):
|
||||||
try:
|
try:
|
||||||
|
|||||||
52
src/util/audio_run_persist.py
Normal file
52
src/util/audio_run_persist.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""Persist whether the audio beat detector should be running (survives process restarts)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def _db_path() -> str:
|
||||||
|
base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
return os.path.join(base, "db", "audio_run.json")
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_audio_device(device: Any) -> Optional[Any]:
|
||||||
|
"""Match ``/api/audio/start`` body coercion (None = host default input)."""
|
||||||
|
if device in ("", None):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(device)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
def read_audio_run_state() -> Dict[str, Any]:
|
||||||
|
path = _db_path()
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
raw = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError, TypeError):
|
||||||
|
return {"enabled": False, "device": None}
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return {"enabled": False, "device": None}
|
||||||
|
enabled = bool(raw.get("enabled"))
|
||||||
|
dev = raw.get("device", None)
|
||||||
|
return {"enabled": enabled, "device": dev}
|
||||||
|
|
||||||
|
|
||||||
|
def write_audio_run_state(*, enabled: bool, device: Any = None) -> None:
|
||||||
|
"""Write run intent. When ``enabled`` is false, keep ``device`` from the previous file for next start."""
|
||||||
|
path = _db_path()
|
||||||
|
prev = read_audio_run_state()
|
||||||
|
if enabled:
|
||||||
|
data = {"enabled": True, "device": device}
|
||||||
|
else:
|
||||||
|
data = {"enabled": False, "device": prev.get("device")}
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
except OSError as e:
|
||||||
|
print(f"[audio_run_persist] save failed: {e!r}")
|
||||||
@@ -6,9 +6,13 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
from typing import Any, Dict, List, Optional, Set
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||||
|
|
||||||
_route_lock = threading.Lock()
|
_route_lock = threading.Lock()
|
||||||
|
# Per-lane manual routes: key ``-1`` = legacy single-route (preset push / UI); keys ``0..n`` =
|
||||||
|
# zone sequence lanes so every manual lane gets its own stride counter and wire.
|
||||||
|
_lane_manual: Dict[int, Dict[str, Any]] = {}
|
||||||
|
# Public mirror for ``get_beat_route`` / header UI (derived from lane table).
|
||||||
_beat_route: Dict[str, Any] = {
|
_beat_route: Dict[str, Any] = {
|
||||||
"enabled": False,
|
"enabled": False,
|
||||||
"device_names": [],
|
"device_names": [],
|
||||||
@@ -18,6 +22,7 @@ _beat_route: Dict[str, Any] = {
|
|||||||
"manual_beat_n": 1,
|
"manual_beat_n": 1,
|
||||||
}
|
}
|
||||||
_beat_counter: int = 0
|
_beat_counter: int = 0
|
||||||
|
_preset_session_beats: int = 0
|
||||||
_main_loop: Optional[asyncio.AbstractEventLoop] = None
|
_main_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
|
|
||||||
|
|
||||||
@@ -26,16 +31,65 @@ def set_beat_route_main_loop(loop: asyncio.AbstractEventLoop) -> None:
|
|||||||
_main_loop = loop
|
_main_loop = loop
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_display_lane_key() -> Optional[int]:
|
||||||
|
"""Lane key used for header stride readout (prefer sequence lane 0)."""
|
||||||
|
if not _lane_manual:
|
||||||
|
return None
|
||||||
|
if 0 in _lane_manual:
|
||||||
|
return 0
|
||||||
|
seq_keys = [k for k in _lane_manual if isinstance(k, int) and k >= 0]
|
||||||
|
if seq_keys:
|
||||||
|
return min(seq_keys)
|
||||||
|
if -1 in _lane_manual:
|
||||||
|
return -1
|
||||||
|
return min(_lane_manual.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_public_beat_route_from_lane_table() -> None:
|
||||||
|
"""Mirror ``_lane_manual`` into legacy ``_beat_route`` shape for API consumers."""
|
||||||
|
global _beat_route, _beat_counter
|
||||||
|
pick = _pick_display_lane_key()
|
||||||
|
if pick is None:
|
||||||
|
_beat_route = {
|
||||||
|
"enabled": False,
|
||||||
|
"device_names": [],
|
||||||
|
"wire_preset_id": "2",
|
||||||
|
"is_manual": False,
|
||||||
|
"pattern": "",
|
||||||
|
"manual_beat_n": 1,
|
||||||
|
}
|
||||||
|
_beat_counter = 0
|
||||||
|
return
|
||||||
|
e = _lane_manual[pick]
|
||||||
|
_beat_route = {
|
||||||
|
"enabled": True,
|
||||||
|
"device_names": list(e.get("device_names") or []),
|
||||||
|
"wire_preset_id": str(e.get("wire_preset_id") or "2"),
|
||||||
|
"is_manual": True,
|
||||||
|
"pattern": str(e.get("pattern") or ""),
|
||||||
|
"manual_beat_n": int(e.get("manual_beat_n") or 1),
|
||||||
|
}
|
||||||
|
_beat_counter = int(e.get("beat_counter", 0))
|
||||||
|
|
||||||
|
|
||||||
def update_beat_route(payload: Dict[str, Any]) -> None:
|
def update_beat_route(payload: Dict[str, Any]) -> None:
|
||||||
"""Internal: set or clear routing from explicit fields (tests / future APIs)."""
|
"""Internal: set or clear routing from explicit fields (tests / future APIs)."""
|
||||||
global _beat_route, _beat_counter
|
global _lane_manual, _beat_route, _beat_counter, _preset_session_beats
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
return
|
return
|
||||||
with _route_lock:
|
with _route_lock:
|
||||||
if payload.get("enabled") is False:
|
if payload.get("enabled") is False:
|
||||||
_beat_route = {**_beat_route, "enabled": False}
|
_lane_manual.clear()
|
||||||
|
_beat_route = {
|
||||||
|
**_beat_route,
|
||||||
|
"enabled": False,
|
||||||
|
"is_manual": False,
|
||||||
|
"device_names": [],
|
||||||
|
}
|
||||||
_beat_counter = 0
|
_beat_counter = 0
|
||||||
|
_preset_session_beats = 0
|
||||||
return
|
return
|
||||||
|
old = dict(_beat_route)
|
||||||
names = payload.get("device_names")
|
names = payload.get("device_names")
|
||||||
if not isinstance(names, list):
|
if not isinstance(names, list):
|
||||||
names = []
|
names = []
|
||||||
@@ -44,15 +98,20 @@ def update_beat_route(payload: Dict[str, Any]) -> None:
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
n_raw = 1
|
n_raw = 1
|
||||||
manual_n = max(1, min(64, n_raw))
|
manual_n = max(1, min(64, n_raw))
|
||||||
_beat_route = {
|
new_wire = str(payload.get("wire_preset_id") or "2")
|
||||||
"enabled": bool(payload.get("enabled", False)),
|
old_wire = str(old.get("wire_preset_id") or "2")
|
||||||
"device_names": [str(n).strip() for n in names if str(n).strip()],
|
if not old.get("enabled") or old_wire != new_wire:
|
||||||
"wire_preset_id": str(payload.get("wire_preset_id") or "2"),
|
_preset_session_beats = 0
|
||||||
"is_manual": bool(payload.get("is_manual", False)),
|
clean_names = [str(n).strip() for n in names if str(n).strip()]
|
||||||
|
_lane_manual.clear()
|
||||||
|
_lane_manual[-1] = {
|
||||||
|
"device_names": clean_names,
|
||||||
|
"wire_preset_id": new_wire,
|
||||||
"pattern": str(payload.get("pattern") or "").strip(),
|
"pattern": str(payload.get("pattern") or "").strip(),
|
||||||
"manual_beat_n": manual_n,
|
"manual_beat_n": manual_n,
|
||||||
|
"beat_counter": 0,
|
||||||
}
|
}
|
||||||
_beat_counter = 0
|
_sync_public_beat_route_from_lane_table()
|
||||||
|
|
||||||
|
|
||||||
def get_beat_route() -> Dict[str, Any]:
|
def get_beat_route() -> Dict[str, Any]:
|
||||||
@@ -60,6 +119,44 @@ def get_beat_route() -> Dict[str, Any]:
|
|||||||
return dict(_beat_route)
|
return dict(_beat_route)
|
||||||
|
|
||||||
|
|
||||||
|
def manual_beat_stride_status() -> Dict[str, Any]:
|
||||||
|
"""Audio-beat stride for a live manual preset (not sequence). For UI readout with BPM.
|
||||||
|
|
||||||
|
``beat_in_stride`` is always in ``1..stride_n`` when ``active`` (1-based within the stride).
|
||||||
|
With multiple sequence manual lanes, reflects lane 0 (or the smallest lane index).
|
||||||
|
"""
|
||||||
|
with _route_lock:
|
||||||
|
pick = _pick_display_lane_key()
|
||||||
|
if pick is None or pick not in _lane_manual:
|
||||||
|
wid = str(_beat_route.get("wire_preset_id") or "").strip()
|
||||||
|
return {"active": False, "preset_session_beats": 0, "wire_preset_id": wid}
|
||||||
|
e = _lane_manual[pick]
|
||||||
|
c = int(e.get("beat_counter", 0))
|
||||||
|
psb = int(_preset_session_beats)
|
||||||
|
wid = str(e.get("wire_preset_id") or "").strip()
|
||||||
|
try:
|
||||||
|
n = int(e.get("manual_beat_n") or 1)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
n = 1
|
||||||
|
n = max(1, min(64, n))
|
||||||
|
if c <= 0:
|
||||||
|
return {
|
||||||
|
"active": True,
|
||||||
|
"beat_in_stride": 1,
|
||||||
|
"stride_n": n,
|
||||||
|
"preset_session_beats": psb,
|
||||||
|
"wire_preset_id": wid,
|
||||||
|
}
|
||||||
|
beat_in_stride = ((c - 1) % n) + 1
|
||||||
|
return {
|
||||||
|
"active": True,
|
||||||
|
"beat_in_stride": beat_in_stride,
|
||||||
|
"stride_n": n,
|
||||||
|
"preset_session_beats": psb,
|
||||||
|
"wire_preset_id": wid,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _coerce_manual_beat_n(body: Any) -> int:
|
def _coerce_manual_beat_n(body: Any) -> int:
|
||||||
"""Beats between audio-triggered selects (led-controller only); default 1 = every beat."""
|
"""Beats between audio-triggered selects (led-controller only); default 1 = every beat."""
|
||||||
if not isinstance(body, dict):
|
if not isinstance(body, dict):
|
||||||
@@ -137,33 +234,99 @@ def _apply_manual_beat_route(
|
|||||||
preset_body: Any,
|
preset_body: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Enable audio→driver routing for one manual preset, or disable if invalid."""
|
"""Enable audio→driver routing for one manual preset, or disable if invalid."""
|
||||||
|
global _lane_manual
|
||||||
if not device_names:
|
if not device_names:
|
||||||
update_beat_route({"enabled": False})
|
with _route_lock:
|
||||||
|
_lane_manual.clear()
|
||||||
|
_sync_public_beat_route_from_lane_table()
|
||||||
return
|
return
|
||||||
if not isinstance(preset_body, dict):
|
if not isinstance(preset_body, dict):
|
||||||
update_beat_route({"enabled": False})
|
with _route_lock:
|
||||||
|
_lane_manual.clear()
|
||||||
|
_sync_public_beat_route_from_lane_table()
|
||||||
return
|
return
|
||||||
if _coerce_auto_from_body(preset_body):
|
if _coerce_auto_from_body(preset_body):
|
||||||
update_beat_route({"enabled": False})
|
with _route_lock:
|
||||||
|
_lane_manual.clear()
|
||||||
|
_sync_public_beat_route_from_lane_table()
|
||||||
return
|
return
|
||||||
pattern = str(preset_body.get("pattern") or preset_body.get("p") or "").strip()
|
pattern = str(preset_body.get("pattern") or preset_body.get("p") or "").strip()
|
||||||
if pattern and not _pattern_supports_manual(pattern):
|
if pattern and not _pattern_supports_manual(pattern):
|
||||||
update_beat_route({"enabled": False})
|
with _route_lock:
|
||||||
|
_lane_manual.clear()
|
||||||
|
_sync_public_beat_route_from_lane_table()
|
||||||
return
|
return
|
||||||
update_beat_route(
|
names = [str(n).strip() for n in device_names if str(n).strip()]
|
||||||
{
|
with _route_lock:
|
||||||
"enabled": True,
|
_lane_manual.clear()
|
||||||
"device_names": device_names,
|
_lane_manual[-1] = {
|
||||||
"wire_preset_id": wire_preset_id,
|
"device_names": names,
|
||||||
"is_manual": True,
|
"wire_preset_id": str(wire_preset_id).strip(),
|
||||||
"pattern": pattern,
|
"pattern": pattern,
|
||||||
"manual_beat_n": _coerce_manual_beat_n(preset_body),
|
"manual_beat_n": _coerce_manual_beat_n(preset_body),
|
||||||
|
"beat_counter": 0,
|
||||||
}
|
}
|
||||||
)
|
_sync_public_beat_route_from_lane_table()
|
||||||
|
|
||||||
|
|
||||||
|
def set_sequence_manual_lane_route(
|
||||||
|
lane_index: int,
|
||||||
|
device_names: List[str],
|
||||||
|
wire_preset_id: str,
|
||||||
|
preset_body: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Register or update one sequence lane's manual beat route (parallel lanes, independent strides)."""
|
||||||
|
global _lane_manual
|
||||||
|
names = [str(n).strip() for n in (device_names or []) if str(n).strip()]
|
||||||
|
if not names or not isinstance(preset_body, dict) or _coerce_auto_from_body(preset_body):
|
||||||
|
with _route_lock:
|
||||||
|
if lane_index in _lane_manual:
|
||||||
|
del _lane_manual[lane_index]
|
||||||
|
_sync_public_beat_route_from_lane_table()
|
||||||
|
return
|
||||||
|
pattern = str(preset_body.get("pattern") or preset_body.get("p") or "").strip()
|
||||||
|
if pattern and not _pattern_supports_manual(pattern):
|
||||||
|
with _route_lock:
|
||||||
|
if lane_index in _lane_manual:
|
||||||
|
del _lane_manual[lane_index]
|
||||||
|
_sync_public_beat_route_from_lane_table()
|
||||||
|
return
|
||||||
|
mn = _coerce_manual_beat_n(preset_body)
|
||||||
|
wid = str(wire_preset_id).strip()
|
||||||
|
with _route_lock:
|
||||||
|
old = _lane_manual.get(lane_index)
|
||||||
|
bc = 0
|
||||||
|
if (
|
||||||
|
old
|
||||||
|
and str(old.get("wire_preset_id") or "") == wid
|
||||||
|
and int(old.get("manual_beat_n") or 1) == mn
|
||||||
|
and set(old.get("device_names") or []) == set(names)
|
||||||
|
):
|
||||||
|
bc = int(old.get("beat_counter", 0))
|
||||||
|
_lane_manual[lane_index] = {
|
||||||
|
"device_names": names,
|
||||||
|
"wire_preset_id": wid,
|
||||||
|
"pattern": pattern,
|
||||||
|
"manual_beat_n": mn,
|
||||||
|
"beat_counter": bc,
|
||||||
|
}
|
||||||
|
_sync_public_beat_route_from_lane_table()
|
||||||
|
|
||||||
|
|
||||||
|
def clear_sequence_manual_lane_route(lane_index: int) -> None:
|
||||||
|
"""Remove beat routing for one sequence lane (e.g. step switched to auto)."""
|
||||||
|
global _lane_manual
|
||||||
|
with _route_lock:
|
||||||
|
if lane_index in _lane_manual:
|
||||||
|
del _lane_manual[lane_index]
|
||||||
|
_sync_public_beat_route_from_lane_table()
|
||||||
|
|
||||||
|
|
||||||
def sync_beat_route_from_push_sequence(
|
def sync_beat_route_from_push_sequence(
|
||||||
sequence: List[Any], target_macs: Optional[List[str]] = None
|
sequence: List[Any],
|
||||||
|
target_macs: Optional[List[str]] = None,
|
||||||
|
*,
|
||||||
|
preserve_manual_beat_route_on_auto_select: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Update beat routing from a ``/presets/push`` body ``sequence`` (list of v1 dicts).
|
Update beat routing from a ``/presets/push`` body ``sequence`` (list of v1 dicts).
|
||||||
@@ -173,6 +336,10 @@ def sync_beat_route_from_push_sequence(
|
|||||||
Without ``select`` (e.g. manual preset loaded without immediate select): if ``target_macs``
|
Without ``select`` (e.g. manual preset loaded without immediate select): if ``target_macs``
|
||||||
is set and the merged ``presets`` contain exactly one manual preset, enable routing using
|
is set and the merged ``presets`` contain exactly one manual preset, enable routing using
|
||||||
registry names for those MACs so the first advance is on the next audio beat.
|
registry names for those MACs so the first advance is on the next audio beat.
|
||||||
|
|
||||||
|
When ``preserve_manual_beat_route_on_auto_select`` is true (zone sequence playback), an
|
||||||
|
auto preset in ``select`` does not clear manual routing — other lanes may still need
|
||||||
|
``notify_beat_detected`` for manual patterns in parallel.
|
||||||
"""
|
"""
|
||||||
merged_presets: Dict[str, Any] = {}
|
merged_presets: Dict[str, Any] = {}
|
||||||
last_select: Optional[Dict[str, Any]] = None
|
last_select: Optional[Dict[str, Any]] = None
|
||||||
@@ -214,6 +381,13 @@ def sync_beat_route_from_push_sequence(
|
|||||||
if str(k).strip() == wire_preset_id:
|
if str(k).strip() == wire_preset_id:
|
||||||
preset_body = v
|
preset_body = v
|
||||||
break
|
break
|
||||||
|
if preset_body is None:
|
||||||
|
update_beat_route({"enabled": False})
|
||||||
|
return
|
||||||
|
if _coerce_auto_from_body(preset_body):
|
||||||
|
if not preserve_manual_beat_route_on_auto_select:
|
||||||
|
update_beat_route({"enabled": False})
|
||||||
|
return
|
||||||
_apply_manual_beat_route(device_names, wire_preset_id, preset_body)
|
_apply_manual_beat_route(device_names, wire_preset_id, preset_body)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -247,25 +421,30 @@ def _pattern_supports_manual(pattern_key: str) -> bool:
|
|||||||
|
|
||||||
def remap_beat_route_device_name(old_name: str, new_name: str) -> None:
|
def remap_beat_route_device_name(old_name: str, new_name: str) -> None:
|
||||||
"""Update cached audio-beat target names after a device registry rename."""
|
"""Update cached audio-beat target names after a device registry rename."""
|
||||||
global _beat_route
|
global _lane_manual
|
||||||
o = str(old_name or "").strip()
|
o = str(old_name or "").strip()
|
||||||
n = str(new_name or "").strip()
|
n = str(new_name or "").strip()
|
||||||
if not o or not n or o == n:
|
if not o or not n or o == n:
|
||||||
return
|
return
|
||||||
with _route_lock:
|
with _route_lock:
|
||||||
if not _beat_route.get("enabled"):
|
any_changed = False
|
||||||
return
|
for e in _lane_manual.values():
|
||||||
names = _beat_route.get("device_names") or []
|
names = e.get("device_names") or []
|
||||||
|
if not isinstance(names, list):
|
||||||
|
continue
|
||||||
new_list: List[str] = []
|
new_list: List[str] = []
|
||||||
changed = False
|
row_changed = False
|
||||||
for item in names:
|
for item in names:
|
||||||
if str(item).strip() == o:
|
if str(item).strip() == o:
|
||||||
new_list.append(n)
|
new_list.append(n)
|
||||||
changed = True
|
row_changed = True
|
||||||
else:
|
else:
|
||||||
new_list.append(str(item))
|
new_list.append(str(item))
|
||||||
if changed:
|
if row_changed:
|
||||||
_beat_route = {**_beat_route, "device_names": new_list}
|
e["device_names"] = new_list
|
||||||
|
any_changed = True
|
||||||
|
if any_changed:
|
||||||
|
_sync_public_beat_route_from_lane_table()
|
||||||
|
|
||||||
|
|
||||||
async def _deliver_select(device_names: List[str], wire_preset_id: str) -> None:
|
async def _deliver_select(device_names: List[str], wire_preset_id: str) -> None:
|
||||||
@@ -302,35 +481,45 @@ async def _deliver_select(device_names: List[str], wire_preset_id: str) -> None:
|
|||||||
print(f"[beat-route] deliver failed: {e}")
|
print(f"[beat-route] deliver failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _deliver_select_batch(pairs: List[Tuple[List[str], str]]) -> None:
|
||||||
|
for names, pid in pairs:
|
||||||
|
await _deliver_select(names, pid)
|
||||||
|
|
||||||
|
|
||||||
def notify_beat_detected() -> None:
|
def notify_beat_detected() -> None:
|
||||||
"""Invoked from the audio thread when a beat is detected."""
|
"""Invoked from the audio thread when a beat is detected."""
|
||||||
global _beat_counter
|
global _preset_session_beats
|
||||||
|
work: List[Tuple[List[str], str]] = []
|
||||||
with _route_lock:
|
with _route_lock:
|
||||||
r = dict(_beat_route)
|
if not _lane_manual:
|
||||||
if not r.get("enabled"):
|
|
||||||
return
|
return
|
||||||
if not r.get("is_manual"):
|
work = []
|
||||||
return
|
for key in sorted(_lane_manual.keys()):
|
||||||
pattern = r.get("pattern") or ""
|
e = _lane_manual[key]
|
||||||
|
names = e.get("device_names") or []
|
||||||
|
if not isinstance(names, list) or not names:
|
||||||
|
continue
|
||||||
|
pattern = str(e.get("pattern") or "")
|
||||||
if pattern and not _pattern_supports_manual(pattern):
|
if pattern and not _pattern_supports_manual(pattern):
|
||||||
return
|
continue
|
||||||
names = r.get("device_names") or []
|
|
||||||
if not names:
|
|
||||||
return
|
|
||||||
try:
|
try:
|
||||||
n = int(r.get("manual_beat_n") or 1)
|
n = int(e.get("manual_beat_n") or 1)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
n = 1
|
n = 1
|
||||||
n = max(1, min(64, n))
|
n = max(1, min(64, n))
|
||||||
_beat_counter += 1
|
e["beat_counter"] = int(e.get("beat_counter", 0)) + 1
|
||||||
if ((_beat_counter - 1) % n) != 0:
|
c = int(e["beat_counter"])
|
||||||
|
if (c - 1) % n != 0:
|
||||||
|
continue
|
||||||
|
work.append((list(names), str(e.get("wire_preset_id") or "2")))
|
||||||
|
if work:
|
||||||
|
_preset_session_beats += 1
|
||||||
|
if not work:
|
||||||
return
|
return
|
||||||
preset_id = str(r.get("wire_preset_id") or "2")
|
|
||||||
names_copy = list(names)
|
|
||||||
loop = _main_loop
|
loop = _main_loop
|
||||||
if loop is None:
|
if loop is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
asyncio.run_coroutine_threadsafe(_deliver_select(names_copy, preset_id), loop)
|
asyncio.run_coroutine_threadsafe(_deliver_select_batch(work), loop)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[beat-route] schedule failed: {e}")
|
print(f"[beat-route] schedule failed: {e}")
|
||||||
|
|||||||
996
src/util/sequence_playback.py
Normal file
996
src/util/sequence_playback.py
Normal file
@@ -0,0 +1,996 @@
|
|||||||
|
"""Server-side zone sequence playback (time or audio-beat advance).
|
||||||
|
|
||||||
|
The browser selects a sequence and zone; this module delivers preset pushes to drivers.
|
||||||
|
Sequence start sends one v1 message with every preset body used in the sequence; auto steps
|
||||||
|
then send select-only updates. Manual steps rely on the bulk load and only update beat routing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
_thread_beat_queue: "queue.Queue[int]" = queue.Queue(maxsize=256)
|
||||||
|
_beat_consumer_started = False
|
||||||
|
_beat_consumer_lock = threading.Lock()
|
||||||
|
|
||||||
|
_time_task: Optional[asyncio.Task] = None
|
||||||
|
_time_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
_beat_run: Optional[Dict[str, Any]] = None
|
||||||
|
_beat_run_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_mac(raw: Any) -> Optional[str]:
|
||||||
|
from models.device import normalize_mac
|
||||||
|
|
||||||
|
return normalize_mac(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_sequence_lanes(doc: Dict[str, Any]) -> List[List[Dict[str, Any]]]:
|
||||||
|
lanes_raw = doc.get("lanes") if isinstance(doc.get("lanes"), list) else []
|
||||||
|
lanes = [x for x in lanes_raw if isinstance(x, list)]
|
||||||
|
has_any = any(len(x) > 0 for x in lanes)
|
||||||
|
steps = doc.get("steps")
|
||||||
|
if (not lanes or not has_any) and isinstance(steps, list) and steps:
|
||||||
|
lanes = [list(steps)]
|
||||||
|
if not lanes:
|
||||||
|
lanes = [[]]
|
||||||
|
out: List[List[Dict[str, Any]]] = []
|
||||||
|
for lane in lanes:
|
||||||
|
row: List[Dict[str, Any]] = []
|
||||||
|
for s in lane:
|
||||||
|
if not isinstance(s, dict):
|
||||||
|
continue
|
||||||
|
pid = s.get("preset_id", s.get("presetId"))
|
||||||
|
try:
|
||||||
|
b_raw = s.get("beats")
|
||||||
|
b_n = int(b_raw) if b_raw is not None else 1
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
b_n = 1
|
||||||
|
row.append(
|
||||||
|
{
|
||||||
|
"preset_id": str(pid).strip() if pid is not None else "",
|
||||||
|
"beats": max(1, b_n),
|
||||||
|
"group_ids": [
|
||||||
|
str(x).strip()
|
||||||
|
for x in (s.get("group_ids") or [])
|
||||||
|
if x is not None and str(x).strip()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
out.append(row)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _group_ids_for_lane_step(
|
||||||
|
sequence_doc: Dict[str, Any], step: Dict[str, Any], lane_index: int, num_lanes: int
|
||||||
|
) -> List[str]:
|
||||||
|
lgs = sequence_doc.get("lanes_group_ids")
|
||||||
|
if isinstance(lgs, list) and lane_index < len(lgs):
|
||||||
|
for_lane = lgs[lane_index]
|
||||||
|
if isinstance(for_lane, list):
|
||||||
|
return [str(x).strip() for x in for_lane if x is not None and str(x).strip()]
|
||||||
|
# Multi-lane doc with a shorter ``lanes_group_ids``: do not fall back to ``group_ids``
|
||||||
|
# (editor stores lane 0's groups there; applying it to other lanes targets the wrong groups).
|
||||||
|
if num_lanes > 1 and isinstance(lgs, list) and lane_index >= len(lgs):
|
||||||
|
return []
|
||||||
|
shared = sequence_doc.get("group_ids")
|
||||||
|
if isinstance(shared, list) and shared:
|
||||||
|
return [str(x).strip() for x in shared if x is not None and str(x).strip()]
|
||||||
|
if num_lanes == 1:
|
||||||
|
sg = step.get("group_ids")
|
||||||
|
if isinstance(sg, list) and sg:
|
||||||
|
return [str(x).strip() for x in sg if x is not None and str(x).strip()]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_zone_targets(
|
||||||
|
zone_doc: Dict[str, Any], devices: Any, groups: Any
|
||||||
|
) -> Tuple[List[str], List[str]]:
|
||||||
|
gids = zone_doc.get("group_ids")
|
||||||
|
gids = [str(x).strip() for x in gids if isinstance(gids, list) and x is not None and str(x).strip()]
|
||||||
|
names: List[str] = []
|
||||||
|
macs: List[str] = []
|
||||||
|
if gids:
|
||||||
|
seen: set = set()
|
||||||
|
for gid in gids:
|
||||||
|
g = groups.read(gid) if hasattr(groups, "read") else None
|
||||||
|
if not isinstance(g, dict):
|
||||||
|
continue
|
||||||
|
devs = g.get("devices")
|
||||||
|
if not isinstance(devs, list):
|
||||||
|
continue
|
||||||
|
for raw in devs:
|
||||||
|
m = _norm_mac(raw)
|
||||||
|
if not m or m in seen:
|
||||||
|
continue
|
||||||
|
seen.add(m)
|
||||||
|
doc = devices.read(m) or {}
|
||||||
|
nm = str(doc.get("name") or "").strip() or m
|
||||||
|
names.append(nm)
|
||||||
|
macs.append(m)
|
||||||
|
return names, macs
|
||||||
|
zone_names = zone_doc.get("names")
|
||||||
|
if not isinstance(zone_names, list):
|
||||||
|
zone_names = []
|
||||||
|
name_to_mac: Dict[str, str] = {}
|
||||||
|
for did in devices.list():
|
||||||
|
m = _norm_mac(did)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
doc = devices.read(did) or {}
|
||||||
|
nm = str(doc.get("name") or "").strip()
|
||||||
|
if nm:
|
||||||
|
name_to_mac[nm] = m
|
||||||
|
for zn in zone_names:
|
||||||
|
z = str(zn).strip()
|
||||||
|
if not z:
|
||||||
|
continue
|
||||||
|
m = name_to_mac.get(z)
|
||||||
|
if m and m not in macs:
|
||||||
|
names.append(z)
|
||||||
|
macs.append(m)
|
||||||
|
return names, macs
|
||||||
|
|
||||||
|
|
||||||
|
def _sequence_referenced_group_ids(sequence_doc: Dict[str, Any]) -> List[str]:
|
||||||
|
"""Group ids mentioned on the sequence (shared, per-lane, per-step, legacy steps)."""
|
||||||
|
seen: set = set()
|
||||||
|
out: List[str] = []
|
||||||
|
|
||||||
|
def add(raw: Any) -> None:
|
||||||
|
if raw is None:
|
||||||
|
return
|
||||||
|
s = str(raw).strip()
|
||||||
|
if not s or s in seen:
|
||||||
|
return
|
||||||
|
seen.add(s)
|
||||||
|
out.append(s)
|
||||||
|
|
||||||
|
g0 = sequence_doc.get("group_ids")
|
||||||
|
if isinstance(g0, list):
|
||||||
|
for x in g0:
|
||||||
|
add(x)
|
||||||
|
lgs = sequence_doc.get("lanes_group_ids")
|
||||||
|
if isinstance(lgs, list):
|
||||||
|
for row in lgs:
|
||||||
|
if isinstance(row, list):
|
||||||
|
for x in row:
|
||||||
|
add(x)
|
||||||
|
for lane_key in ("lanes", "steps"):
|
||||||
|
lanes_raw = sequence_doc.get(lane_key)
|
||||||
|
if not isinstance(lanes_raw, list):
|
||||||
|
continue
|
||||||
|
for lane in lanes_raw:
|
||||||
|
if lane_key == "steps":
|
||||||
|
step = lane if isinstance(lane, dict) else None
|
||||||
|
if step:
|
||||||
|
sg = step.get("group_ids")
|
||||||
|
if isinstance(sg, list):
|
||||||
|
for x in sg:
|
||||||
|
add(x)
|
||||||
|
continue
|
||||||
|
if not isinstance(lane, list):
|
||||||
|
continue
|
||||||
|
for step in lane:
|
||||||
|
if not isinstance(step, dict):
|
||||||
|
continue
|
||||||
|
sg = step.get("group_ids")
|
||||||
|
if isinstance(sg, list):
|
||||||
|
for x in sg:
|
||||||
|
add(x)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _extend_mac_scope_for_sequence_groups(
|
||||||
|
zone_mac_set: set,
|
||||||
|
zone_name_by_mac: Dict[str, str],
|
||||||
|
sequence_doc: Dict[str, Any],
|
||||||
|
devices: Any,
|
||||||
|
groups: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Include MACs from any group the sequence references so per-lane groups can differ from the zone tab."""
|
||||||
|
for gid in _sequence_referenced_group_ids(sequence_doc):
|
||||||
|
g = groups.read(gid) if hasattr(groups, "read") else None
|
||||||
|
if not isinstance(g, dict):
|
||||||
|
continue
|
||||||
|
for raw in g.get("devices") or []:
|
||||||
|
m = _norm_mac(raw)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
zone_mac_set.add(m)
|
||||||
|
if m not in zone_name_by_mac:
|
||||||
|
doc = devices.read(m) if hasattr(devices, "read") else None
|
||||||
|
if isinstance(doc, dict):
|
||||||
|
nm = str(doc.get("name") or "").strip() or m
|
||||||
|
else:
|
||||||
|
nm = m
|
||||||
|
zone_name_by_mac[m] = nm
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_step_device_names(
|
||||||
|
zone_doc: Dict[str, Any],
|
||||||
|
step_group_ids: List[str],
|
||||||
|
devices: Any,
|
||||||
|
groups: Any,
|
||||||
|
*,
|
||||||
|
sequence_doc: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> List[str]:
|
||||||
|
z_names, z_macs = _compute_zone_targets(zone_doc, devices, groups)
|
||||||
|
if not step_group_ids:
|
||||||
|
return list(z_names)
|
||||||
|
zone_mac_set = {m for m in (_norm_mac(x) for x in z_macs) if m}
|
||||||
|
zone_name_by_mac: Dict[str, str] = {}
|
||||||
|
for i, m in enumerate(z_macs):
|
||||||
|
mn = _norm_mac(m)
|
||||||
|
if mn and mn not in zone_name_by_mac:
|
||||||
|
zone_name_by_mac[mn] = z_names[i] if i < len(z_names) else mn
|
||||||
|
if sequence_doc is not None:
|
||||||
|
_extend_mac_scope_for_sequence_groups(
|
||||||
|
zone_mac_set, zone_name_by_mac, sequence_doc, devices, groups
|
||||||
|
)
|
||||||
|
step_macs: set = set()
|
||||||
|
for gid in step_group_ids:
|
||||||
|
g = groups.read(gid) if hasattr(groups, "read") else None
|
||||||
|
if not isinstance(g, dict):
|
||||||
|
continue
|
||||||
|
for raw in g.get("devices") or []:
|
||||||
|
m = _norm_mac(raw)
|
||||||
|
if m and m in zone_mac_set:
|
||||||
|
step_macs.add(m)
|
||||||
|
out: List[str] = []
|
||||||
|
for m in step_macs:
|
||||||
|
n = zone_name_by_mac.get(m)
|
||||||
|
if n:
|
||||||
|
out.append(n)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _lane_has_non_empty_lanes_group_ids(sequence_doc: Dict[str, Any], lane_index: int) -> bool:
|
||||||
|
"""True when this lane's targets come from ``lanes_group_ids[lane]`` (already lane-scoped)."""
|
||||||
|
lgs = sequence_doc.get("lanes_group_ids")
|
||||||
|
if not isinstance(lgs, list) or lane_index < 0 or lane_index >= len(lgs):
|
||||||
|
return False
|
||||||
|
for_lane = lgs[lane_index]
|
||||||
|
if not isinstance(for_lane, list) or not for_lane:
|
||||||
|
return False
|
||||||
|
return any(x is not None and str(x).strip() for x in for_lane)
|
||||||
|
|
||||||
|
|
||||||
|
def _split_device_names_for_lane(
|
||||||
|
all_names: List[str],
|
||||||
|
lane_index: int,
|
||||||
|
num_lanes: int,
|
||||||
|
*,
|
||||||
|
partition_shared_zone: bool = True,
|
||||||
|
) -> List[str]:
|
||||||
|
names = [n for n in all_names if n and str(n).strip()]
|
||||||
|
if num_lanes <= 1 or not partition_shared_zone:
|
||||||
|
return names
|
||||||
|
if len(names) >= num_lanes:
|
||||||
|
n = names[lane_index]
|
||||||
|
return [n] if n else []
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_colors_with_palette_refs(
|
||||||
|
colors: Any, palette_refs: Any, palette_colors: List[Any]
|
||||||
|
) -> List[Any]:
|
||||||
|
base = list(colors) if isinstance(colors, list) else []
|
||||||
|
refs = list(palette_refs) if isinstance(palette_refs, list) else []
|
||||||
|
pal = list(palette_colors) if isinstance(palette_colors, list) else []
|
||||||
|
out: List[Any] = []
|
||||||
|
for idx, color in enumerate(base):
|
||||||
|
ref_raw = refs[idx] if idx < len(refs) else None
|
||||||
|
try:
|
||||||
|
ref = int(ref_raw) if ref_raw is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
ref = None
|
||||||
|
if isinstance(ref, int) and 0 <= ref < len(pal) and pal[ref]:
|
||||||
|
out.append(pal[ref])
|
||||||
|
else:
|
||||||
|
out.append(color)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _ordered_unique_preset_ids_from_lanes(lanes: List[List[Dict[str, Any]]]) -> List[str]:
|
||||||
|
seen: set = set()
|
||||||
|
out: List[str] = []
|
||||||
|
for lane in lanes:
|
||||||
|
for step in lane:
|
||||||
|
if not isinstance(step, dict):
|
||||||
|
continue
|
||||||
|
pid = str(step.get("preset_id") or "").strip()
|
||||||
|
if not pid or pid in seen:
|
||||||
|
continue
|
||||||
|
seen.add(pid)
|
||||||
|
out.append(pid)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _display_preset_for_step(
|
||||||
|
preset_id: str,
|
||||||
|
presets_map: Dict[str, Any],
|
||||||
|
palette_colors: List[Any],
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
preset = presets_map.get(preset_id)
|
||||||
|
if not isinstance(preset, dict):
|
||||||
|
return None
|
||||||
|
base_colors = preset.get("colors") or preset.get("c") or ["#FFFFFF"]
|
||||||
|
colors = _resolve_colors_with_palette_refs(
|
||||||
|
base_colors if isinstance(base_colors, list) else [base_colors],
|
||||||
|
preset.get("palette_refs"),
|
||||||
|
palette_colors,
|
||||||
|
)
|
||||||
|
return {**preset, "colors": colors}
|
||||||
|
|
||||||
|
|
||||||
|
def _preset_inner_from_display_preset(display_preset: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
from util.espnow_message import build_preset_dict
|
||||||
|
|
||||||
|
body = dict(display_preset)
|
||||||
|
inner = build_preset_dict(body)
|
||||||
|
mb = body.get("manual_beat_n", body.get("manualBeatN"))
|
||||||
|
if mb is not None:
|
||||||
|
try:
|
||||||
|
n = int(mb)
|
||||||
|
if 1 <= n <= 64:
|
||||||
|
inner["manual_beat_n"] = n
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return inner
|
||||||
|
|
||||||
|
|
||||||
|
def _device_names_to_macs(device_names: List[str], devices: Any) -> List[str]:
|
||||||
|
macs: List[str] = []
|
||||||
|
seen: set = set()
|
||||||
|
for nm in device_names:
|
||||||
|
key = str(nm).strip()
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
m = None
|
||||||
|
for did in devices.list():
|
||||||
|
doc = devices.read(did) or {}
|
||||||
|
if str(doc.get("name") or "").strip() == key:
|
||||||
|
m = _norm_mac(did)
|
||||||
|
break
|
||||||
|
if not m and key.startswith("led-"):
|
||||||
|
m = _norm_mac(key[4:])
|
||||||
|
if m and m not in seen:
|
||||||
|
seen.add(m)
|
||||||
|
macs.append(m)
|
||||||
|
return macs
|
||||||
|
|
||||||
|
|
||||||
|
def _union_macs_for_sequence(ctx: Dict[str, Any]) -> List[str]:
|
||||||
|
"""MACs that appear on any lane/step (union); falls back to full zone targets."""
|
||||||
|
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
|
||||||
|
sequence_doc: Dict[str, Any] = ctx["sequence_doc"]
|
||||||
|
zone_doc: Dict[str, Any] = ctx["zone_doc"]
|
||||||
|
devices = ctx["devices"]
|
||||||
|
groups = ctx["groups"]
|
||||||
|
num_lanes = int(ctx["num_lanes"])
|
||||||
|
seen: set = set()
|
||||||
|
out: List[str] = []
|
||||||
|
for lane_index, lane in enumerate(lanes):
|
||||||
|
for step in lane:
|
||||||
|
if not isinstance(step, dict):
|
||||||
|
continue
|
||||||
|
gids = _group_ids_for_lane_step(sequence_doc, step, lane_index, num_lanes)
|
||||||
|
device_names = _resolve_step_device_names(
|
||||||
|
zone_doc, gids, devices, groups, sequence_doc=sequence_doc
|
||||||
|
)
|
||||||
|
device_names = _split_device_names_for_lane(
|
||||||
|
device_names,
|
||||||
|
lane_index,
|
||||||
|
num_lanes,
|
||||||
|
partition_shared_zone=not _lane_has_non_empty_lanes_group_ids(
|
||||||
|
sequence_doc, lane_index
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if gids and not device_names:
|
||||||
|
continue
|
||||||
|
for m in _device_names_to_macs(device_names, devices):
|
||||||
|
if m and m not in seen:
|
||||||
|
seen.add(m)
|
||||||
|
out.append(m)
|
||||||
|
if out:
|
||||||
|
return out
|
||||||
|
_, z_macs = _compute_zone_targets(zone_doc, devices, groups)
|
||||||
|
return list(z_macs)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_sequence_wire_presets_map(ctx: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
|
||||||
|
presets_map: Dict[str, Any] = ctx["presets_map"]
|
||||||
|
palette_colors: List[Any] = ctx["palette_colors"]
|
||||||
|
inner_by_wire: Dict[str, Any] = {}
|
||||||
|
for pid in _ordered_unique_preset_ids_from_lanes(lanes):
|
||||||
|
disp = _display_preset_for_step(pid, presets_map, palette_colors)
|
||||||
|
if not disp:
|
||||||
|
continue
|
||||||
|
inner_by_wire[str(pid)] = _preset_inner_from_display_preset(disp)
|
||||||
|
return inner_by_wire
|
||||||
|
|
||||||
|
|
||||||
|
async def _deliver_sequence_presets_bulk(ctx: Dict[str, Any]) -> None:
|
||||||
|
"""Push all preset definitions used in the sequence once; step advances use select (auto) only."""
|
||||||
|
from models.transport import get_current_sender
|
||||||
|
from util.driver_delivery import deliver_json_messages
|
||||||
|
|
||||||
|
inner_by_wire = _build_sequence_wire_presets_map(ctx)
|
||||||
|
ctx["_sequence_wire_presets"] = inner_by_wire
|
||||||
|
if not inner_by_wire:
|
||||||
|
return
|
||||||
|
sender = get_current_sender()
|
||||||
|
if not sender:
|
||||||
|
raise RuntimeError("Transport not configured")
|
||||||
|
macs = _union_macs_for_sequence(ctx)
|
||||||
|
if not macs:
|
||||||
|
return
|
||||||
|
msg = json.dumps({"v": "1", "presets": inner_by_wire}, separators=(",", ":"))
|
||||||
|
await deliver_json_messages(sender, [msg], macs, ctx["devices"], delay_s=0.05)
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_auto(preset: Dict[str, Any]) -> bool:
|
||||||
|
raw = preset.get("auto", preset.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):
|
||||||
|
lo = raw.strip().lower()
|
||||||
|
if lo in ("false", "0", "no", "off"):
|
||||||
|
return False
|
||||||
|
if lo in ("true", "1", "yes", "on"):
|
||||||
|
return True
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _load_palette_colors(profile_id: str) -> List[Any]:
|
||||||
|
from models.profile import Profile
|
||||||
|
from models.pallet import Palette
|
||||||
|
|
||||||
|
prof = Profile().read(profile_id)
|
||||||
|
if not isinstance(prof, dict):
|
||||||
|
return []
|
||||||
|
pid = prof.get("palette_id") or prof.get("paletteId")
|
||||||
|
if not pid:
|
||||||
|
return []
|
||||||
|
return Palette().read(str(pid)) or []
|
||||||
|
|
||||||
|
|
||||||
|
async def _deliver_preset_for_devices(
|
||||||
|
preset_id: str,
|
||||||
|
preset_doc: Dict[str, Any],
|
||||||
|
device_names: List[str],
|
||||||
|
devices: Any,
|
||||||
|
*,
|
||||||
|
lane_index: Optional[int] = None,
|
||||||
|
) -> None:
|
||||||
|
from models.transport import get_current_sender
|
||||||
|
from util.driver_delivery import deliver_json_messages
|
||||||
|
from util.beat_driver_route import sync_beat_route_from_push_sequence
|
||||||
|
from util.espnow_message import build_preset_dict
|
||||||
|
|
||||||
|
sender = get_current_sender()
|
||||||
|
if not sender:
|
||||||
|
raise RuntimeError("Transport not configured")
|
||||||
|
|
||||||
|
macs: List[str] = []
|
||||||
|
seen: set = set()
|
||||||
|
for nm in device_names:
|
||||||
|
key = str(nm).strip()
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
m = None
|
||||||
|
for did in devices.list():
|
||||||
|
doc = devices.read(did) or {}
|
||||||
|
if str(doc.get("name") or "").strip() == key:
|
||||||
|
m = _norm_mac(did)
|
||||||
|
break
|
||||||
|
if not m and key.startswith("led-"):
|
||||||
|
m = _norm_mac(key[4:])
|
||||||
|
if m and m not in seen:
|
||||||
|
seen.add(m)
|
||||||
|
macs.append(m)
|
||||||
|
if not macs:
|
||||||
|
return
|
||||||
|
|
||||||
|
body = dict(preset_doc)
|
||||||
|
auto = _coerce_auto(body)
|
||||||
|
inner = build_preset_dict(body)
|
||||||
|
mb = body.get("manual_beat_n", body.get("manualBeatN"))
|
||||||
|
if mb is not None:
|
||||||
|
try:
|
||||||
|
n = int(mb)
|
||||||
|
if 1 <= n <= 64:
|
||||||
|
inner["manual_beat_n"] = n
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
wire = str(preset_id)
|
||||||
|
seq_list: List[Dict[str, Any]] = [{"v": "1", "presets": {wire: inner}}]
|
||||||
|
if auto and device_names:
|
||||||
|
sel: Dict[str, Any] = {}
|
||||||
|
for n in device_names:
|
||||||
|
if n:
|
||||||
|
sel[str(n)] = [wire]
|
||||||
|
if sel:
|
||||||
|
seq_list.append({"v": "1", "select": sel})
|
||||||
|
messages = [json.dumps(x, separators=(",", ":")) for x in seq_list]
|
||||||
|
await deliver_json_messages(sender, messages, macs, devices, delay_s=0.05)
|
||||||
|
if not auto:
|
||||||
|
if lane_index is not None:
|
||||||
|
from util.beat_driver_route import set_sequence_manual_lane_route
|
||||||
|
|
||||||
|
set_sequence_manual_lane_route(lane_index, device_names, wire, inner)
|
||||||
|
else:
|
||||||
|
sync_beat_route_from_push_sequence(
|
||||||
|
seq_list, target_macs=macs, preserve_manual_beat_route_on_auto_select=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_lane(
|
||||||
|
lane_index: int,
|
||||||
|
st: Dict[str, Any],
|
||||||
|
ctx: Dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
|
||||||
|
sequence_doc: Dict[str, Any] = ctx["sequence_doc"]
|
||||||
|
presets_map: Dict[str, Any] = ctx["presets_map"]
|
||||||
|
zone_doc: Dict[str, Any] = ctx["zone_doc"]
|
||||||
|
devices = ctx["devices"]
|
||||||
|
groups = ctx["groups"]
|
||||||
|
palette_colors: List[Any] = ctx["palette_colors"]
|
||||||
|
num_lanes = ctx["num_lanes"]
|
||||||
|
|
||||||
|
if st.get("done"):
|
||||||
|
return
|
||||||
|
lane_steps = lanes[lane_index]
|
||||||
|
idx = int(st.get("stepIdx", 0))
|
||||||
|
if idx < 0 or idx >= len(lane_steps):
|
||||||
|
return
|
||||||
|
step = lane_steps[idx]
|
||||||
|
preset_id = str(step.get("preset_id") or "").strip()
|
||||||
|
if not preset_id:
|
||||||
|
return
|
||||||
|
display_preset = _display_preset_for_step(preset_id, presets_map, palette_colors)
|
||||||
|
if not display_preset:
|
||||||
|
return
|
||||||
|
gids = _group_ids_for_lane_step(sequence_doc, step, lane_index, num_lanes)
|
||||||
|
device_names = _resolve_step_device_names(
|
||||||
|
zone_doc, gids, devices, groups, sequence_doc=sequence_doc
|
||||||
|
)
|
||||||
|
device_names = _split_device_names_for_lane(
|
||||||
|
device_names,
|
||||||
|
lane_index,
|
||||||
|
num_lanes,
|
||||||
|
partition_shared_zone=not _lane_has_non_empty_lanes_group_ids(sequence_doc, lane_index),
|
||||||
|
)
|
||||||
|
if gids and not device_names:
|
||||||
|
return
|
||||||
|
|
||||||
|
from models.transport import get_current_sender
|
||||||
|
from util.beat_driver_route import (
|
||||||
|
clear_sequence_manual_lane_route,
|
||||||
|
set_sequence_manual_lane_route,
|
||||||
|
)
|
||||||
|
from util.driver_delivery import deliver_json_messages
|
||||||
|
|
||||||
|
sender = get_current_sender()
|
||||||
|
if not sender:
|
||||||
|
raise RuntimeError("Transport not configured")
|
||||||
|
|
||||||
|
macs = _device_names_to_macs(device_names, devices)
|
||||||
|
if not macs:
|
||||||
|
return
|
||||||
|
|
||||||
|
bulk = ctx.get("_sequence_wire_presets")
|
||||||
|
if isinstance(bulk, dict) and bulk:
|
||||||
|
auto = _coerce_auto(display_preset)
|
||||||
|
inner = _preset_inner_from_display_preset(display_preset)
|
||||||
|
wire = str(preset_id)
|
||||||
|
if auto:
|
||||||
|
clear_sequence_manual_lane_route(lane_index)
|
||||||
|
sel: Dict[str, Any] = {}
|
||||||
|
for n in device_names:
|
||||||
|
if n:
|
||||||
|
sel[str(n)] = [wire]
|
||||||
|
if not sel:
|
||||||
|
return
|
||||||
|
msg = json.dumps({"v": "1", "select": sel}, separators=(",", ":"))
|
||||||
|
await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05)
|
||||||
|
else:
|
||||||
|
set_sequence_manual_lane_route(lane_index, device_names, wire, inner)
|
||||||
|
return
|
||||||
|
|
||||||
|
await _deliver_preset_for_devices(
|
||||||
|
preset_id, display_preset, device_names, devices, lane_index=lane_index
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_all_lanes(ctx: Dict[str, Any]) -> None:
|
||||||
|
lane_states: List[Dict[str, Any]] = ctx["lane_states"]
|
||||||
|
num_lanes = ctx["num_lanes"]
|
||||||
|
for i in range(num_lanes):
|
||||||
|
if lane_states[i].get("done"):
|
||||||
|
continue
|
||||||
|
await _send_lane(i, lane_states[i], ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def _sequence_advance_beats(sequence_doc: Dict[str, Any]) -> bool:
|
||||||
|
raw = sequence_doc.get("advance_mode")
|
||||||
|
return isinstance(raw, str) and raw.strip().lower() == "beats"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_ctx(
|
||||||
|
sequence_doc: Dict[str, Any],
|
||||||
|
zone_doc: Dict[str, Any],
|
||||||
|
presets_map: Dict[str, Any],
|
||||||
|
profile_id: str,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
from models.device import Device
|
||||||
|
from models.group import Group
|
||||||
|
|
||||||
|
lanes = [x for x in _normalize_sequence_lanes(sequence_doc) if len(x) > 0]
|
||||||
|
if not lanes:
|
||||||
|
return None
|
||||||
|
devices = Device()
|
||||||
|
groups = Group()
|
||||||
|
palette_colors = _load_palette_colors(profile_id)
|
||||||
|
num_lanes = len(lanes)
|
||||||
|
lane_states = [{"stepIdx": 0, "beatCount": 0, "done": False} for _ in range(num_lanes)]
|
||||||
|
return {
|
||||||
|
"lanes": lanes,
|
||||||
|
"lane_states": lane_states,
|
||||||
|
"num_lanes": num_lanes,
|
||||||
|
"sequence_doc": sequence_doc,
|
||||||
|
"zone_doc": zone_doc,
|
||||||
|
"presets_map": presets_map,
|
||||||
|
"devices": devices,
|
||||||
|
"groups": groups,
|
||||||
|
"palette_colors": palette_colors,
|
||||||
|
"loop": True,
|
||||||
|
"advance_mode": "beats" if _sequence_advance_beats(sequence_doc) else "time",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def playback_status() -> Dict[str, Any]:
|
||||||
|
"""Snapshot for UI (e.g. audio status poll): lane 0 step + beats within step, total steps sum."""
|
||||||
|
with _beat_run_lock:
|
||||||
|
ctx = _beat_run
|
||||||
|
if not ctx:
|
||||||
|
return {"active": False, "beat_readout": ""}
|
||||||
|
lanes: List[List[Dict[str, Any]]] = ctx.get("lanes") or []
|
||||||
|
lane_states: List[Dict[str, Any]] = ctx.get("lane_states") or []
|
||||||
|
num_lanes = int(ctx.get("num_lanes") or 0)
|
||||||
|
total_steps = sum(len(l) for l in lanes)
|
||||||
|
lane0_steps = len(lanes[0]) if lanes else 0
|
||||||
|
beat_count = 0
|
||||||
|
beats_per_step = 1
|
||||||
|
step_1based = 0
|
||||||
|
lane0 = lanes[0] if lanes else []
|
||||||
|
sequence_beats_per_pass = 0
|
||||||
|
for step in lane0:
|
||||||
|
sequence_beats_per_pass += max(1, int((step or {}).get("beats") or 1))
|
||||||
|
sequence_beat_at = 0
|
||||||
|
if lane_states and lane0_steps > 0:
|
||||||
|
st0 = lane_states[0]
|
||||||
|
idx = int(st0.get("stepIdx", 0))
|
||||||
|
advance_mode = str(ctx.get("advance_mode") or "").strip().lower()
|
||||||
|
if st0.get("done"):
|
||||||
|
step_1based = lane0_steps
|
||||||
|
sequence_beat_at = sequence_beats_per_pass
|
||||||
|
else:
|
||||||
|
step_1based = idx + 1
|
||||||
|
if 0 <= idx < len(lanes[0]):
|
||||||
|
step = lanes[0][idx]
|
||||||
|
beats_per_step = max(1, int(step.get("beats") or 1))
|
||||||
|
beat_count_raw = int(st0.get("beatCount", 0))
|
||||||
|
# Internal beatCount resets to 0 on step rollover; expose 1..beats_per_step in beats mode.
|
||||||
|
if advance_mode == "beats":
|
||||||
|
bt = max(1, int(beats_per_step))
|
||||||
|
beat_count = min(bt, max(1, beat_count_raw if beat_count_raw > 0 else 1))
|
||||||
|
else:
|
||||||
|
beat_count = beat_count_raw
|
||||||
|
for j in range(min(idx, len(lane0))):
|
||||||
|
sequence_beat_at += max(1, int((lane0[j] or {}).get("beats") or 1))
|
||||||
|
sequence_beat_at += beat_count
|
||||||
|
lane0_preset_id = ""
|
||||||
|
lane0_preset_name = ""
|
||||||
|
pm_raw = ctx.get("presets_map")
|
||||||
|
presets_map_status: Dict[str, Any] = pm_raw if isinstance(pm_raw, dict) else {}
|
||||||
|
if lane_states and lane0_steps > 0 and lane0:
|
||||||
|
st_preset = lane_states[0]
|
||||||
|
if not st_preset.get("done"):
|
||||||
|
ix = int(st_preset.get("stepIdx", 0))
|
||||||
|
if 0 <= ix < len(lane0):
|
||||||
|
stp = lane0[ix] or {}
|
||||||
|
pid = str(stp.get("preset_id") or "").strip()
|
||||||
|
lane0_preset_id = pid
|
||||||
|
if pid:
|
||||||
|
pdoc = presets_map_status.get(pid)
|
||||||
|
if isinstance(pdoc, dict):
|
||||||
|
nm = str(pdoc.get("name") or "").strip()
|
||||||
|
lane0_preset_name = nm or pid
|
||||||
|
else:
|
||||||
|
lane0_preset_name = pid
|
||||||
|
beat_readout = ""
|
||||||
|
adv_m = str(ctx.get("advance_mode") or "").strip().lower()
|
||||||
|
if (
|
||||||
|
adv_m == "beats"
|
||||||
|
and sequence_beats_per_pass > 0
|
||||||
|
and lane_states
|
||||||
|
and lane0_steps > 0
|
||||||
|
and lane_states[0]
|
||||||
|
and not lane_states[0].get("done")
|
||||||
|
):
|
||||||
|
tot = max(1, int(sequence_beats_per_pass))
|
||||||
|
at = int(sequence_beat_at)
|
||||||
|
# Pass position within this run: inclusive 1..tot
|
||||||
|
sp = min(tot, max(1, at if at > 0 else 1))
|
||||||
|
beat_readout = f"{sp}/{tot}"
|
||||||
|
return {
|
||||||
|
"active": True,
|
||||||
|
"advance_mode": ctx.get("advance_mode"),
|
||||||
|
"sequence_id": ctx.get("sequence_id"),
|
||||||
|
"zone_id": ctx.get("zone_id"),
|
||||||
|
"num_lanes": num_lanes,
|
||||||
|
"total_sequence_steps": total_steps,
|
||||||
|
"lane0_current_step": step_1based,
|
||||||
|
"lane0_lane_length": lane0_steps,
|
||||||
|
"lane0_beat_in_step": beat_count,
|
||||||
|
"lane0_beats_per_step": beats_per_step,
|
||||||
|
"lane0_preset_id": lane0_preset_id,
|
||||||
|
"lane0_preset_name": lane0_preset_name,
|
||||||
|
"sequence_beat_at": sequence_beat_at,
|
||||||
|
"sequence_beats_per_pass": sequence_beats_per_pass,
|
||||||
|
"sequence_loop_beat": int(ctx.get("sequence_loop_beat", 0)),
|
||||||
|
"beat_readout": beat_readout,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def process_active_beat_advance() -> None:
|
||||||
|
with _beat_run_lock:
|
||||||
|
ctx = _beat_run
|
||||||
|
if not ctx or ctx.get("advance_mode") != "beats":
|
||||||
|
return
|
||||||
|
lane_states: List[Dict[str, Any]] = ctx["lane_states"]
|
||||||
|
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
|
||||||
|
loop = bool(ctx.get("loop"))
|
||||||
|
lane0_looped = False
|
||||||
|
for i in range(ctx["num_lanes"]):
|
||||||
|
st = lane_states[i]
|
||||||
|
if st.get("done"):
|
||||||
|
continue
|
||||||
|
lane_steps = lanes[i]
|
||||||
|
if not lane_steps:
|
||||||
|
continue
|
||||||
|
st["beatCount"] = int(st.get("beatCount", 0)) + 1
|
||||||
|
step = lane_steps[int(st.get("stepIdx", 0))]
|
||||||
|
need = max(1, int(step.get("beats") or 1))
|
||||||
|
if int(st["beatCount"]) >= need:
|
||||||
|
st["beatCount"] = 0
|
||||||
|
if int(st.get("stepIdx", 0)) + 1 >= len(lane_steps):
|
||||||
|
if loop:
|
||||||
|
if i == 0:
|
||||||
|
lane0_looped = True
|
||||||
|
st["stepIdx"] = 0
|
||||||
|
await _send_lane(i, st, ctx)
|
||||||
|
else:
|
||||||
|
st["done"] = True
|
||||||
|
else:
|
||||||
|
st["stepIdx"] = int(st.get("stepIdx", 0)) + 1
|
||||||
|
await _send_lane(i, st, ctx)
|
||||||
|
if lane0_looped:
|
||||||
|
# First beat of the next loop (was 0 here so single-step / first wrap never left 0).
|
||||||
|
ctx["sequence_loop_beat"] = 1
|
||||||
|
else:
|
||||||
|
ctx["sequence_loop_beat"] = int(ctx.get("sequence_loop_beat", 0)) + 1
|
||||||
|
if all(s.get("done") for s in lane_states):
|
||||||
|
stop()
|
||||||
|
|
||||||
|
|
||||||
|
def push_thread_beat() -> None:
|
||||||
|
try:
|
||||||
|
_thread_beat_queue.put_nowait(1)
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def beat_consumer_loop() -> None:
|
||||||
|
while True:
|
||||||
|
n = 0
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
_thread_beat_queue.get_nowait()
|
||||||
|
n += 1
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
if n:
|
||||||
|
from util.beat_driver_route import notify_beat_detected
|
||||||
|
|
||||||
|
for _ in range(n):
|
||||||
|
try:
|
||||||
|
await process_active_beat_advance()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[sequence-playback] beat advance: {e}")
|
||||||
|
try:
|
||||||
|
notify_beat_detected()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[sequence-playback] notify_beat_detected: {e}")
|
||||||
|
else:
|
||||||
|
await asyncio.sleep(0.012)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_beat_consumer_started() -> None:
|
||||||
|
global _beat_consumer_started
|
||||||
|
with _beat_consumer_lock:
|
||||||
|
if _beat_consumer_started:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
return
|
||||||
|
_beat_consumer_started = True
|
||||||
|
loop.create_task(beat_consumer_loop())
|
||||||
|
|
||||||
|
|
||||||
|
_time_token = 0
|
||||||
|
|
||||||
|
|
||||||
|
async def _time_loop(ctx: Dict[str, Any], token: int) -> None:
|
||||||
|
sequence_doc = ctx["sequence_doc"]
|
||||||
|
raw_dur = sequence_doc.get("step_duration_ms", 3000)
|
||||||
|
try:
|
||||||
|
duration = max(200, int(raw_dur))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
duration = 3000
|
||||||
|
raw_tr = sequence_doc.get("sequence_transition")
|
||||||
|
try:
|
||||||
|
tr_in = int(raw_tr) if raw_tr is not None else 0
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
tr_in = 0
|
||||||
|
transition_ms = min(60000, max(0, tr_in))
|
||||||
|
min_step = 200
|
||||||
|
time_sleep_tr = min(transition_ms, max(0, duration - min_step))
|
||||||
|
time_tick_lead = max(min_step, duration - time_sleep_tr)
|
||||||
|
|
||||||
|
await _send_all_lanes(ctx)
|
||||||
|
my = token
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(time_tick_lead / 1000.0)
|
||||||
|
with _beat_run_lock:
|
||||||
|
cur = _time_token
|
||||||
|
if cur != my:
|
||||||
|
return
|
||||||
|
if time_sleep_tr > 0:
|
||||||
|
await asyncio.sleep(time_sleep_tr / 1000.0)
|
||||||
|
with _beat_run_lock:
|
||||||
|
cur = _time_token
|
||||||
|
if cur != my:
|
||||||
|
return
|
||||||
|
lane_states = ctx["lane_states"]
|
||||||
|
lanes = ctx["lanes"]
|
||||||
|
loop = bool(ctx.get("loop"))
|
||||||
|
lane0_looped = False
|
||||||
|
for i in range(ctx["num_lanes"]):
|
||||||
|
st = lane_states[i]
|
||||||
|
if st.get("done"):
|
||||||
|
continue
|
||||||
|
ln = len(lanes[i])
|
||||||
|
if int(st.get("stepIdx", 0)) + 1 >= ln:
|
||||||
|
if loop:
|
||||||
|
if i == 0:
|
||||||
|
lane0_looped = True
|
||||||
|
st["stepIdx"] = 0
|
||||||
|
else:
|
||||||
|
st["done"] = True
|
||||||
|
else:
|
||||||
|
st["stepIdx"] = int(st.get("stepIdx", 0)) + 1
|
||||||
|
if lane0_looped:
|
||||||
|
ctx["sequence_loop_beat"] = 1
|
||||||
|
else:
|
||||||
|
ctx["sequence_loop_beat"] = int(ctx.get("sequence_loop_beat", 0)) + 1
|
||||||
|
if all(s.get("done") for s in lane_states):
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
await _send_all_lanes(ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def stop() -> None:
|
||||||
|
global _beat_run, _time_task, _time_token
|
||||||
|
with _beat_run_lock:
|
||||||
|
_beat_run = None
|
||||||
|
_time_token += 1
|
||||||
|
t = _time_task
|
||||||
|
_time_task = None
|
||||||
|
if t and not t.done():
|
||||||
|
t.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
def stop_if_playing_sequence(sequence_id: str) -> bool:
|
||||||
|
"""If zone sequence playback is running this sequence id, stop it (e.g. after save/delete)."""
|
||||||
|
sid = str(sequence_id).strip()
|
||||||
|
if not sid:
|
||||||
|
return False
|
||||||
|
with _beat_run_lock:
|
||||||
|
ctx = _beat_run
|
||||||
|
if not ctx:
|
||||||
|
return False
|
||||||
|
cur = ctx.get("sequence_id")
|
||||||
|
if cur is None or str(cur).strip() != sid:
|
||||||
|
return False
|
||||||
|
stop()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def start(zone_id: str, sequence_id: str, profile_id: str) -> None:
|
||||||
|
global _beat_run, _time_task, _time_token
|
||||||
|
from models.preset import Preset
|
||||||
|
from models.profile import Profile
|
||||||
|
from models.sequence import Sequence
|
||||||
|
from models.zone import Zone
|
||||||
|
|
||||||
|
stop()
|
||||||
|
seq_m = Sequence()
|
||||||
|
zone_m = Zone()
|
||||||
|
prof_m = Profile()
|
||||||
|
sequence_doc = seq_m.read(sequence_id)
|
||||||
|
zone_doc = zone_m.read(zone_id)
|
||||||
|
if not sequence_doc or str(sequence_doc.get("profile_id")) != str(profile_id):
|
||||||
|
raise ValueError("sequence not found")
|
||||||
|
if not zone_doc:
|
||||||
|
raise ValueError("zone not found")
|
||||||
|
prof = prof_m.read(profile_id)
|
||||||
|
if not prof:
|
||||||
|
raise ValueError("profile not found")
|
||||||
|
|
||||||
|
presets_map: Dict[str, Any] = {}
|
||||||
|
pr = Preset()
|
||||||
|
for pid in pr.list():
|
||||||
|
doc = pr.read(pid)
|
||||||
|
if isinstance(doc, dict) and str(doc.get("profile_id")) == str(profile_id):
|
||||||
|
presets_map[str(pid)] = doc
|
||||||
|
|
||||||
|
ctx = _build_ctx(sequence_doc, zone_doc, presets_map, profile_id)
|
||||||
|
if not ctx:
|
||||||
|
raise ValueError("sequence has no steps")
|
||||||
|
|
||||||
|
ctx["sequence_id"] = str(sequence_id)
|
||||||
|
ctx["zone_id"] = str(zone_id)
|
||||||
|
ctx["sequence_loop_beat"] = 0
|
||||||
|
|
||||||
|
await _deliver_sequence_presets_bulk(ctx)
|
||||||
|
|
||||||
|
advance = ctx["advance_mode"]
|
||||||
|
if advance == "beats":
|
||||||
|
from util.beat_driver_route import update_beat_route
|
||||||
|
|
||||||
|
update_beat_route({"enabled": False})
|
||||||
|
with _beat_run_lock:
|
||||||
|
_beat_run = ctx
|
||||||
|
await _send_all_lanes(ctx)
|
||||||
|
else:
|
||||||
|
with _beat_run_lock:
|
||||||
|
_beat_run = ctx
|
||||||
|
_time_token += 1
|
||||||
|
my = _time_token
|
||||||
|
|
||||||
|
async def _run() -> None:
|
||||||
|
try:
|
||||||
|
await _time_loop(ctx, my)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[sequence-playback] time loop: {e}")
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
_time_task = loop.create_task(_run())
|
||||||
|
|
||||||
BIN
tests/audio/beat_test_120bpm.wav
Normal file
BIN
tests/audio/beat_test_120bpm.wav
Normal file
Binary file not shown.
@@ -1,16 +1,19 @@
|
|||||||
from models.squence import Sequence
|
from models.sequence import Sequence
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
_PROJECT_DB = os.path.normpath(os.path.join(_HERE, "..", "..", "db", "sequence.json"))
|
||||||
|
|
||||||
|
|
||||||
def test_sequence():
|
def test_sequence():
|
||||||
"""Test Sequence model CRUD operations."""
|
"""Test Sequence model CRUD operations."""
|
||||||
# Clean up any existing test file
|
if os.path.exists(_PROJECT_DB):
|
||||||
if os.path.exists("Sequence.json"):
|
os.remove(_PROJECT_DB)
|
||||||
os.remove("Sequence.json")
|
|
||||||
|
|
||||||
sequences = Sequence()
|
sequences = Sequence()
|
||||||
|
|
||||||
print("Testing create sequence")
|
print("Testing create sequence")
|
||||||
sequence_id = sequences.create("test_group", ["preset1", "preset2"])
|
sequence_id = sequences.create("1")
|
||||||
print(f"Created sequence with ID: {sequence_id}")
|
print(f"Created sequence with ID: {sequence_id}")
|
||||||
assert sequence_id is not None
|
assert sequence_id is not None
|
||||||
assert sequence_id in sequences
|
assert sequence_id in sequences
|
||||||
@@ -19,27 +22,42 @@ def test_sequence():
|
|||||||
sequence = sequences.read(sequence_id)
|
sequence = sequences.read(sequence_id)
|
||||||
print(f"Read: {sequence}")
|
print(f"Read: {sequence}")
|
||||||
assert sequence is not None
|
assert sequence is not None
|
||||||
assert sequence["group_name"] == "test_group"
|
assert sequence["profile_id"] == "1"
|
||||||
assert len(sequence["presets"]) == 2
|
assert sequence["steps"] == []
|
||||||
assert "sequence_duration" in sequence
|
assert sequence["lanes"] == [[]]
|
||||||
assert "sequence_loop" in sequence
|
assert sequence.get("lanes_group_ids") == [[]]
|
||||||
|
assert sequence.get("advance_mode") == "time"
|
||||||
|
assert sequence["step_duration_ms"] == 3000
|
||||||
|
assert sequence["loop"] is True
|
||||||
|
assert sequence.get("sequence_transition") == 500
|
||||||
|
|
||||||
print("\nTesting update sequence")
|
print("\nTesting update sequence")
|
||||||
update_data = {
|
update_data = {
|
||||||
"group_name": "updated_group",
|
"name": "updated_seq",
|
||||||
"presets": ["preset3", "preset4", "preset5"],
|
"steps": [
|
||||||
"sequence_duration": 5000,
|
{"preset_id": "5", "group_ids": ["1"], "beats": 2},
|
||||||
"sequence_transition": 1000,
|
{"preset_id": "6", "group_ids": [], "beats": 4},
|
||||||
"sequence_loop": True,
|
],
|
||||||
"sequence_repeat_count": 3
|
"lanes_group_ids": [["1"]],
|
||||||
|
"step_duration_ms": 5000,
|
||||||
|
"loop": True,
|
||||||
|
"advance_mode": "beats",
|
||||||
}
|
}
|
||||||
result = sequences.update(sequence_id, update_data)
|
result = sequences.update(sequence_id, update_data)
|
||||||
assert result is True
|
assert result is True
|
||||||
updated = sequences.read(sequence_id)
|
updated = sequences.read(sequence_id)
|
||||||
assert updated["group_name"] == "updated_group"
|
assert updated["name"] == "updated_seq"
|
||||||
assert len(updated["presets"]) == 3
|
assert len(updated["steps"]) == 2
|
||||||
assert updated["sequence_duration"] == 5000
|
assert updated["steps"][0]["preset_id"] == "5"
|
||||||
assert updated["sequence_loop"] is True
|
assert updated["steps"][0]["group_ids"] == ["1"]
|
||||||
|
assert updated["steps"][0].get("beats") == 2
|
||||||
|
assert isinstance(updated.get("lanes"), list)
|
||||||
|
assert len(updated["lanes"]) == 1
|
||||||
|
assert len(updated["lanes"][0]) == 2
|
||||||
|
assert updated["lanes"][0][0]["beats"] == 2
|
||||||
|
assert updated.get("advance_mode") == "beats"
|
||||||
|
assert updated["step_duration_ms"] == 5000
|
||||||
|
assert updated["loop"] is True
|
||||||
|
|
||||||
print("\nTesting list sequences")
|
print("\nTesting list sequences")
|
||||||
sequence_list = sequences.list()
|
sequence_list = sequences.list()
|
||||||
@@ -58,5 +76,5 @@ def test_sequence():
|
|||||||
print("\nAll sequence tests passed!")
|
print("\nAll sequence tests passed!")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
test_sequence()
|
test_sequence()
|
||||||
|
|||||||
@@ -750,6 +750,46 @@ def test_presets_ui(browser: BrowserTest) -> bool:
|
|||||||
print(f"\nBrowser presets UI tests: {passed}/{total} passed")
|
print(f"\nBrowser presets UI tests: {passed}/{total} passed")
|
||||||
return passed == total
|
return passed == total
|
||||||
|
|
||||||
|
def test_preset_editor_palette_child_modal_zindex(browser: BrowserTest) -> bool:
|
||||||
|
"""
|
||||||
|
Regression: preset editor 'From Palette' builds a body-appended .modal without an id.
|
||||||
|
It must use .modal-child-overlay so z-index clears #preset-editor-modal (1060).
|
||||||
|
"""
|
||||||
|
print("\n=== Testing preset child overlay z-index ===")
|
||||||
|
if not browser.setup():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
if not browser.navigate("/"):
|
||||||
|
return False
|
||||||
|
z_editor, z_child = browser.driver.execute_script(
|
||||||
|
"""
|
||||||
|
const ed = document.getElementById('preset-editor-modal');
|
||||||
|
if (!ed) { return [0, 0]; }
|
||||||
|
ed.classList.add('active');
|
||||||
|
const m = document.createElement('div');
|
||||||
|
m.className = 'modal active modal-child-overlay';
|
||||||
|
m.innerHTML = '<div class="modal-content"><p>stack probe</p></div>';
|
||||||
|
document.body.appendChild(m);
|
||||||
|
const ze = parseInt(getComputedStyle(ed).zIndex, 10) || 0;
|
||||||
|
const zc = parseInt(getComputedStyle(m).zIndex, 10) || 0;
|
||||||
|
m.remove();
|
||||||
|
ed.classList.remove('active');
|
||||||
|
return [ze, zc];
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
print(f" preset-editor z-index={z_editor} modal-child-overlay z-index={z_child}")
|
||||||
|
if z_child > z_editor >= 1000:
|
||||||
|
print("✓ Child overlay stacks above preset editor")
|
||||||
|
return True
|
||||||
|
print("✗ Child overlay did not stack above preset editor")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ z-index probe failed: {e}")
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
browser.teardown()
|
||||||
|
|
||||||
|
|
||||||
def test_color_palette_ui(browser: BrowserTest) -> bool:
|
def test_color_palette_ui(browser: BrowserTest) -> bool:
|
||||||
"""Test color palette UI in browser."""
|
"""Test color palette UI in browser."""
|
||||||
print("\n=== Testing Color Palette UI in Browser ===")
|
print("\n=== Testing Color Palette UI in Browser ===")
|
||||||
@@ -1105,6 +1145,9 @@ def main():
|
|||||||
results.append(("Zones UI", test_zones_ui(browser)))
|
results.append(("Zones UI", test_zones_ui(browser)))
|
||||||
results.append(("Profiles UI", test_profiles_ui(browser)))
|
results.append(("Profiles UI", test_profiles_ui(browser)))
|
||||||
results.append(("Presets UI", test_presets_ui(browser)))
|
results.append(("Presets UI", test_presets_ui(browser)))
|
||||||
|
results.append(
|
||||||
|
("Preset palette child z-index", test_preset_editor_palette_child_modal_zindex(browser))
|
||||||
|
)
|
||||||
results.append(("Color Palette UI", test_color_palette_ui(browser)))
|
results.append(("Color Palette UI", test_color_palette_ui(browser)))
|
||||||
results.append(("Preset Drag and Drop", test_preset_drag_and_drop(browser)))
|
results.append(("Preset Drag and Drop", test_preset_drag_and_drop(browser)))
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ def server(monkeypatch, tmp_path_factory):
|
|||||||
import models.pallet as models_pallet # noqa: E402
|
import models.pallet as models_pallet # noqa: E402
|
||||||
import models.scene as models_scene # noqa: E402
|
import models.scene as models_scene # noqa: E402
|
||||||
import models.pattern as models_pattern # noqa: E402
|
import models.pattern as models_pattern # noqa: E402
|
||||||
import models.squence as models_sequence # noqa: E402
|
import models.sequence as models_sequence # noqa: E402
|
||||||
import models.device as models_device # noqa: E402
|
import models.device as models_device # noqa: E402
|
||||||
|
|
||||||
for cls in (
|
for cls in (
|
||||||
@@ -527,21 +527,24 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
# Sequences.
|
# Sequences.
|
||||||
unique_seq_group_name = f"pytest-seq-group-{uuid.uuid4().hex[:8]}"
|
unique_seq_name = f"pytest-seq-{uuid.uuid4().hex[:8]}"
|
||||||
resp = c.post(
|
resp = c.post(
|
||||||
f"{base_url}/sequences",
|
f"{base_url}/sequences",
|
||||||
json={"group_name": unique_seq_group_name, "presets": []},
|
json={
|
||||||
|
"name": unique_seq_name,
|
||||||
|
"steps": [{"preset_id": "1", "group_ids": []}],
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
sequences_list = c.get(f"{base_url}/sequences").json()
|
sequences_list = c.get(f"{base_url}/sequences").json()
|
||||||
seq_id = _find_id_by_field(sequences_list, "group_name", unique_seq_group_name)
|
seq_id = _find_id_by_field(sequences_list, "name", unique_seq_name)
|
||||||
|
|
||||||
resp = c.get(f"{base_url}/sequences/{seq_id}")
|
resp = c.get(f"{base_url}/sequences/{seq_id}")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
resp = c.put(f"{base_url}/sequences/{seq_id}", json={"sequence_duration": 1234})
|
resp = c.put(f"{base_url}/sequences/{seq_id}", json={"step_duration_ms": 1234})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["sequence_duration"] == 1234
|
assert resp.json()["step_duration_ms"] == 1234
|
||||||
|
|
||||||
resp = c.delete(f"{base_url}/sequences/{seq_id}")
|
resp = c.delete(f"{base_url}/sequences/{seq_id}")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|||||||
221
tools/generate_beat_test_track.py
Normal file
221
tools/generate_beat_test_track.py
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Metronome-style mono click track for testing the audio beat detector.
|
||||||
|
|
||||||
|
Without ``-o``: streams S16LE PCM to ``aplay`` (stdin) until you press Ctrl+C.
|
||||||
|
|
||||||
|
With ``-o``: writes a WAV file of fixed length and exits.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
python3 tools/generate_beat_test_track.py
|
||||||
|
python3 tools/generate_beat_test_track.py --bpm 90
|
||||||
|
python3 tools/generate_beat_test_track.py -o tests/audio/beat_test_120bpm.wav --duration 30
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import math
|
||||||
|
import shutil
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import wave
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_args() -> argparse.Namespace:
|
||||||
|
p = argparse.ArgumentParser(description=__doc__)
|
||||||
|
p.add_argument(
|
||||||
|
"-o",
|
||||||
|
"--output",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
help="If set, write this WAV file and exit (no live playback)",
|
||||||
|
)
|
||||||
|
p.add_argument("--bpm", type=float, default=120.0, help="Beats per minute (default: 120)")
|
||||||
|
p.add_argument(
|
||||||
|
"--duration",
|
||||||
|
type=float,
|
||||||
|
default=30.0,
|
||||||
|
help="With -o only: click section length in seconds after intro (default: 30)",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--intro-silence",
|
||||||
|
type=float,
|
||||||
|
default=0.5,
|
||||||
|
help="Leading silence in seconds (default: 0.5)",
|
||||||
|
)
|
||||||
|
p.add_argument("--sample-rate", type=int, default=44100, help="Sample rate Hz (default: 44100)")
|
||||||
|
p.add_argument(
|
||||||
|
"--click-ms",
|
||||||
|
type=float,
|
||||||
|
default=18.0,
|
||||||
|
help="Approximate click length in ms (default: 18)",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--freq",
|
||||||
|
type=float,
|
||||||
|
default=1000.0,
|
||||||
|
help="Click sine frequency Hz (default: 1000)",
|
||||||
|
)
|
||||||
|
return p.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def _click_int16_samples(sr: int, click_ms: float, freq: float) -> tuple[list[int], int]:
|
||||||
|
"""One click; returns samples and click_len (same as len(samples))."""
|
||||||
|
click_len = max(1, int(sr * max(4.0, click_ms) / 1000.0))
|
||||||
|
freq_clamped = max(200.0, min(4000.0, float(freq)))
|
||||||
|
floats: list[float] = []
|
||||||
|
for i in range(click_len):
|
||||||
|
t = i / sr
|
||||||
|
env = math.sin(0.5 * math.pi * (i + 1) / click_len) ** 2
|
||||||
|
floats.append(env * math.sin(2.0 * math.pi * freq_clamped * t))
|
||||||
|
peak = max(abs(x) for x in floats) or 1.0
|
||||||
|
scale = 0.92 / peak
|
||||||
|
out: list[int] = []
|
||||||
|
for x in floats:
|
||||||
|
v = int(round(max(-1.0, min(1.0, x * scale)) * 32767.0))
|
||||||
|
out.append(max(-32767, min(32767, v)))
|
||||||
|
return out, click_len
|
||||||
|
|
||||||
|
|
||||||
|
def _render_scaled_samples(
|
||||||
|
sr: int,
|
||||||
|
bpm: float,
|
||||||
|
intro: float,
|
||||||
|
dur: float,
|
||||||
|
click_ms: float,
|
||||||
|
freq: float,
|
||||||
|
) -> tuple[list[float], int, float, int]:
|
||||||
|
beat_sec = 60.0 / bpm
|
||||||
|
click_len = max(1, int(sr * max(4.0, click_ms) / 1000.0))
|
||||||
|
freq_clamped = max(200.0, min(4000.0, float(freq)))
|
||||||
|
total_sec = intro + dur
|
||||||
|
n_samples = int(sr * total_sec)
|
||||||
|
intro_samples = int(sr * intro)
|
||||||
|
|
||||||
|
samples = [0.0] * n_samples
|
||||||
|
beat_samples = int(round(sr * beat_sec))
|
||||||
|
if beat_samples < click_len + 1:
|
||||||
|
raise SystemExit("BPM too high for this sample rate / click length")
|
||||||
|
|
||||||
|
beat_idx = 0
|
||||||
|
while True:
|
||||||
|
start = intro_samples + beat_idx * beat_samples
|
||||||
|
if start >= n_samples:
|
||||||
|
break
|
||||||
|
for i in range(click_len):
|
||||||
|
pos = start + i
|
||||||
|
if pos >= n_samples:
|
||||||
|
break
|
||||||
|
t = i / sr
|
||||||
|
env = math.sin(0.5 * math.pi * (i + 1) / click_len) ** 2
|
||||||
|
s = env * math.sin(2.0 * math.pi * freq_clamped * t)
|
||||||
|
samples[pos] += s
|
||||||
|
beat_idx += 1
|
||||||
|
|
||||||
|
peak = max(abs(x) for x in samples) or 1.0
|
||||||
|
scale = 0.92 / peak
|
||||||
|
for i in range(n_samples):
|
||||||
|
samples[i] = max(-1.0, min(1.0, samples[i] * scale))
|
||||||
|
|
||||||
|
return samples, sr, total_sec, beat_idx
|
||||||
|
|
||||||
|
|
||||||
|
def write_wav_mono16(path: Path, samples: list[float], sr: int) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with wave.open(str(path), "w") as w:
|
||||||
|
w.setnchannels(1)
|
||||||
|
w.setsampwidth(2)
|
||||||
|
w.setframerate(sr)
|
||||||
|
for x in samples:
|
||||||
|
v = int(round(x * 32767.0))
|
||||||
|
w.writeframes(struct.pack("<h", v))
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_aplay_until_interrupt(
|
||||||
|
sr: int,
|
||||||
|
bpm: float,
|
||||||
|
intro_silence: float,
|
||||||
|
click_ms: float,
|
||||||
|
freq: float,
|
||||||
|
) -> None:
|
||||||
|
aplay = shutil.which("aplay")
|
||||||
|
if not aplay:
|
||||||
|
raise SystemExit("aplay not found; install alsa-utils, or use -o to write a WAV file.")
|
||||||
|
|
||||||
|
click_samps, click_len = _click_int16_samples(sr, click_ms, freq)
|
||||||
|
beat_samples = int(round(sr * 60.0 / bpm))
|
||||||
|
if beat_samples < click_len + 1:
|
||||||
|
raise SystemExit("BPM too high for this sample rate / click length")
|
||||||
|
|
||||||
|
silence_samples = beat_samples - click_len
|
||||||
|
beat_chunk = struct.pack("<" + "h" * len(click_samps), *click_samps) + (
|
||||||
|
b"\x00\x00" * silence_samples
|
||||||
|
)
|
||||||
|
|
||||||
|
intro_samples = int(sr * max(0.0, float(intro_silence)))
|
||||||
|
intro_chunk = b"\x00\x00" * intro_samples
|
||||||
|
|
||||||
|
argv = [aplay, "-q", "-t", "raw", "-f", "S16_LE", "-c", "1", "-r", str(sr)]
|
||||||
|
proc = subprocess.Popen(argv, stdin=subprocess.PIPE)
|
||||||
|
if proc.stdin is None:
|
||||||
|
raise SystemExit("aplay did not open stdin")
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Streaming {bpm} BPM, {sr} Hz mono -> aplay (raw). Ctrl+C to stop.",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if intro_chunk:
|
||||||
|
proc.stdin.write(intro_chunk)
|
||||||
|
beats_written = 0
|
||||||
|
# Write several beats per syscall to reduce overhead
|
||||||
|
batch = 8
|
||||||
|
multi = beat_chunk * batch
|
||||||
|
while True:
|
||||||
|
proc.stdin.write(multi)
|
||||||
|
beats_written += batch
|
||||||
|
if beats_written % 256 == 0:
|
||||||
|
proc.stdin.flush()
|
||||||
|
except BrokenPipeError:
|
||||||
|
print("aplay exited.", flush=True)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nStopped.", flush=True)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
proc.stdin.close()
|
||||||
|
except BrokenPipeError:
|
||||||
|
pass
|
||||||
|
proc.wait(timeout=3)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = _parse_args()
|
||||||
|
sr = max(8000, min(96000, int(args.sample_rate)))
|
||||||
|
bpm = max(40.0, min(240.0, float(args.bpm)))
|
||||||
|
|
||||||
|
if args.output is not None:
|
||||||
|
intro = max(0.0, float(args.intro_silence))
|
||||||
|
dur = max(1.0, float(args.duration))
|
||||||
|
samples, sr_u, total_sec, beats = _render_scaled_samples(
|
||||||
|
sr, bpm, intro, dur, float(args.click_ms), float(args.freq)
|
||||||
|
)
|
||||||
|
write_wav_mono16(args.output, samples, sr_u)
|
||||||
|
print(
|
||||||
|
f"Wrote {args.output} ({len(samples)} samples, {total_sec:.1f}s, {sr_u} Hz mono): "
|
||||||
|
f"{bpm} BPM, ~{beats} beats"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
_stream_aplay_until_interrupt(
|
||||||
|
sr,
|
||||||
|
bpm,
|
||||||
|
float(args.intro_silence),
|
||||||
|
float(args.click_ms),
|
||||||
|
float(args.freq),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user