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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 (0–255); 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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user