689 lines
24 KiB
Python
689 lines
24 KiB
Python
"""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}")
|