"""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, Tuple _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] = { "enabled": False, "device_names": [], "wire_preset_id": "2", "is_manual": False, "pattern": "", "manual_beat_n": 1, } _beat_counter: int = 0 _preset_session_beats: 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 _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: """Internal: set or clear routing from explicit fields (tests / future APIs).""" global _lane_manual, _beat_route, _beat_counter, _preset_session_beats if not isinstance(payload, dict): return with _route_lock: if payload.get("enabled") is False: _lane_manual.clear() _beat_route = { **_beat_route, "enabled": False, "is_manual": False, "device_names": [], } _beat_counter = 0 _preset_session_beats = 0 return old = dict(_beat_route) 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)) new_wire = str(payload.get("wire_preset_id") or "2") old_wire = str(old.get("wire_preset_id") or "2") if not old.get("enabled") or old_wire != new_wire: _preset_session_beats = 0 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(), "manual_beat_n": manual_n, "beat_counter": 0, } _sync_public_beat_route_from_lane_table() def get_beat_route() -> Dict[str, Any]: with _route_lock: 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: """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 _registry_names_for_macs(macs: Optional[List[str]]) -> List[str]: """Resolve push ``targets`` MAC list to registry device names (order preserved, de-duplicated).""" if not macs: return [] from models.device import Device, normalize_mac devices = Device() out: List[str] = [] seen: Set[str] = set() for raw in macs: m = normalize_mac(str(raw)) if not m: continue doc = devices.read(m) or {} nm = str(doc.get("name") or "").strip() if nm and nm not in seen: seen.add(nm) out.append(nm) return out def _single_manual_wire_preset( merged_presets: Dict[str, Any], ) -> tuple[Optional[str], Optional[Dict[str, Any]]]: """If exactly one manual (non-auto) preset is present, return its wire id and body.""" manual: List[tuple[str, Dict[str, Any]]] = [] for wid, body in merged_presets.items(): if not isinstance(body, dict): continue if _coerce_auto_from_body(body): continue manual.append((str(wid).strip(), body)) if len(manual) != 1: return None, None return manual[0][0], manual[0][1] def _apply_manual_beat_route( device_names: List[str], wire_preset_id: str, preset_body: Any, group_ids: Optional[List[str]] = None, ) -> None: """Enable audio→driver routing for one manual preset (clears all lanes, including sequence).""" global _lane_manual gids = [str(g).strip() for g in (group_ids or []) if str(g).strip()] if not device_names and not gids: with _route_lock: _lane_manual.clear() _sync_public_beat_route_from_lane_table() return if not isinstance(preset_body, dict): with _route_lock: _lane_manual.clear() _sync_public_beat_route_from_lane_table() return if _coerce_auto_from_body(preset_body): with _route_lock: _lane_manual.clear() _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: _lane_manual.clear() _sync_public_beat_route_from_lane_table() return names = [str(n).strip() for n in device_names if str(n).strip()] with _route_lock: _lane_manual.clear() _lane_manual[-1] = { "device_names": names, "wire_preset_id": str(wire_preset_id).strip(), "pattern": pattern, "manual_beat_n": _coerce_manual_beat_n(preset_body), "beat_counter": 0, "group_ids": gids, } _sync_public_beat_route_from_lane_table() def _apply_manual_beat_route_standalone_overlay( device_names: List[str], wire_preset_id: str, preset_body: Any, group_ids: Optional[List[str]] = None, ) -> None: """Register manual beat routing on lane ``-1`` only, keeping sequence lanes ``0..n`` intact.""" global _lane_manual gids = [str(g).strip() for g in (group_ids or []) if str(g).strip()] if not device_names and not gids: with _route_lock: _lane_manual.pop(-1, None) _sync_public_beat_route_from_lane_table() return if not isinstance(preset_body, dict): with _route_lock: _lane_manual.pop(-1, None) _sync_public_beat_route_from_lane_table() return if _coerce_auto_from_body(preset_body): with _route_lock: _lane_manual.pop(-1, None) _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: _lane_manual.pop(-1, None) _sync_public_beat_route_from_lane_table() return names = [str(n).strip() for n in device_names if str(n).strip()] with _route_lock: if _sequence_lane_covers_standalone_overlay(names, str(wire_preset_id).strip()): _lane_manual.pop(-1, None) _sync_public_beat_route_from_lane_table() return _lane_manual[-1] = { "device_names": names, "wire_preset_id": str(wire_preset_id).strip(), "pattern": pattern, "manual_beat_n": _coerce_manual_beat_n(preset_body), "beat_counter": 0, "group_ids": gids, } _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, group_ids: Optional[List[str]] = None, ) -> 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()] gids = [str(g).strip() for g in (group_ids or []) if str(g).strip()] if (not names and not gids) 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, "group_ids": gids, } overlay = _lane_manual.get(-1) if overlay and _lane_route_targets_key(names, wid) == _lane_route_targets_key( overlay.get("device_names") or [], str(overlay.get("wire_preset_id") or "") ): _lane_manual.pop(-1, None) _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 _lane_route_targets_key(device_names: List[str], wire_preset_id: str) -> Tuple[Tuple[str, ...], str]: names = tuple(sorted({str(n).strip() for n in (device_names or []) if str(n).strip()})) return names, str(wire_preset_id or "").strip() def _sequence_lane_covers_standalone_overlay(device_names: List[str], wire_preset_id: str) -> bool: """True when a sequence lane (0..n) already routes the same device(s) and wire preset.""" key = _lane_route_targets_key(device_names, wire_preset_id) for lane_key, entry in _lane_manual.items(): if not isinstance(lane_key, int) or lane_key < 0: continue other = _lane_route_targets_key( entry.get("device_names") or [], str(entry.get("wire_preset_id") or "") ) if other == key: return True return False def mark_manual_select_sent_for_targets( device_names: List[str], wire_preset_id: str ) -> None: """A ``select`` was just sent for these targets; skip one duplicate on the next beat.""" key = _lane_route_targets_key(device_names, wire_preset_id) with _route_lock: for entry in _lane_manual.values(): if not isinstance(entry, dict): continue other = _lane_route_targets_key( entry.get("device_names") or [], str(entry.get("wire_preset_id") or "") ) if other == key: entry["suppress_next_notify"] = True def mark_sequence_manual_lane_select_sent(lane_index: int) -> None: """A ``select`` was just sent for this lane; skip one duplicate on the next beat.""" with _route_lock: e = _lane_manual.get(lane_index) if e is not None: e["suppress_next_notify"] = True def reset_manual_lane_strides() -> None: """Zero manual-lane beat counters after a sequence change (routes unchanged).""" global _preset_session_beats with _route_lock: _preset_session_beats = 0 for e in _lane_manual.values(): if isinstance(e, dict): e["beat_counter"] = 0 def sync_beat_route_from_push_sequence( sequence: List[Any], target_macs: Optional[List[str]] = None, *, preserve_parallel_lane_routes: bool = False, ) -> None: """ Update beat routing from a ``/presets/push`` body ``sequence`` (list of v1 dicts). With ``select`` as ``[preset_id, step?]``: use ``target_macs`` for device names. Legacy name-map ``select`` still uses map keys as device names. 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 registry names for those MACs so the first advance is on the next audio beat. When ``preserve_parallel_lane_routes`` is true (e.g. zone sequence playback is active), an auto preset in ``select`` does not clear manual routing — other lanes still receive ``notify_beat_detected``. A manual preset in ``select`` is applied on lane ``-1`` only so sequence lanes ``0..n`` keep their stride counters and wire ids. """ merged_presets: Dict[str, Any] = {} last_select_list: Optional[List[Any]] = None last_select_map: Optional[Dict[str, Any]] = None last_group_ids: Optional[List[str]] = 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, list) and sel: last_select_list = sel elif isinstance(sel, dict) and sel: last_select_map = sel gr = item.get("groups") if isinstance(gr, list) and gr: last_group_ids = [str(g).strip() for g in gr if str(g).strip()] if last_select_list: device_names = _registry_names_for_macs(target_macs) if not device_names and not last_group_ids: if not preserve_parallel_lane_routes: update_beat_route({"enabled": False}) return wire_preset_id = str(last_select_list[0]).strip() if not wire_preset_id: if not preserve_parallel_lane_routes: update_beat_route({"enabled": False}) return elif last_select_map: device_names = [str(k).strip() for k in last_select_map.keys() if str(k).strip()] if not device_names: if not preserve_parallel_lane_routes: update_beat_route({"enabled": False}) return wire_ids: Set[str] = set() for name in device_names: val = last_select_map.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: if not preserve_parallel_lane_routes: update_beat_route({"enabled": False}) return wire_preset_id = wire_ids.pop() else: wire_preset_id = None if wire_preset_id is not None: 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 preset_body is None: if not preserve_parallel_lane_routes: update_beat_route({"enabled": False}) return if _coerce_auto_from_body(preset_body): if not preserve_parallel_lane_routes: update_beat_route({"enabled": False}) return if preserve_parallel_lane_routes: _apply_manual_beat_route_standalone_overlay( device_names, wire_preset_id, preset_body, group_ids=last_group_ids ) else: _apply_manual_beat_route( device_names, wire_preset_id, preset_body, group_ids=last_group_ids ) mark_manual_select_sent_for_targets(device_names, wire_preset_id) return wire_id, body = _single_manual_wire_preset(merged_presets) if wire_id and body is not None: names = _registry_names_for_macs(target_macs) if preserve_parallel_lane_routes: _apply_manual_beat_route_standalone_overlay( names, wire_id, body, group_ids=last_group_ids ) else: _apply_manual_beat_route(names, wire_id, body, group_ids=last_group_ids) return if not preserve_parallel_lane_routes: update_beat_route({"enabled": False}) 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 remap_beat_route_device_name(old_name: str, new_name: str) -> None: """Update cached audio-beat target names after a device registry rename.""" global _lane_manual o = str(old_name or "").strip() n = str(new_name or "").strip() if not o or not n or o == n: return with _route_lock: any_changed = False for e in _lane_manual.values(): names = e.get("device_names") or [] if not isinstance(names, list): continue new_list: List[str] = [] row_changed = False for item in names: if str(item).strip() == o: new_list.append(n) row_changed = True else: 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( wire_preset_id: str, group_ids: Optional[List[str]] = None, ) -> None: from models.device import Device from models.transport import get_current_bridge from util.driver_delivery import deliver_json_messages bridge = get_current_bridge() if not bridge: return devices = Device() gids = [str(g).strip() for g in (group_ids or []) if str(g).strip()] body: Dict[str, Any] = {"v": "1", "select": [wire_preset_id]} if gids: body["groups"] = gids msg = json.dumps(body, separators=(",", ":")) try: await deliver_json_messages(bridge, [msg], None, devices, delay_s=0.05) except Exception as e: print(f"[beat-route] deliver failed: {e}") async def _deliver_select_batch(pairs: List[Tuple[str, Optional[List[str]]]]) -> None: for pid, gids in pairs: await _deliver_select(pid, gids) def notify_beat_detected() -> None: """Invoked from the audio thread when a beat is detected. Only manual presets are registered in ``_lane_manual`` (auto presets are cleared on step change and get ``select`` from sequence/UI only when the preset changes). """ global _preset_session_beats work: List[Tuple[str, Optional[List[str]]]] = [] with _route_lock: if not _lane_manual: return work = [] seen_targets: Set[Tuple[Tuple[str, ...], str]] = set() for key in sorted(_lane_manual.keys()): e = _lane_manual[key] names = e.get("device_names") or [] if not isinstance(names, list): names = [] gids_raw = e.get("group_ids") or [] gids = ( [str(g).strip() for g in gids_raw if str(g).strip()] if isinstance(gids_raw, list) else [] ) if not names and not gids: continue pattern = str(e.get("pattern") or "") if pattern and not _pattern_supports_manual(pattern): continue if e.pop("suppress_next_notify", False): continue try: n = int(e.get("manual_beat_n") or 1) except (TypeError, ValueError): n = 1 n = max(1, min(64, n)) e["beat_counter"] = int(e.get("beat_counter", 0)) + 1 c = int(e["beat_counter"]) if (c - 1) % n != 0: continue wire = str(e.get("wire_preset_id") or "2") target_key = ( (tuple(sorted(gids)), wire) if gids else _lane_route_targets_key(names, wire) ) if target_key in seen_targets: continue seen_targets.add(target_key) work.append((wire, gids or None)) if work: _preset_session_beats += 1 if not work: return loop = _main_loop if loop is None: return try: asyncio.run_coroutine_threadsafe(_deliver_select_batch(work), loop) except Exception as e: print(f"[beat-route] schedule failed: {e}")