feat(zones): profile-scoped groups, zone modes, sequence brightness

- Optional profile_id on groups; UI and API for shared vs profile-only groups\n- Zone content_kind (presets vs sequences); edit modal shows matching sections; devices via groups only\n- Server sequence playback folds zone brightness into preset wire b (per MAC where needed)\n- Related preset/sequence/audio/beat-route and client updates

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-13 01:58:00 +12:00
parent c1c3e5d71b
commit 6c9e06f33b
21 changed files with 1034 additions and 604 deletions

View File

@@ -280,3 +280,22 @@ class AudioBeatDetector:
with self._lock:
self._running = False
self._status["running"] = False
# Set from ``main`` so sequence playback can tell real audio from simulated beats.
_shared_beat_detector = None
def set_shared_beat_detector(det):
global _shared_beat_detector
_shared_beat_detector = det
def shared_beat_detector_running():
d = _shared_beat_detector
if d is None:
return False
try:
return bool(d.status().get("running"))
except Exception:
return False

View File

@@ -233,7 +233,7 @@ def _apply_manual_beat_route(
wire_preset_id: str,
preset_body: Any,
) -> None:
"""Enable audio→driver routing for one manual preset, or disable if invalid."""
"""Enable audio→driver routing for one manual preset (clears all lanes, including sequence)."""
global _lane_manual
if not device_names:
with _route_lock:
@@ -269,6 +269,46 @@ def _apply_manual_beat_route(
_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,
) -> None:
"""Register manual beat routing on lane ``-1`` only, keeping sequence lanes ``0..n`` intact."""
global _lane_manual
if not device_names:
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:
_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,
}
_sync_public_beat_route_from_lane_table()
def set_sequence_manual_lane_route(
lane_index: int,
device_names: List[str],
@@ -326,7 +366,7 @@ def sync_beat_route_from_push_sequence(
sequence: List[Any],
target_macs: Optional[List[str]] = None,
*,
preserve_manual_beat_route_on_auto_select: bool = False,
preserve_parallel_lane_routes: bool = False,
) -> None:
"""
Update beat routing from a ``/presets/push`` body ``sequence`` (list of v1 dicts).
@@ -337,9 +377,10 @@ def sync_beat_route_from_push_sequence(
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_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.
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: Optional[Dict[str, Any]] = None
@@ -361,7 +402,8 @@ def sync_beat_route_from_push_sequence(
if last_select:
device_names = [str(k).strip() for k in last_select.keys() if str(k).strip()]
if not device_names:
update_beat_route({"enabled": False})
if not preserve_parallel_lane_routes:
update_beat_route({"enabled": False})
return
wire_ids: Set[str] = set()
@@ -372,7 +414,8 @@ def sync_beat_route_from_push_sequence(
elif val is not None:
wire_ids.add(str(val).strip())
if len(wire_ids) != 1:
update_beat_route({"enabled": False})
if not preserve_parallel_lane_routes:
update_beat_route({"enabled": False})
return
wire_preset_id = wire_ids.pop()
preset_body = merged_presets.get(wire_preset_id)
@@ -382,22 +425,32 @@ def sync_beat_route_from_push_sequence(
preset_body = v
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:
if not preserve_parallel_lane_routes:
update_beat_route({"enabled": False})
return
_apply_manual_beat_route(device_names, wire_preset_id, preset_body)
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
)
else:
_apply_manual_beat_route(device_names, wire_preset_id, preset_body)
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)
_apply_manual_beat_route(names, wire_id, body)
if preserve_parallel_lane_routes:
_apply_manual_beat_route_standalone_overlay(names, wire_id, body)
else:
_apply_manual_beat_route(names, wire_id, body)
return
update_beat_route({"enabled": False})
if not preserve_parallel_lane_routes:
update_beat_route({"enabled": False})
def _pattern_supports_manual(pattern_key: str) -> bool:

View File

@@ -78,13 +78,49 @@ def build_select_message(device_name, preset_name, step=None):
return {device_name: select_list}
def build_preset_dict(preset_data):
def _hex_from_background_raw(bg_raw):
"""Coerce ``background`` / ``bg`` field to a ``#RRGGBB`` string (driver wire format)."""
if isinstance(bg_raw, str):
bg = bg_raw if bg_raw.startswith("#") else f"#{bg_raw}"
return bg
if isinstance(bg_raw, (list, tuple)) and len(bg_raw) == 3:
return f"#{int(bg_raw[0]):02x}{int(bg_raw[1]):02x}{int(bg_raw[2]):02x}"
return "#000000"
def resolve_preset_background_hex(preset_data, palette_colors=None):
"""
Resolved background as ``#RRGGBB``. When ``palette_colors`` is a non-empty list and
``background_palette_ref`` is set, uses that palette index; otherwise stored ``background`` / ``bg``.
"""
if not isinstance(preset_data, dict):
return "#000000"
pal = list(palette_colors) if isinstance(palette_colors, list) else []
ref = preset_data.get("background_palette_ref", preset_data.get("backgroundPaletteRef"))
if pal and ref is not None:
try:
idx = int(ref)
except (TypeError, ValueError):
idx = None
else:
if isinstance(idx, int) and 0 <= idx < len(pal):
c = pal[idx]
if isinstance(c, str) and c.strip().startswith("#"):
s = c.strip()
if len(s) == 7 and all(ch in "0123456789abcdefABCDEF" for ch in s[1:]):
return s.upper()
bg_raw = preset_data.get("background", preset_data.get("bg", "#000000"))
return _hex_from_background_raw(bg_raw)
def build_preset_dict(preset_data, palette_colors=None):
"""
Convert preset data to API-compliant format.
Args:
preset_data: Dictionary with preset fields (may include name, pattern, colors, etc.)
palette_colors: Optional list of ``#RRGGBB`` strings for ``background_palette_ref`` resolution.
Returns:
Dictionary with preset in API-compliant format (without name field)
@@ -137,13 +173,7 @@ def build_preset_dict(preset_data):
auto_raw = preset_data.get("auto", preset_data.get("a", True))
auto_bool = _coerce_auto(auto_raw)
bg_raw = preset_data.get("background", preset_data.get("bg", "#000000"))
if isinstance(bg_raw, str):
bg = bg_raw if bg_raw.startswith("#") else f"#{bg_raw}"
elif isinstance(bg_raw, (list, tuple)) and len(bg_raw) == 3:
bg = f"#{int(bg_raw[0]):02x}{int(bg_raw[1]):02x}{int(bg_raw[2]):02x}"
else:
bg = "#000000"
bg = resolve_preset_background_hex(preset_data, palette_colors)
# Build payload using the short keys expected by led-driver
preset = {
@@ -164,13 +194,14 @@ def build_preset_dict(preset_data):
return preset
def build_presets_dict(presets_data):
def build_presets_dict(presets_data, palette_colors=None):
"""
Convert multiple presets to API-compliant format.
Args:
presets_data: Dictionary mapping preset names to preset data
palette_colors: Optional list of ``#RRGGBB`` strings for background palette ref resolution.
Returns:
Dictionary mapping preset names to API-compliant preset objects
@@ -190,7 +221,7 @@ def build_presets_dict(presets_data):
"""
result = {}
for preset_name, preset_data in presets_data.items():
result[preset_name] = build_preset_dict(preset_data)
result[preset_name] = build_preset_dict(preset_data, palette_colors)
return result

View File

@@ -1,8 +1,7 @@
"""Server-side zone sequence playback (time or audio-beat advance).
"""Server-side zone sequence playback (audio beats or simulated BPM).
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.
Steps advance on each beat from the audio detector when it is running; otherwise the server
emits beats at the sequence ``simulated_bpm`` rate until playback stops or live audio starts.
"""
from __future__ import annotations
@@ -13,12 +12,14 @@ import queue
import threading
from typing import Any, Dict, List, Optional, Tuple
from util.espnow_message import resolve_preset_background_hex
_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()
_sim_beat_task: Optional[asyncio.Task] = None
_sim_beat_token = 0
_beat_run: Optional[Dict[str, Any]] = None
_beat_run_lock = threading.Lock()
@@ -91,27 +92,28 @@ def _group_ids_for_lane_step(
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:
gids = zone_doc.get("group_ids")
if isinstance(gids, list) and gids:
seen: set = set()
for gid in gids:
g = groups.read(gid) if hasattr(groups, "read") else None
s = str(gid).strip()
if not s:
continue
g = groups.read(s) 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:
for raw in g.get("devices") or []:
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)
doc = devices.read(m) if hasattr(devices, "read") else None
nm = ""
if isinstance(doc, dict):
nm = str(doc.get("name") or "").strip()
names.append(nm or m)
macs.append(m)
return names, macs
zone_names = zone_doc.get("names")
@@ -326,7 +328,8 @@ def _display_preset_for_step(
preset.get("palette_refs"),
palette_colors,
)
return {**preset, "colors": colors}
resolved_bg = resolve_preset_background_hex(preset, palette_colors)
return {**preset, "colors": colors, "background": resolved_bg}
def _preset_inner_from_display_preset(display_preset: Dict[str, Any]) -> Dict[str, Any]:
@@ -345,6 +348,54 @@ def _preset_inner_from_display_preset(display_preset: Dict[str, Any]) -> Dict[st
return inner
def _parse_zone_brightness_value(zone_doc: Any) -> int:
"""Zone slider value stored on the zone row (0255); default 255 if unset."""
from util.brightness_combine import clamp255
if not isinstance(zone_doc, dict):
return 255
raw = zone_doc.get("brightness")
if raw is None or raw == "":
return 255
try:
return clamp255(int(raw))
except (TypeError, ValueError):
return 255
def _inner_wire_b_with_sequence_zone_brightness(
inner: Dict[str, Any],
zone_doc: Dict[str, Any],
*,
target_mac: Optional[str],
settings_obj: Any,
groups_model: Any,
devices_model: Any,
) -> Dict[str, Any]:
"""Combine preset wire ``b`` with zone brightness (and global/group/device when ``target_mac`` is set)."""
from util.brightness_combine import (
clamp255,
multiply_brightness_factors,
effective_brightness_for_mac,
)
out = dict(inner)
base = clamp255(out.get("b", 127))
zb = _parse_zone_brightness_value(zone_doc)
if target_mac and settings_obj is not None and groups_model is not None and devices_model is not None:
eff = effective_brightness_for_mac(
settings_obj,
groups_model,
devices_model,
target_mac,
zone_brightness=zb,
)
out["b"] = multiply_brightness_factors([base, eff])
else:
out["b"] = multiply_brightness_factors([base, zb])
return out
def _device_names_to_macs(device_names: List[str], devices: Any) -> List[str]:
macs: List[str] = []
seen: set = set()
@@ -432,8 +483,24 @@ async def _deliver_sequence_presets_bulk(ctx: Dict[str, Any]) -> None:
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)
zone_doc = ctx.get("zone_doc") if isinstance(ctx.get("zone_doc"), dict) else {}
settings_obj = ctx.get("settings")
groups_model = ctx.get("groups")
devices_model = ctx.get("devices")
delay_s = 0.05
for mac in macs:
adjusted: Dict[str, Any] = {}
for wire_pid, inner in inner_by_wire.items():
adjusted[wire_pid] = _inner_wire_b_with_sequence_zone_brightness(
inner,
zone_doc,
target_mac=mac,
settings_obj=settings_obj,
groups_model=groups_model,
devices_model=devices_model,
)
msg = json.dumps({"v": "1", "presets": adjusted}, separators=(",", ":"))
await deliver_json_messages(sender, [msg], [mac], devices_model, delay_s=delay_s)
def _coerce_auto(preset: Dict[str, Any]) -> bool:
@@ -473,6 +540,9 @@ async def _deliver_preset_for_devices(
devices: Any,
*,
lane_index: Optional[int] = None,
zone_doc: Optional[Dict[str, Any]] = None,
settings_obj: Any = None,
groups_model: Any = None,
) -> None:
from models.transport import get_current_sender
from util.driver_delivery import deliver_json_messages
@@ -505,34 +575,61 @@ async def _deliver_preset_for_devices(
body = dict(preset_doc)
auto = _coerce_auto(body)
inner = build_preset_dict(body)
inner_base = 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
inner_base["manual_beat_n"] = n
except (TypeError, ValueError):
pass
wire = str(preset_id)
seq_list: List[Dict[str, Any]] = [{"v": "1", "presets": {wire: inner}}]
zone_use = zone_doc if isinstance(zone_doc, dict) else {}
sel_append: Optional[Dict[str, Any]] = None
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)
sel_append = {"v": "1", "select": sel}
for mac in macs:
inner = _inner_wire_b_with_sequence_zone_brightness(
inner_base,
zone_use,
target_mac=mac,
settings_obj=settings_obj,
groups_model=groups_model,
devices_model=devices,
)
seq_list: List[Dict[str, Any]] = [{"v": "1", "presets": {wire: inner}}]
if sel_append:
seq_list.append(dict(sel_append))
messages = [json.dumps(x, separators=(",", ":")) for x in seq_list]
await deliver_json_messages(sender, messages, [mac], devices, delay_s=0.05)
if not auto:
manual_inner = _inner_wire_b_with_sequence_zone_brightness(
inner_base,
zone_use,
target_mac=macs[0] if len(macs) == 1 else None,
settings_obj=settings_obj,
groups_model=groups_model,
devices_model=devices,
)
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)
set_sequence_manual_lane_route(lane_index, device_names, wire, manual_inner)
else:
seq_one = [{"v": "1", "presets": {wire: manual_inner}}]
if sel_append:
seq_one.append(dict(sel_append))
sync_beat_route_from_push_sequence(
seq_list, target_macs=macs, preserve_manual_beat_route_on_auto_select=True
seq_one, target_macs=macs, preserve_parallel_lane_routes=True
)
@@ -595,6 +692,15 @@ async def _send_lane(
if isinstance(bulk, dict) and bulk:
auto = _coerce_auto(display_preset)
inner = _preset_inner_from_display_preset(display_preset)
zone_use = ctx.get("zone_doc") if isinstance(ctx.get("zone_doc"), dict) else {}
inner = _inner_wire_b_with_sequence_zone_brightness(
inner,
zone_use,
target_mac=macs[0] if len(macs) == 1 else None,
settings_obj=ctx.get("settings"),
groups_model=ctx.get("groups"),
devices_model=devices,
)
wire = str(preset_id)
if auto:
clear_sequence_manual_lane_route(lane_index)
@@ -611,7 +717,14 @@ async def _send_lane(
return
await _deliver_preset_for_devices(
preset_id, display_preset, device_names, devices, lane_index=lane_index
preset_id,
display_preset,
device_names,
devices,
lane_index=lane_index,
zone_doc=zone_doc,
settings_obj=ctx.get("settings"),
groups_model=groups,
)
@@ -624,11 +737,6 @@ async def _send_all_lanes(ctx: Dict[str, Any]) -> None:
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],
@@ -637,6 +745,7 @@ def _build_ctx(
) -> Optional[Dict[str, Any]]:
from models.device import Device
from models.group import Group
from settings import Settings
lanes = [x for x in _normalize_sequence_lanes(sequence_doc) if len(x) > 0]
if not lanes:
@@ -655,9 +764,10 @@ def _build_ctx(
"presets_map": presets_map,
"devices": devices,
"groups": groups,
"settings": Settings(),
"palette_colors": palette_colors,
"loop": True,
"advance_mode": "beats" if _sequence_advance_beats(sequence_doc) else "time",
"advance_mode": "beats",
}
@@ -683,7 +793,6 @@ def playback_status() -> Dict[str, Any]:
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
@@ -693,12 +802,8 @@ def playback_status() -> Dict[str, Any]:
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
bt = max(1, int(beats_per_step))
beat_count = min(bt, max(1, beat_count_raw if beat_count_raw > 0 else 1))
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
@@ -722,10 +827,8 @@ def playback_status() -> Dict[str, Any]:
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
sequence_beats_per_pass > 0
and lane_states
and lane0_steps > 0
and lane_states[0]
@@ -759,7 +862,7 @@ def playback_status() -> Dict[str, Any]:
async def process_active_beat_advance() -> None:
with _beat_run_lock:
ctx = _beat_run
if not ctx or ctx.get("advance_mode") != "beats":
if not ctx:
return
lane_states: List[Dict[str, Any]] = ctx["lane_states"]
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
@@ -842,77 +945,54 @@ def ensure_beat_consumer_started() -> None:
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)
def _coerce_simulated_bpm(sequence_doc: Dict[str, Any], play_options: Optional[Dict[str, Any]]) -> float:
raw = None
if isinstance(play_options, dict):
o = play_options.get("simulated_bpm")
if o is not None:
raw = o
if raw is None and isinstance(sequence_doc, dict):
raw = sequence_doc.get("simulated_bpm")
try:
duration = max(200, int(raw_dur))
v = float(raw) if raw is not None else 120.0
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)
v = 120.0
return max(30.0, min(300.0, v))
await _send_all_lanes(ctx)
my = token
async def _simulated_beat_loop(ctx: Dict[str, Any], my_token: int, bpm: float) -> None:
from util import audio_detector as ad_mod
interval = 60.0 / max(30.0, min(300.0, float(bpm)))
while True:
await asyncio.sleep(time_tick_lead / 1000.0)
with _beat_run_lock:
cur = _time_token
if cur != my:
cur_tok = _sim_beat_token
active = _beat_run
if cur_tok != my_token or active is None or active is not ctx:
return
if time_sleep_tr > 0:
await asyncio.sleep(time_sleep_tr / 1000.0)
if ad_mod.shared_beat_detector_running():
await asyncio.sleep(0.12)
continue
await asyncio.sleep(interval)
with _beat_run_lock:
cur = _time_token
if cur != my:
cur_tok = _sim_beat_token
active = _beat_run
if cur_tok != my_token or active is None or active is not ctx:
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)
if ad_mod.shared_beat_detector_running():
continue
push_thread_beat()
def stop() -> None:
global _beat_run, _time_task, _time_token
global _beat_run, _sim_beat_task, _sim_beat_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()
_sim_beat_token += 1
st = _sim_beat_task
_sim_beat_task = None
if st and not st.done():
st.cancel()
def stop_if_playing_sequence(sequence_id: str) -> bool:
@@ -931,8 +1011,13 @@ def stop_if_playing_sequence(sequence_id: str) -> bool:
return True
async def start(zone_id: str, sequence_id: str, profile_id: str) -> None:
global _beat_run, _time_task, _time_token
async def start(
zone_id: str,
sequence_id: str,
profile_id: str,
play_options: Optional[Dict[str, Any]] = None,
) -> None:
global _beat_run, _sim_beat_task, _sim_beat_token
from models.preset import Preset
from models.profile import Profile
from models.sequence import Sequence
@@ -969,28 +1054,16 @@ async def start(zone_id: str, sequence_id: str, profile_id: str) -> None:
await _deliver_sequence_presets_bulk(ctx)
advance = ctx["advance_mode"]
if advance == "beats":
from util.beat_driver_route import update_beat_route
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
update_beat_route({"enabled": False})
with _beat_run_lock:
_beat_run = ctx
await _send_all_lanes(ctx)
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())
bpm = _coerce_simulated_bpm(sequence_doc, play_options)
loop = asyncio.get_running_loop()
_sim_beat_token += 1
my_tok = _sim_beat_token
_sim_beat_task = loop.create_task(_simulated_beat_loop(ctx, my_tok, bpm))