3 Commits

Author SHA1 Message Date
c1c3e5d71b feat(ui): edit tab zones, audio readout, live reload
- Zones/presets/sequence strip and Pipfile dev command fix
- Optional live reload and beat test audio asset + generator

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 00:44:20 +12:00
c64dd736f2 feat(api): parallel group sends and batch identify
- asyncio.gather for group brightness and driver-config Wi-Fi pushes
- Batch identify envelope for group members

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 00:44:13 +12:00
cad0aa7e59 feat(sequences): multi-lane playback and per-lane manual beats
- Add sequence_playback with beat and time advance, zone targeting fixes
- Per-lane manual beat routing in beat_driver_route (parallel lanes)
- Sequence API, editor JS, fix sequence model filename, tests

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 00:44:08 +12:00
29 changed files with 4382 additions and 367 deletions

View File

@@ -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'"

View File

@@ -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}}

View File

@@ -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

View File

@@ -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": [[]]}}

View File

@@ -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, { return json.dumps({"message": "Identify sent"}), 200, {
"Content-Type": "application/json", "Content-Type": "application/json",
} }
sender = get_current_sender() return json.dumps({"error": err}), status, {"Content-Type": "application/json"}
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, {
"Content-Type": "application/json",
}
@controller.post("/<id>/brightness") @controller.post("/<id>/brightness")

View File

@@ -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,14 +136,17 @@ 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):
sent += 1 if r is True:
else: sent += 1
errors.append({"mac": m, "error": "driver not connected"}) elif isinstance(r, Exception):
errors.append({"mac": m, "error": str(r)})
else:
errors.append({"mac": m, "error": "driver not connected"})
return json.dumps( return json.dumps(
{"message": "driver-config sent", "sent": sent, "errors": errors} {"message": "driver-config sent", "sent": sent, "errors": errors}
@@ -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)
return m, bool(ok), None if ok else "driver not connected"
if not sender:
return m, False, "transport not configured"
try:
await sender.send(msg, addr=m)
return m, True, None
except Exception as 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: if ok:
sent += 1 sent += 1
else: elif err:
errors.append({"mac": m, "error": "driver not connected"}) errors.append({"mac": m, "error": err})
else:
sender = get_current_sender()
if not sender:
errors.append({"mac": m, "error": "transport not configured"})
continue
try:
await sender.send(msg, addr=m)
sent += 1
except Exception as e:
errors.append({"mac": m, "error": str(e)})
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"}

View File

@@ -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):
"""Create a new sequence."""
try:
data = request.json or {}
group_name = data.get("group_name", "")
preset_names = data.get("presets", None)
sequence_id = sequences.create(group_name, preset_names)
if data:
sequences.update(sequence_id, data)
return json.dumps(sequences.read(sequence_id)), 201, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put('/<id>') @controller.post("")
async def update_sequence(request, id): @with_session
"""Update an existing sequence.""" async def create_sequence(request, session):
"""Create a new sequence for the current profile."""
try: try:
try:
data = request.json or {}
except Exception:
return (
json.dumps({"error": "Invalid JSON"}),
400,
{"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:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
@controller.put("/<id>")
@with_session
async def update_sequence(request, session, id):
"""Update an existing sequence (current profile only)."""
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
async def delete_sequence(request, session, id):
"""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
try:
from util.sequence_playback import stop_if_playing_sequence
stop_if_playing_sequence(str(id))
except Exception:
pass
if sequences.delete(id): if sequences.delete(id):
return json.dumps({"message": "Sequence deleted successfully"}), 200 return (
json.dumps({"message": "Sequence deleted successfully"}),
200,
{"Content-Type": "application/json"},
)
return json.dumps({"error": "Sequence not found"}), 404 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"}

View File

@@ -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
View 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())

View File

@@ -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())

View File

@@ -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,

View File

@@ -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();
}); });
})(); })();

View 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();
})();

View File

@@ -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);
}
};
}); });

View File

@@ -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,7 +1324,10 @@ 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}`, {
method: 'PUT', method: 'PUT',
@@ -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,13 +2118,10 @@ 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
const empty = document.createElement('p'); const empty = document.createElement('p');
@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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;

View File

@@ -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;
sendZoneBrightness(zoneId, normalizedBrightness); 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);
}
// 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)) { async function saveZonePresetGroupOverride(zoneId, presetId, useDefault, selectedGids) {
if (tabData.presets.length && typeof tabData.presets[0] === "string") { const tabRes = await fetch(`/zones/${zoneId}`, { headers: { Accept: "application/json" } });
ids = tabData.presets.slice(); if (!tabRes.ok) {
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) { alert("Failed to load zone.");
ids = tabData.presets.flat(); 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,9 +1504,14 @@ 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 () => {
await loadZones(); suppressZoneContentDriverSideEffects = true;
if (zonesModal && zonesModal.classList.contains("active")) { try {
await loadZonesModal(); await loadZones();
if (zonesModal && zonesModal.classList.contains("active")) {
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;

View File

@@ -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">
<span class="audio-top-indicator-label">BPM</span> <div class="audio-top-indicator-main">
<span id="audio-top-bpm-value" class="audio-top-indicator-value">--</span> <span class="audio-top-indicator-label">BPM</span>
<span id="audio-top-beat-count" class="audio-top-indicator-subvalue">#0</span> <span id="audio-top-bpm-value" class="audio-top-indicator-value">--</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 (0255)</label> <label for="edit-group-output-brightness" style="margin-top:0.75rem;display:block;">Group output brightness (0255)</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 id="audio-bpm-value" class="audio-bpm-readout">--</div> <div class="audio-bpm-row">
<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>

View File

@@ -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:

View 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}")

View File

@@ -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 []
new_list: List[str] = [] if not isinstance(names, list):
changed = False continue
for item in names: new_list: List[str] = []
if str(item).strip() == o: row_changed = False
new_list.append(n) for item in names:
changed = True if str(item).strip() == o:
else: new_list.append(n)
new_list.append(str(item)) row_changed = True
if changed: else:
_beat_route = {**_beat_route, "device_names": new_list} new_list.append(str(item))
if row_changed:
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]
if pattern and not _pattern_supports_manual(pattern): names = e.get("device_names") or []
return if not isinstance(names, list) or not names:
names = r.get("device_names") or [] continue
if not names: pattern = str(e.get("pattern") or "")
return if pattern and not _pattern_supports_manual(pattern):
try: continue
n = int(r.get("manual_beat_n") or 1) try:
except (TypeError, ValueError): n = int(e.get("manual_beat_n") or 1)
n = 1 except (TypeError, ValueError):
n = max(1, min(64, n)) n = 1
_beat_counter += 1 n = max(1, min(64, n))
if ((_beat_counter - 1) % n) != 0: e["beat_counter"] = int(e.get("beat_counter", 0)) + 1
return c = int(e["beat_counter"])
preset_id = str(r.get("wire_preset_id") or "2") if (c - 1) % n != 0:
names_copy = list(names) continue
work.append((list(names), str(e.get("wire_preset_id") or "2")))
if work:
_preset_session_beats += 1
if not work:
return
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}")

View 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())

Binary file not shown.

View File

@@ -1,62 +1,80 @@
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
print("\nTesting read sequence") print("\nTesting read 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()
print(f"Sequence list: {sequence_list}") print(f"Sequence list: {sequence_list}")
assert sequence_id in sequence_list assert sequence_id in sequence_list
print("\nTesting delete sequence") print("\nTesting delete sequence")
deleted = sequences.delete(sequence_id) deleted = sequences.delete(sequence_id)
assert deleted is True assert deleted is True
assert sequence_id not in sequences assert sequence_id not in sequences
print("\nTesting read after delete") print("\nTesting read after delete")
sequence = sequences.read(sequence_id) sequence = sequences.read(sequence_id)
assert sequence is None assert sequence is None
print("\nAll sequence tests passed!") print("\nAll sequence tests passed!")
if __name__ == '__main__': if __name__ == "__main__":
test_sequence() test_sequence()

View File

@@ -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)))

View File

@@ -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

View 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()