feat(audio): move beat routing server-side and extend presets

Route beat-triggered manual selects from the controller server, add preset background and beat-counter UI support, and bump led-driver to include the matching pattern/runtime fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-09 20:08:05 +12:00
parent 1db905eaae
commit 822d9d8e01
21 changed files with 2453 additions and 109 deletions

View File

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