feat(audio-sequences): beat phase sync and aligned playback

Add bar-phase tracking, audio reset/anchor APIs, BPM holdover, beat-phase
sequence switching, sync-phase endpoint, and sample sequence data.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-17 18:32:10 +12:00
parent 7ecb5c3b3e
commit 964cfc6d91
14 changed files with 1117 additions and 292 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{}
{"1": {"name": "Pulse (manual)", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "6", "beats": 1}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "6", "beats": 1}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": true}, "2": {"name": "Off (1 beat)", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "2", "beats": 1}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "2", "beats": 1}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": false}, "3": {"name": "On (1 beat)", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "1", "beats": 1}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "1", "beats": 1}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": false}, "4": {"name": "Rainbow \u2192 transition \u2192 off", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "3", "beats": 4}, {"preset_id": "4", "beats": 4}, {"preset_id": "2", "beats": 2}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "3", "beats": 4}, {"preset_id": "4", "beats": 4}, {"preset_id": "2", "beats": 2}], "step_duration_ms": 3000, "simulated_bpm": 100, "sequence_transition": 500, "loop": true}, "5": {"name": "Manual pulse + chase", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "6", "beats": 2}, {"preset_id": "5", "beats": 2}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "6", "beats": 2}, {"preset_id": "5", "beats": 2}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": false}, "6": {"name": "RGB solid cycle", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "11", "beats": 2}, {"preset_id": "12", "beats": 2}, {"preset_id": "9", "beats": 2}, {"preset_id": "10", "beats": 2}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "11", "beats": 2}, {"preset_id": "12", "beats": 2}, {"preset_id": "9", "beats": 2}, {"preset_id": "10", "beats": 2}], "step_duration_ms": 3000, "simulated_bpm": 90, "sequence_transition": 500, "loop": true}, "7": {"name": "Winter trio", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "64", "beats": 8}, {"preset_id": "65", "beats": 8}, {"preset_id": "66", "beats": 8}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "64", "beats": 8}, {"preset_id": "65", "beats": 8}, {"preset_id": "66", "beats": 8}], "step_duration_ms": 3000, "simulated_bpm": 80, "sequence_transition": 500, "loop": true}, "8": {"name": "Fast rainbow", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "1", "beats": 1}, {"preset_id": "3", "beats": 4}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "1", "beats": 1}, {"preset_id": "3", "beats": 4}], "step_duration_ms": 3000, "simulated_bpm": 180, "sequence_transition": 500, "loop": true}, "9": {"name": "Off then on", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "2", "beats": 2}, {"preset_id": "1", "beats": 4}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "2", "beats": 2}, {"preset_id": "1", "beats": 4}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": false}, "10": {"name": "Twinkle + flame", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "41", "beats": 6}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "41", "beats": 6}], "step_duration_ms": 3000, "simulated_bpm": 110, "sequence_transition": 500, "loop": true}, "11": {"name": "radiate chase", "profile_id": "1", "group_ids": [], "lanes": [[{"preset_id": "42", "beats": 12}, {"preset_id": "5", "beats": 4}]], "lanes_group_ids": [[]], "advance_mode": "beats", "steps": [{"preset_id": "42", "beats": 12}, {"preset_id": "5", "beats": 4}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": true}}

View File

@@ -8,7 +8,7 @@ from models.device import (
)
from models.group import Group
from models.transport import get_current_sender
from settings import Settings
from settings import get_settings
from util.brightness_combine import effective_brightness_for_mac
from models.wifi_ws_clients import (
normalize_tcp_peer_ip,
@@ -77,7 +77,7 @@ def _brightness_save_message_json(b_val: int) -> str:
controller = Microdot()
devices = Device()
_group_registry = Group()
_pi_settings = Settings()
_pi_settings = get_settings()
def _device_live_connected(dev_dict):

View File

@@ -5,14 +5,14 @@ from models.group import Group
from models.device import Device
from models.transport import get_current_sender
from models.wifi_ws_clients import normalize_tcp_peer_ip, send_json_line_to_ip
from settings import Settings
from settings import get_settings
from util.brightness_combine import effective_brightness_for_mac
import json
controller = Microdot()
groups = Group()
devices = Device()
_pi_settings = Settings()
_pi_settings = get_settings()
def _group_doc_visible_for_profile(doc, profile_id):

View File

@@ -30,6 +30,7 @@ def get_current_profile_id(session=None):
@with_session
async def list_sequences(request, session):
"""List sequences for the current profile."""
sequences.load()
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return json.dumps({}), 200, {"Content-Type": "application/json"}
@@ -97,6 +98,7 @@ async def import_sequence(request, session):
@with_session
async def get_sequence(request, session, id):
"""Get a specific sequence by ID (current profile only)."""
sequences.load()
current_profile_id = get_current_profile_id(session)
seq = sequences.read(id)
if (
@@ -203,15 +205,46 @@ async def delete_sequence(request, session, id):
return json.dumps({"error": "Sequence not found"}), 404
@controller.post("/sync-phase")
@with_session
async def sync_sequence_beat_phase(request, session):
"""Align beat counters while a sequence is playing (body: {\"mode\": \"step\"|\"pass\"})."""
_ = session
try:
data = request.json or {}
except Exception:
data = {}
if not isinstance(data, dict):
data = {}
mode = data.get("mode") or data.get("align") or "step"
try:
from util.sequence_playback import sync_beat_phase
if not await sync_beat_phase(str(mode)):
return (
json.dumps({"error": "No sequence is playing"}),
409,
{"Content-Type": "application/json"},
)
from util.audio_detector import anchor_shared_bar_phase
anchor_shared_bar_phase()
return json.dumps({"ok": True, "mode": str(mode).strip().lower()}), 200, {
"Content-Type": "application/json"
}
except Exception as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
@controller.post("/stop")
@with_session
async def stop_sequence_playback(request, session):
"""Stop server-driven zone sequence playback."""
_ = request
try:
from util.sequence_playback import stop
from util.sequence_playback import stop_playback
stop()
await stop_playback(clear_devices=True)
return json.dumps({"ok": True}), 200, {"Content-Type": "application/json"}
except Exception as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
@@ -251,8 +284,12 @@ async def play_sequence(request, session, id):
try:
from util.sequence_playback import start
await start(zone_id, str(id), str(current_profile_id), data if isinstance(data, dict) else None)
return json.dumps({"ok": True}), 200, {"Content-Type": "application/json"}
play_opts = data if isinstance(data, dict) else None
await start(zone_id, str(id), str(current_profile_id), play_opts)
from util.sequence_playback import pending_play_status
body = {"ok": True, **pending_play_status()}
return json.dumps(body), 200, {"Content-Type": "application/json"}
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
except RuntimeError as e:

View File

@@ -4,10 +4,10 @@ import json
from microdot import Microdot, send_file
from models import wifi_ws_clients
from settings import Settings
from settings import get_settings
controller = Microdot()
settings = Settings()
settings = get_settings()
@controller.get('')
async def get_settings(request):
@@ -75,7 +75,21 @@ def _validate_global_brightness(value):
return v
@controller.put('/settings')
def _validate_sequence_switch_wait(value):
s = str(value).strip().lower()
if s not in ("beat", "downbeat"):
raise ValueError("sequence_switch_wait must be beat or downbeat")
return s
def _validate_audio_beat_phase_ms(value):
v = int(value)
if v < 0 or v > 500:
raise ValueError("audio_beat_phase_ms must be between 0 and 500")
return v
@controller.put('')
async def update_settings(request):
"""Update general settings."""
try:
@@ -87,6 +101,10 @@ async def update_settings(request):
elif key == 'global_brightness' and value is not None:
settings[key] = _validate_global_brightness(value)
global_brightness_changed = True
elif key == 'sequence_switch_wait' and value is not None:
settings[key] = _validate_sequence_switch_wait(value)
elif key == 'audio_beat_phase_ms' and value is not None:
settings[key] = _validate_audio_beat_phase_ms(value)
else:
settings[key] = value
settings.save()

View File

@@ -145,6 +145,7 @@ async def zone_content_fragment(request, session, id):
@controller.get("")
@with_session
async def list_zones(request, session):
zones.load()
profile_id = get_current_profile_id(session)
current_zone_id = get_current_zone_id(request, session)
zone_order = get_profile_zone_order(profile_id) if profile_id else []
@@ -213,6 +214,7 @@ async def set_current_zone(request, id):
@controller.get("/<id>")
async def get_zone(request, id):
zones.load()
z = zones.read(id)
if z:
return json.dumps(z), 200, {"Content-Type": "application/json"}

View File

@@ -10,7 +10,7 @@ import traceback
from microdot import Microdot, send_file
from microdot.websocket import with_websocket
from microdot.session import Session
from settings import Settings
from settings import get_settings
import controllers.preset as preset
import controllers.profile as profile
@@ -159,8 +159,12 @@ async def _periodic_wifi_driver_hello_loop(settings, udp_holder) -> None:
sock.setblocking(False)
loop = asyncio.get_running_loop()
try:
while True:
await asyncio.sleep(interval)
while not udp_holder.get("closing"):
slept = 0.0
while slept < interval and not udp_holder.get("closing"):
chunk = min(1.0, interval - slept)
await asyncio.sleep(chunk)
slept += chunk
if udp_holder.get("closing"):
break
try:
@@ -244,7 +248,7 @@ async def _send_bridge_wifi_channel(settings, sender):
async def main(port=80):
settings = Settings()
settings = get_settings()
print(settings)
print("Starting")
@@ -377,7 +381,12 @@ async def main(port=80):
audio_detector.start(device=device)
from util.audio_run_persist import write_audio_run_state
write_audio_run_state(enabled=True, device=device)
write_audio_run_state(
enabled=True,
device=device,
device_override=str(payload.get("device_override") or ""),
device_select=str(payload.get("device_select") or ""),
)
return {"ok": True, "status": audio_detector.status()}
except Exception as e:
return {"ok": False, "error": str(e)}, 500
@@ -391,6 +400,24 @@ async def main(port=80):
write_audio_run_state(enabled=False)
return {"ok": True, "status": audio_detector.status()}
@app.route('/api/audio/reset', methods=['POST'])
async def audio_reset(request):
"""Clear beat/BPM tracking state without stopping the detector."""
_ = request
ok = audio_detector.reset_tracking()
if not ok:
return {"ok": False, "error": "Audio detector is not running"}, 409
return {"ok": True, "status": audio_detector.status()}
@app.route('/api/audio/anchor-bar', methods=['POST'])
async def audio_anchor_bar(request):
"""Mark the current moment as bar beat 1 (downbeat)."""
_ = request
ok = audio_detector.anchor_bar_phase()
if not ok:
return {"ok": False, "error": "Audio detector is not running"}, 409
return {"ok": True, "status": audio_detector.status()}
@app.route('/api/audio/status')
async def audio_status(request):
_ = request
@@ -426,6 +453,14 @@ async def main(port=80):
if bs > 0:
beat_readout = str(bs)
st["beat_readout"] = beat_readout
from util.audio_run_persist import read_audio_run_state
st["beat_phase_ms"] = int(settings.get("audio_beat_phase_ms") or 0)
seq_wait = str(settings.get("sequence_switch_wait") or "beat").strip().lower()
if seq_wait not in ("beat", "downbeat"):
seq_wait = "beat"
st["sequence_switch_wait"] = seq_wait
st["audio_run"] = read_audio_run_state()
return {"status": st}
# Static file route
@@ -480,16 +515,30 @@ async def main(port=80):
await _send_bridge_wifi_channel(settings, sender)
_prime_wifi_outbound_driver_connections()
udp_holder = {"closing": False}
udp_holder = {"closing": False, "shutting_down": False}
loop = asyncio.get_running_loop()
server_tasks: list[asyncio.Task] = []
def _graceful_shutdown(*_args):
if udp_holder.get("shutting_down"):
raise SystemExit(0)
udp_holder["shutting_down"] = True
print("[server] shutting down...")
udp_holder["closing"] = True
try:
audio_detector.stop()
except Exception:
pass
try:
from util import sequence_playback as seq_pb
seq_pb.stop()
for attr in ("_pending_beat_task", "_sim_beat_task"):
t = getattr(seq_pb, attr, None)
if t is not None and not t.done():
t.cancel()
except Exception:
pass
u = udp_holder.get("sock")
if u is not None:
try:
@@ -498,7 +547,13 @@ async def main(port=80):
pass
tcp_client_registry.cancel_all_driver_tasks()
if getattr(app, "server", None) is not None:
app.shutdown()
try:
app.shutdown()
except Exception:
pass
for t in server_tasks:
if not t.done():
t.cancel()
shutdown_handlers_registered = False
try:
@@ -511,11 +566,21 @@ async def main(port=80):
# Await HTTP + UDP discovery; bind failures (e.g. port 80 in use) surface here.
try:
await asyncio.gather(
app.start_server(host="0.0.0.0", port=port),
_run_udp_discovery_server(udp_holder),
_periodic_wifi_driver_hello_loop(settings, udp_holder),
)
server_tasks[:] = [
asyncio.create_task(
app.start_server(host="0.0.0.0", port=port), name="http"
),
asyncio.create_task(
_run_udp_discovery_server(udp_holder), name="udp"
),
asyncio.create_task(
_periodic_wifi_driver_hello_loop(settings, udp_holder),
name="hello",
),
]
await asyncio.gather(*server_tasks)
except asyncio.CancelledError:
pass
except OSError as e:
if e.errno == errno.EADDRINUSE:
print(
@@ -540,6 +605,21 @@ async def main(port=80):
app.server = None
except Exception:
pass
udp_holder["closing"] = True
for t in list(server_tasks):
if not t.done():
t.cancel()
if server_tasks:
await asyncio.gather(*server_tasks, return_exceptions=True)
pending = [
t
for t in asyncio.all_tasks(loop)
if t is not asyncio.current_task() and not t.done()
]
for t in pending:
t.cancel()
if pending:
await asyncio.gather(*pending, return_exceptions=True)
if shutdown_handlers_registered:
for sig in (signal.SIGINT, signal.SIGTERM):
try:
@@ -549,5 +629,9 @@ async def main(port=80):
if __name__ == "__main__":
import os
port = int(os.environ.get("PORT", 80))
asyncio.run(main(port=port))
try:
asyncio.run(main(port=port))
except KeyboardInterrupt:
print("[server] interrupted")

View File

@@ -22,7 +22,7 @@ class Zone(Model):
"""Preset layout row (stored in ``db/zone.json``); legacy storage was ``tab.json`` / Tab.
Optional ``content_kind`` on a row: ``\"presets\"`` (preset tiles only) or ``\"sequences\"``
(sequence tiles only). Omitted or unknown => both (legacy behaviour).
(sequence tiles only). Legacy rows without ``content_kind`` are inferred on load.
"""
def __init__(self):
@@ -43,6 +43,12 @@ class Zone(Model):
if "preset_group_ids" not in doc or not isinstance(doc.get("preset_group_ids"), dict):
doc["preset_group_ids"] = {}
changed = True
if "sequence_ids" not in doc or not isinstance(doc.get("sequence_ids"), list):
doc["sequence_ids"] = []
changed = True
if not self._normalized_content_kind(doc):
doc["content_kind"] = self._infer_content_kind(doc)
changed = True
if changed:
self.save()
@@ -53,6 +59,41 @@ class Zone(Model):
kind = doc.get("content_kind")
return kind if kind in ("presets", "sequences") else None
@staticmethod
def _preset_ids_in_doc(doc):
if not isinstance(doc, dict):
return []
flat = doc.get("presets_flat")
if isinstance(flat, list):
return [str(x) for x in flat if x is not None and str(x).strip()]
presets = doc.get("presets")
if not isinstance(presets, list) or not presets:
return []
if isinstance(presets[0], str):
return [str(x) for x in presets if x is not None and str(x).strip()]
if isinstance(presets[0], list):
out = []
for row in presets:
if isinstance(row, list):
out.extend(str(x) for x in row if x is not None and str(x).strip())
return out
return []
@classmethod
def _infer_content_kind(cls, doc):
kind = cls._normalized_content_kind(doc)
if kind:
return kind
seq_ids = [
str(x).strip()
for x in (doc.get("sequence_ids") or [])
if x is not None and str(x).strip()
]
preset_ids = cls._preset_ids_in_doc(doc)
if seq_ids and not preset_ids:
return "sequences"
return "presets"
def _enforce_content_kind_invariants(self, doc):
"""Presets-only zones hold no sequences; sequences-only hold no preset tiles."""
kind = self._normalized_content_kind(doc)
@@ -95,7 +136,8 @@ class Zone(Model):
return False
patch = data if isinstance(data, dict) else {}
self[id_str].update(patch)
self._enforce_content_kind_invariants(self[id_str])
if "content_kind" in patch:
self._enforce_content_kind_invariants(self[id_str])
self.save()
return True

View File

@@ -12,11 +12,15 @@ def _settings_path():
return "settings.json"
_settings_singleton: "Settings | None" = None
class Settings(dict):
SETTINGS_FILE = None # Set in __init__ from _settings_path()
def __init__(self):
def __init__(self, *, quiet: bool = False):
super().__init__()
self._quiet = quiet
if Settings.SETTINGS_FILE is None:
Settings.SETTINGS_FILE = _settings_path()
self.load() # Load settings from file during initialization
@@ -79,13 +83,22 @@ class Settings(dict):
# Zone UI global brightness (0255); shared across browsers/devices.
if 'global_brightness' not in self:
self['global_brightness'] = 255
# Sequence tile start: wait for beat or downbeat (server-owned).
if 'sequence_switch_wait' not in self:
self['sequence_switch_wait'] = 'beat'
elif str(self.get('sequence_switch_wait', '')).strip().lower() == 'phrase':
self['sequence_switch_wait'] = 'beat'
# Beat flash alignment delay (ms); applied by all UI clients polling audio status.
if 'audio_beat_phase_ms' not in self:
self['audio_beat_phase_ms'] = 0
def save(self):
try:
j = json.dumps(self)
with open(self.SETTINGS_FILE, 'w') as file:
file.write(j)
print("Settings saved successfully.")
if not getattr(self, "_quiet", False):
print("Settings saved successfully.")
except Exception as e:
print(f"Error saving settings: {e}")
@@ -96,9 +109,11 @@ class Settings(dict):
loaded_settings = json.load(file)
self.update(loaded_settings)
loaded_from_file = True
print("Settings loaded successfully.")
if not getattr(self, "_quiet", False):
print("Settings loaded successfully.")
except Exception as e:
print(f"Error loading settings")
if not getattr(self, "_quiet", False):
print(f"Error loading settings: {e}")
self.clear()
finally:
# Ensure defaults are set even if file exists but is missing keys
@@ -106,3 +121,18 @@ class Settings(dict):
# Only save if file didn't exist or was invalid
if not loaded_from_file:
self.save()
def get_settings() -> Settings:
"""Process-wide settings instance (avoid re-reading settings.json on every request)."""
global _settings_singleton
if _settings_singleton is None:
_settings_singleton = Settings()
return _settings_singleton
def reload_settings() -> Settings:
"""Re-read settings.json (e.g. after external file edit)."""
global _settings_singleton
_settings_singleton = Settings(quiet=True)
return _settings_singleton

View File

@@ -4,6 +4,12 @@ import os
import queue
import threading
import time
from typing import Any
_HOLDOVER_BPM_MIN = 30.0
_HOLDOVER_BPM_MAX = 300.0
_HOLDOVER_MAX_S = 300.0
class AudioBeatDetector:
@@ -13,6 +19,11 @@ class AudioBeatDetector:
self._stream = None
self._running = False
self._stop_event = threading.Event()
self._runtime = None
self._pending_reset = False
self._holdover_thread: threading.Thread | None = None
self._holdover_stop = threading.Event()
self._holdover_active = False
self._status = {
"running": False,
"bpm": None,
@@ -20,6 +31,11 @@ class AudioBeatDetector:
"beat_seq": 0,
"beat_type": "unknown",
"beat_type_confidence": 0.0,
"bar_beat": 1,
"beats_per_bar": 4,
"is_downbeat": False,
"phase_confidence": 0.0,
"bar_phase_readout": "1/4",
"error": None,
"device": None,
}
@@ -100,6 +116,11 @@ class AudioBeatDetector:
"beat_seq": 0,
"beat_type": "unknown",
"beat_type_confidence": 0.0,
"bar_beat": 1,
"beats_per_bar": 4,
"is_downbeat": False,
"phase_confidence": 0.0,
"bar_phase_readout": "1/4",
"error": None,
"device": device,
}
@@ -111,6 +132,7 @@ class AudioBeatDetector:
self._thread.start()
def stop(self):
self._stop_bpm_holdover()
with self._lock:
self._stop_event.set()
t = self._thread
@@ -139,11 +161,159 @@ class AudioBeatDetector:
self._running = False
self._thread = None
self._stream = None
self._pending_reset = False
self._status["running"] = False
def status(self):
with self._lock:
return dict(self._status)
st = dict(self._status)
holdover = self._holdover_active
last = st.get("last_beat_ts")
if st.get("running") and last is not None and not holdover:
try:
if (time.time() - float(last)) > 4.0:
st["bpm"] = None
except (TypeError, ValueError):
pass
return st
def _apply_tracking_reset_status(self) -> None:
"""Refresh published status after a tracking reset (lock must be held)."""
bpb = max(1, int(self._status.get("beats_per_bar") or 4))
self._status.update(
{
"running": True,
"beat_type": "unknown",
"beat_type_confidence": 0.0,
"bar_beat": 1,
"is_downbeat": True,
"phase_confidence": 0.0,
"bar_phase_readout": f"1/{bpb}",
}
)
def _clamp_holdover_bpm(self, bpm: Any) -> float | None:
try:
v = float(bpm)
except (TypeError, ValueError):
return None
if not (_HOLDOVER_BPM_MIN <= v <= _HOLDOVER_BPM_MAX):
return None
return v
def _holdover_interval_s(self, bpm: float) -> float:
return 60.0 / max(_HOLDOVER_BPM_MIN, min(_HOLDOVER_BPM_MAX, float(bpm)))
def _stop_bpm_holdover(self) -> None:
with self._lock:
self._holdover_active = False
self._holdover_stop.set()
t = self._holdover_thread
if t and t.is_alive() and t is not threading.current_thread():
t.join(timeout=2.0)
with self._lock:
if self._holdover_thread is t:
self._holdover_thread = None
def _advance_holdover_bar_phase_locked(self) -> dict:
"""Advance bar phase for one synthetic beat (lock must be held)."""
bpb = max(1, int(self._status.get("beats_per_bar") or 4))
prev = int(self._status.get("bar_beat") or 1)
bar_beat = (prev % bpb) + 1
is_downbeat = bar_beat == 1
bar_readout = f"{bar_beat}/{bpb}"
self._status["bar_beat"] = bar_beat
self._status["is_downbeat"] = is_downbeat
self._status["bar_phase_readout"] = bar_readout
return {
"bar_beat": bar_beat,
"beats_per_bar": bpb,
"is_downbeat": is_downbeat,
"bar_phase_readout": bar_readout,
}
def _emit_holdover_beat(self, bpm: float) -> None:
now = time.time()
with self._lock:
if not self._running or not self._holdover_active:
return
self._advance_holdover_bar_phase_locked()
self._status["last_beat_ts"] = now
self._status["bpm"] = float(bpm)
self._status["beat_type"] = "holdover"
self._status["beat_type_confidence"] = 0.0
self._status["beat_seq"] = int(self._status.get("beat_seq", 0)) + 1
try:
from util import sequence_playback as seq_pb
seq_pb.push_thread_beat()
except Exception as e:
print(f"[audio] holdover beat queue: {e}")
def _holdover_loop(self, bpm: float, started_at: float) -> None:
interval = self._holdover_interval_s(bpm)
while not self._holdover_stop.is_set():
with self._lock:
if not self._running or not self._holdover_active:
return
if (time.time() - started_at) > _HOLDOVER_MAX_S:
self._holdover_active = False
return
last = self._status.get("last_beat_ts")
if last is not None:
try:
delay = max(0.02, float(last) + interval - time.time())
except (TypeError, ValueError):
delay = interval
else:
delay = interval
if self._holdover_stop.wait(delay):
return
self._emit_holdover_beat(bpm)
def _start_bpm_holdover(self, bpm: float) -> None:
bpm_v = self._clamp_holdover_bpm(bpm)
if bpm_v is None:
return
self._stop_bpm_holdover()
self._holdover_stop.clear()
started_at = time.time()
with self._lock:
self._holdover_active = True
self._holdover_thread = threading.Thread(
target=self._holdover_loop,
args=(bpm_v, started_at),
name="audio-bpm-holdover",
daemon=True,
)
t = self._holdover_thread
t.start()
def _process_pending_reset(self, runtime) -> None:
"""Run ``reset_state`` on the audio thread (safe for aubio tempo)."""
with self._lock:
if not self._pending_reset:
return
self._pending_reset = False
try:
runtime.reset_state()
with self._lock:
self._apply_tracking_reset_status()
except Exception as e:
print(f"[audio] pending reset: {e}")
def reset_tracking(self) -> bool:
"""Clear detector tempo history without stopping the input stream."""
holdover_bpm = None
with self._lock:
if not self._running or self._runtime is None:
return False
holdover_bpm = self._clamp_holdover_bpm(self._status.get("bpm"))
self._pending_reset = True
self._apply_tracking_reset_status()
if holdover_bpm is not None:
self._start_bpm_holdover(holdover_bpm)
return True
def _set_error(self, msg):
print(f"[audio] {msg}")
@@ -152,7 +322,28 @@ class AudioBeatDetector:
self._status["running"] = False
self._running = False
def _record_beat(self, bpm, beat_type="unknown", beat_type_confidence=0.0):
def anchor_bar_phase(self) -> bool:
"""Mark the current moment as bar beat 1 (downbeat), e.g. after manual sync."""
with self._lock:
rt = self._runtime
if rt is None:
return False
try:
rt.anchor_bar_phase(time.time())
with self._lock:
self._status["bar_beat"] = 1
self._status["is_downbeat"] = True
self._status["bar_phase_readout"] = f"1/{int(self._status.get('beats_per_bar') or 4)}"
self._status["phase_confidence"] = max(
float(self._status.get("phase_confidence") or 0.0), 0.85
)
return True
except Exception as e:
print(f"[audio] anchor_bar_phase: {e}")
return False
def _record_beat(self, bpm, beat_type="unknown", beat_type_confidence=0.0, **phase_fields):
self._stop_bpm_holdover()
now = time.time()
with self._lock:
self._status["last_beat_ts"] = now
@@ -160,6 +351,16 @@ class AudioBeatDetector:
self._status["beat_type"] = beat_type
self._status["beat_type_confidence"] = float(beat_type_confidence or 0.0)
self._status["beat_seq"] = int(self._status.get("beat_seq", 0)) + 1
if phase_fields.get("bar_beat") is not None:
self._status["bar_beat"] = int(phase_fields["bar_beat"])
if phase_fields.get("beats_per_bar") is not None:
self._status["beats_per_bar"] = int(phase_fields["beats_per_bar"])
if phase_fields.get("is_downbeat") is not None:
self._status["is_downbeat"] = bool(phase_fields["is_downbeat"])
if phase_fields.get("phase_confidence") is not None:
self._status["phase_confidence"] = float(phase_fields["phase_confidence"])
if phase_fields.get("bar_phase_readout"):
self._status["bar_phase_readout"] = str(phase_fields["bar_phase_readout"])
try:
from util import sequence_playback as seq_pb
@@ -210,15 +411,17 @@ class AudioBeatDetector:
flux_weight=0.3,
threshold_multiplier=1.35,
ema_alpha=0.08,
min_ioi_ms=85.0,
min_ioi_ms=100.0,
bpm_window=8,
post_url="",
aubio_method="default",
aubio_threshold=0.12,
silence_gate_db=-58.0,
aubio_threshold=0.14,
beats_per_bar=4,
)
runtime = beat_mod.BeatDetectRuntime(args)
runtime.setup(sample_rate=sample_rate)
with self._lock:
self._runtime = runtime
hop_size = runtime.frame_size
audio_q = queue.Queue(maxsize=64)
@@ -243,10 +446,12 @@ class AudioBeatDetector:
stream.start()
try:
while not self._stop_event.is_set():
self._process_pending_reset(runtime)
try:
frame = audio_q.get(timeout=0.1)
except queue.Empty:
continue
self._process_pending_reset(runtime)
if frame.shape[0] != hop_size:
if frame.shape[0] > hop_size:
frame = frame[:hop_size]
@@ -260,6 +465,11 @@ class AudioBeatDetector:
bpm,
beat_type=event.get("beat_type", "unknown"),
beat_type_confidence=event.get("beat_type_confidence", 0.0),
bar_beat=event.get("bar_beat"),
beats_per_bar=event.get("beats_per_bar"),
is_downbeat=event.get("is_downbeat"),
phase_confidence=event.get("phase_confidence"),
bar_phase_readout=event.get("bar_phase_readout"),
)
finally:
try:
@@ -280,6 +490,7 @@ class AudioBeatDetector:
with self._lock:
self._running = False
self._status["running"] = False
self._runtime = None
# Set from ``main`` so sequence playback can tell real audio from simulated beats.
@@ -299,3 +510,25 @@ def shared_beat_detector_running():
return bool(d.status().get("running"))
except Exception:
return False
def shared_beat_status_snapshot() -> dict:
"""Thread-safe copy of live detector status, or {} if audio is off."""
d = _shared_beat_detector
if d is None:
return {}
try:
return dict(d.status())
except Exception:
return {}
def anchor_shared_bar_phase() -> bool:
"""Anchor bar phase on the shared detector (no-op if audio is off)."""
d = _shared_beat_detector
if d is None:
return False
try:
return bool(d.anchor_bar_phase())
except Exception:
return False

View File

@@ -30,20 +30,64 @@ def read_audio_run_state() -> Dict[str, Any]:
except (OSError, json.JSONDecodeError, TypeError):
return {"enabled": False, "device": None}
if not isinstance(raw, dict):
return {"enabled": False, "device": None}
return {
"enabled": False,
"device": None,
"device_override": "",
"device_select": "",
}
enabled = bool(raw.get("enabled"))
dev = raw.get("device", None)
return {"enabled": enabled, "device": dev}
return {
"enabled": enabled,
"device": dev,
"device_override": str(raw.get("device_override") or ""),
"device_select": str(raw.get("device_select") or ""),
}
def write_audio_run_state(*, enabled: bool, device: Any = None) -> None:
"""Write run intent. When ``enabled`` is false, keep ``device`` from the previous file for next start."""
def write_audio_run_state(
*,
enabled: bool,
device: Any = None,
device_override: str | None = None,
device_select: str | None = None,
) -> None:
"""Write run intent. When ``enabled`` is false, keep device fields from the previous file."""
path = _db_path()
prev = read_audio_run_state()
if enabled:
data = {"enabled": True, "device": device}
data = {
"enabled": True,
"device": device,
"device_override": (
str(device_override)
if device_override is not None
else str(prev.get("device_override") or "")
),
"device_select": (
str(device_select)
if device_select is not None
else str(prev.get("device_select") or "")
),
}
if device_select is None and device is not None:
data["device_select"] = str(device)
else:
data = {"enabled": False, "device": prev.get("device")}
data = {
"enabled": False,
"device": prev.get("device"),
"device_override": (
str(device_override)
if device_override is not None
else str(prev.get("device_override") or "")
),
"device_select": (
str(device_select)
if device_select is not None
else str(prev.get("device_select") or "")
),
}
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:

View File

@@ -299,6 +299,10 @@ def _apply_manual_beat_route_standalone_overlay(
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(),
@@ -350,6 +354,11 @@ def set_sequence_manual_lane_route(
"manual_beat_n": mn,
"beat_counter": bc,
}
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()
@@ -362,6 +371,49 @@ def clear_sequence_manual_lane_route(lane_index: int) -> None:
_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 sync_beat_route_from_push_sequence(
sequence: List[Any],
target_macs: Optional[List[str]] = None,
@@ -438,6 +490,7 @@ def sync_beat_route_from_push_sequence(
)
else:
_apply_manual_beat_route(device_names, wire_preset_id, preset_body)
mark_manual_select_sent_for_targets(device_names, wire_preset_id)
return
wire_id, body = _single_manual_wire_preset(merged_presets)
@@ -547,6 +600,7 @@ def notify_beat_detected() -> None:
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 []
@@ -555,6 +609,8 @@ def notify_beat_detected() -> None:
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):
@@ -564,7 +620,12 @@ def notify_beat_detected() -> None:
c = int(e["beat_counter"])
if (c - 1) % n != 0:
continue
work.append((list(names), str(e.get("wire_preset_id") or "2")))
wire = str(e.get("wire_preset_id") or "2")
target_key = _lane_route_targets_key(names, wire)
if target_key in seen_targets:
continue
seen_targets.add(target_key)
work.append((list(names), wire))
if work:
_preset_session_beats += 1
if not work:

View File

@@ -24,6 +24,16 @@ _sim_beat_token = 0
_beat_run: Optional[Dict[str, Any]] = None
_beat_run_lock = threading.Lock()
_pending_play: Optional[Dict[str, Any]] = None
_pending_play_lock = threading.Lock()
_pending_beat_task: Optional[asyncio.Task] = None
_pending_beat_token = 0
_last_thread_beat_phase: Dict[str, Any] = {
"is_downbeat": True,
"bar_beat": 1,
}
_sim_beat_counter = 0
def _norm_mac(raw: Any) -> Optional[str]:
from models.device import normalize_mac
@@ -299,21 +309,6 @@ def _resolve_colors_with_palette_refs(
return out
def _ordered_unique_preset_ids_from_lanes(lanes: List[List[Dict[str, Any]]]) -> List[str]:
seen: set = set()
out: List[str] = []
for lane in lanes:
for step in lane:
if not isinstance(step, dict):
continue
pid = str(step.get("preset_id") or "").strip()
if not pid or pid in seen:
continue
seen.add(pid)
out.append(pid)
return out
def _display_preset_for_step(
preset_id: str,
presets_map: Dict[str, Any],
@@ -348,6 +343,129 @@ def _preset_inner_from_display_preset(display_preset: Dict[str, Any]) -> Dict[st
return inner
def _ordered_unique_preset_ids_in_lane(lane: List[Dict[str, Any]]) -> List[str]:
seen: set = set()
out: List[str] = []
for step in lane:
if not isinstance(step, dict):
continue
pid = str(step.get("preset_id") or "").strip()
if not pid or pid in seen:
continue
seen.add(pid)
out.append(pid)
return out
def _resolve_lane_device_names(lane_index: int, ctx: Dict[str, Any]) -> List[str]:
"""Device names for one lane (lane groups / whole zone), after lane partition."""
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
sequence_doc: Dict[str, Any] = ctx["sequence_doc"]
zone_doc: Dict[str, Any] = ctx["zone_doc"]
devices = ctx["devices"]
groups = ctx["groups"]
num_lanes = int(ctx["num_lanes"])
lane = lanes[lane_index] if 0 <= lane_index < len(lanes) else []
if not lane:
return []
gids = _group_ids_for_lane_step(sequence_doc, lane[0], lane_index, num_lanes)
device_names = _resolve_step_device_names(
zone_doc, gids, devices, groups, sequence_doc=sequence_doc
)
return _split_device_names_for_lane(
device_names,
lane_index,
num_lanes,
partition_shared_zone=not _lane_has_non_empty_lanes_group_ids(sequence_doc, lane_index),
)
def _build_lane_wire_presets_map(lane_index: int, ctx: Dict[str, Any]) -> Dict[str, Any]:
"""All preset wire bodies for one lane, keyed by preset id."""
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
presets_map: Dict[str, Any] = ctx["presets_map"]
palette_colors: List[Any] = ctx["palette_colors"]
lane = lanes[lane_index] if 0 <= lane_index < len(lanes) else []
inner_by_wire: Dict[str, Any] = {}
for pid in _ordered_unique_preset_ids_in_lane(lane):
disp = _display_preset_for_step(pid, presets_map, palette_colors)
if not disp:
continue
inner_by_wire[str(pid)] = _preset_inner_from_display_preset(disp)
return inner_by_wire
async def _prime_lane(lane_index: int, ctx: Dict[str, Any]) -> None:
"""Upload all lane presets and select step 0 in one message (driver applies presets before select)."""
from models.transport import get_current_sender
from util.beat_driver_route import (
clear_sequence_manual_lane_route,
mark_sequence_manual_lane_select_sent,
set_sequence_manual_lane_route,
)
from util.driver_delivery import deliver_json_messages
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
presets_map: Dict[str, Any] = ctx["presets_map"]
palette_colors: List[Any] = ctx["palette_colors"]
lane_steps = lanes[lane_index] if 0 <= lane_index < len(lanes) else []
if not lane_steps:
return
inner_by_wire = _build_lane_wire_presets_map(lane_index, ctx)
if not inner_by_wire:
return
step0 = lane_steps[0]
preset_id = str(step0.get("preset_id") or "").strip()
if not preset_id:
return
display_preset = _display_preset_for_step(preset_id, presets_map, palette_colors)
if not display_preset:
return
device_names = _resolve_lane_device_names(lane_index, ctx)
macs = _device_names_to_macs(device_names, ctx["devices"])
if not macs:
return
sender = get_current_sender()
if not sender:
raise RuntimeError("Transport not configured")
zone_doc = ctx.get("zone_doc") if isinstance(ctx.get("zone_doc"), dict) else {}
devices_model = ctx["devices"]
wire = str(preset_id)
auto = _coerce_auto(display_preset)
sel: Dict[str, Any] = {}
for n in device_names:
if n:
sel[str(n)] = [wire]
delay_s = 0.05
for mac in macs:
body: Dict[str, Any] = {"v": "1", "presets": dict(inner_by_wire)}
if sel:
body["select"] = sel
msg = json.dumps(body, separators=(",", ":"))
await deliver_json_messages(sender, [msg], [mac], devices_model, delay_s=delay_s)
if auto:
clear_sequence_manual_lane_route(lane_index)
else:
inner = _preset_inner_from_display_preset(display_preset)
set_sequence_manual_lane_route(lane_index, device_names, wire, inner)
mark_sequence_manual_lane_select_sent(lane_index)
async def _prime_all_lanes(ctx: Dict[str, Any]) -> None:
"""One-shot preset upload + first-step select per lane (to each lane's groups)."""
for i in range(int(ctx["num_lanes"])):
await _prime_lane(i, ctx)
ctx["_presets_delivered"] = True
ctx["_sequence_primed"] = True
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
@@ -363,37 +481,33 @@ def _parse_zone_brightness_value(zone_doc: Any) -> int:
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,
)
async def _deliver_zone_brightness_for_sequence(ctx: Dict[str, Any]) -> None:
"""Apply zone/global/group/device brightness like the zone slider (not inside preset ``b``)."""
from models.transport import get_current_sender
from util.brightness_combine import effective_brightness_for_mac
from util.driver_delivery import deliver_json_messages
out = dict(inner)
base = clamp255(out.get("b", 127))
sender = get_current_sender()
if not sender:
return
macs = _union_macs_for_sequence(ctx)
if not macs:
return
zone_doc = ctx.get("zone_doc") if isinstance(ctx.get("zone_doc"), dict) else {}
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:
settings_obj = ctx.get("settings")
groups_model = ctx.get("groups")
devices_model = ctx.get("devices")
for mac in macs:
eff = effective_brightness_for_mac(
settings_obj,
groups_model,
devices_model,
target_mac,
mac,
zone_brightness=zb,
)
out["b"] = multiply_brightness_factors([base, eff])
else:
out["b"] = multiply_brightness_factors([base, zb])
return out
msg = json.dumps({"v": "1", "b": eff, "save": True}, separators=(",", ":"))
await deliver_json_messages(sender, [msg], [mac], devices_model, delay_s=0.05)
def _device_names_to_macs(device_names: List[str], devices: Any) -> List[str]:
@@ -455,52 +569,21 @@ def _union_macs_for_sequence(ctx: Dict[str, Any]) -> List[str]:
return list(z_macs)
def _build_sequence_wire_presets_map(ctx: Dict[str, Any]) -> Dict[str, Any]:
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
presets_map: Dict[str, Any] = ctx["presets_map"]
palette_colors: List[Any] = ctx["palette_colors"]
inner_by_wire: Dict[str, Any] = {}
for pid in _ordered_unique_preset_ids_from_lanes(lanes):
disp = _display_preset_for_step(pid, presets_map, palette_colors)
if not disp:
continue
inner_by_wire[str(pid)] = _preset_inner_from_display_preset(disp)
return inner_by_wire
async def _deliver_sequence_presets_bulk(ctx: Dict[str, Any]) -> None:
"""Push all preset definitions used in the sequence once; step advances use select (auto) only."""
from models.transport import get_current_sender
from util.driver_delivery import deliver_json_messages
inner_by_wire = _build_sequence_wire_presets_map(ctx)
ctx["_sequence_wire_presets"] = inner_by_wire
if not inner_by_wire:
return
sender = get_current_sender()
if not sender:
raise RuntimeError("Transport not configured")
macs = _union_macs_for_sequence(ctx)
if not macs:
return
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_loop(sequence_doc: Dict[str, Any]) -> bool:
raw = sequence_doc.get("loop", sequence_doc.get("sequence_loop", True))
if isinstance(raw, bool):
return raw
if raw is None:
return True
if isinstance(raw, int):
return raw != 0
if isinstance(raw, str):
lo = raw.strip().lower()
if lo in ("false", "0", "no", "off"):
return False
if lo in ("true", "1", "yes", "on"):
return True
return True
def _coerce_auto(preset: Dict[str, Any]) -> bool:
@@ -533,119 +616,17 @@ def _load_palette_colors(profile_id: str) -> List[Any]:
return Palette().read(str(pid)) or []
async def _deliver_preset_for_devices(
preset_id: str,
preset_doc: Dict[str, Any],
device_names: List[str],
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
from util.beat_driver_route import sync_beat_route_from_push_sequence
from util.espnow_message import build_preset_dict
sender = get_current_sender()
if not sender:
raise RuntimeError("Transport not configured")
macs: List[str] = []
seen: set = set()
for nm in device_names:
key = str(nm).strip()
if not key:
continue
m = None
for did in devices.list():
doc = devices.read(did) or {}
if str(doc.get("name") or "").strip() == key:
m = _norm_mac(did)
break
if not m and key.startswith("led-"):
m = _norm_mac(key[4:])
if m and m not in seen:
seen.add(m)
macs.append(m)
if not macs:
return
body = dict(preset_doc)
auto = _coerce_auto(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_base["manual_beat_n"] = n
except (TypeError, ValueError):
pass
wire = str(preset_id)
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:
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, 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_one, target_macs=macs, preserve_parallel_lane_routes=True
)
async def _send_lane(
lane_index: int,
st: Dict[str, Any],
ctx: Dict[str, Any],
) -> None:
"""Apply the current step (select or manual route). Presets must already be on devices."""
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
sequence_doc: Dict[str, Any] = ctx["sequence_doc"]
presets_map: Dict[str, Any] = ctx["presets_map"]
zone_doc: Dict[str, Any] = ctx["zone_doc"]
devices = ctx["devices"]
groups = ctx["groups"]
palette_colors: List[Any] = ctx["palette_colors"]
num_lanes = ctx["num_lanes"]
devices = ctx["devices"]
if st.get("done"):
return
@@ -660,14 +641,14 @@ async def _send_lane(
display_preset = _display_preset_for_step(preset_id, presets_map, palette_colors)
if not display_preset:
return
gids = _group_ids_for_lane_step(sequence_doc, step, lane_index, num_lanes)
gids = _group_ids_for_lane_step(sequence_doc, step, lane_index, int(ctx["num_lanes"]))
device_names = _resolve_step_device_names(
zone_doc, gids, devices, groups, sequence_doc=sequence_doc
ctx["zone_doc"], gids, devices, ctx["groups"], sequence_doc=sequence_doc
)
device_names = _split_device_names_for_lane(
device_names,
lane_index,
num_lanes,
int(ctx["num_lanes"]),
partition_shared_zone=not _lane_has_non_empty_lanes_group_ids(sequence_doc, lane_index),
)
if gids and not device_names:
@@ -676,6 +657,7 @@ async def _send_lane(
from models.transport import get_current_sender
from util.beat_driver_route import (
clear_sequence_manual_lane_route,
mark_sequence_manual_lane_select_sent,
set_sequence_manual_lane_route,
)
from util.driver_delivery import deliver_json_messages
@@ -688,44 +670,29 @@ async def _send_lane(
if not macs:
return
bulk = ctx.get("_sequence_wire_presets")
if isinstance(bulk, dict) and bulk:
auto = _coerce_auto(display_preset)
wire = str(preset_id)
auto = _coerce_auto(display_preset)
if auto:
clear_sequence_manual_lane_route(lane_index)
sel: Dict[str, Any] = {}
for n in device_names:
if n:
sel[str(n)] = [wire]
if not sel:
return
msg = json.dumps({"v": "1", "select": sel}, separators=(",", ":"))
await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05)
else:
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)
sel: Dict[str, Any] = {}
for n in device_names:
if n:
sel[str(n)] = [wire]
if not sel:
return
set_sequence_manual_lane_route(lane_index, device_names, wire, inner)
sel: Dict[str, Any] = {}
for n in device_names:
if n:
sel[str(n)] = [wire]
if sel:
msg = json.dumps({"v": "1", "select": sel}, separators=(",", ":"))
await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05)
else:
set_sequence_manual_lane_route(lane_index, device_names, wire, inner)
return
await _deliver_preset_for_devices(
preset_id,
display_preset,
device_names,
devices,
lane_index=lane_index,
zone_doc=zone_doc,
settings_obj=ctx.get("settings"),
groups_model=groups,
)
mark_sequence_manual_lane_select_sent(lane_index)
async def _send_all_lanes(ctx: Dict[str, Any]) -> None:
@@ -745,7 +712,7 @@ def _build_ctx(
) -> Optional[Dict[str, Any]]:
from models.device import Device
from models.group import Group
from settings import Settings
from settings import get_settings
lanes = [x for x in _normalize_sequence_lanes(sequence_doc) if len(x) > 0]
if not lanes:
@@ -764,9 +731,9 @@ def _build_ctx(
"presets_map": presets_map,
"devices": devices,
"groups": groups,
"settings": Settings(),
"settings": get_settings(),
"palette_colors": palette_colors,
"loop": True,
"loop": _coerce_loop(sequence_doc),
"advance_mode": "beats",
}
@@ -897,7 +864,294 @@ async def process_active_beat_advance() -> None:
else:
ctx["sequence_loop_beat"] = int(ctx.get("sequence_loop_beat", 0)) + 1
if all(s.get("done") for s in lane_states):
stop()
await stop_playback(clear_devices=True)
return
async def _clear_devices_after_sequence(ctx: Dict[str, Any]) -> None:
"""Stop beat routing and clear driver presets for devices used by this sequence run."""
from models.transport import get_current_sender
from util.beat_driver_route import clear_sequence_manual_lane_route, update_beat_route
from util.driver_delivery import deliver_json_messages
num_lanes = int(ctx.get("num_lanes") or 0)
for i in range(num_lanes):
clear_sequence_manual_lane_route(i)
update_beat_route({"enabled": False})
sender = get_current_sender()
if not sender:
return
devices = ctx.get("devices")
macs = _union_macs_for_sequence(ctx)
if not macs:
return
msg = json.dumps({"v": "1", "clear_presets": True, "save": True}, separators=(",", ":"))
await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05)
def _halt_playback_state() -> Optional[Dict[str, Any]]:
"""Drop active run state and cancel simulated beats; return the previous ctx."""
global _beat_run, _sim_beat_task, _sim_beat_token
ctx: Optional[Dict[str, Any]] = None
with _beat_run_lock:
ctx = _beat_run
_beat_run = None
_sim_beat_token += 1
st = _sim_beat_task
_sim_beat_task = None
if st and not st.done():
st.cancel()
return ctx
async def stop_playback(*, clear_devices: bool = True) -> None:
"""Stop sequence playback; optionally clear presets on targeted devices."""
clear_pending_play()
ctx = _halt_playback_state()
if clear_devices and ctx:
await _clear_devices_after_sequence(ctx)
def apply_beat_phase_sync(ctx: Dict[str, Any], mode: str) -> Tuple[bool, bool]:
"""Align beat counters to music.
``step`` (default): beat 1 of the current step on the next counted beat.
``pass``: restart from step 1 of the sequence pass and re-apply presets.
Returns ``(ok, resend_lanes)`` — caller should ``await _send_all_lanes(ctx)`` when resend is true.
"""
if not ctx:
return False, False
mode_norm = str(mode or "step").strip().lower()
lane_states: List[Dict[str, Any]] = ctx.get("lane_states") or []
if mode_norm in ("pass", "sequence", "restart"):
for st in lane_states:
st["stepIdx"] = 0
st["beatCount"] = 0
st["done"] = False
ctx["sequence_loop_beat"] = 0
return True, True
for st in lane_states:
if not st.get("done"):
st["beatCount"] = 0
return True, False
async def sync_beat_phase(mode: str = "step") -> bool:
"""Public entry: align active sequence playback to a musical phase."""
with _beat_run_lock:
ctx = _beat_run
if not ctx:
return False
ok, resend = apply_beat_phase_sync(ctx, mode)
if not ok:
return False
if resend:
await _send_all_lanes(ctx)
return True
def _drain_beat_queue() -> None:
try:
while True:
_thread_beat_queue.get_nowait()
except queue.Empty:
pass
def _reset_beat_side_effects() -> None:
"""Clear manual routes and queued beats so startup cannot select before presets land."""
from util.beat_driver_route import update_beat_route
update_beat_route({"enabled": False})
_drain_beat_queue()
def _sequence_switch_wait_from_settings() -> str:
try:
from settings import get_settings
raw = get_settings().get("sequence_switch_wait", "beat")
mode = _normalize_wait_for({"wait_for": raw}) or "beat"
if mode == "phrase":
return "beat"
return mode
except Exception:
return "beat"
def _normalize_wait_for(play_options: Optional[Dict[str, Any]]) -> Optional[str]:
"""``beat`` | ``downbeat`` | None (immediate)."""
if not isinstance(play_options, dict):
return None
raw = play_options.get("wait_for")
if raw is None:
raw = play_options.get("start_on")
if raw is None:
return None
s = str(raw).strip().lower()
if s in ("beat", "next_beat"):
return "beat"
if s in ("downbeat", "next_downbeat"):
return "downbeat"
return None
def _play_options_without_wait(play_options: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
if not isinstance(play_options, dict):
return play_options
out = dict(play_options)
out.pop("wait_for", None)
out.pop("start_on", None)
return out
def _cancel_pending_beat_waiter() -> None:
global _pending_beat_task, _pending_beat_token
_pending_beat_token += 1
t = _pending_beat_task
_pending_beat_task = None
if t and not t.done():
t.cancel()
def clear_pending_play() -> None:
"""Drop a queued sequence start (e.g. user stop)."""
global _pending_play
with _pending_play_lock:
_pending_play = None
_cancel_pending_beat_waiter()
def pending_play_status() -> Dict[str, Any]:
with _pending_play_lock:
p = _pending_play
if not p:
return {"pending": False}
return {
"pending": True,
"wait_for": p.get("wait_for"),
"sequence_id": p.get("sequence_id"),
"zone_id": p.get("zone_id"),
}
def _beat_phase_from_sources() -> Dict[str, Any]:
from util import audio_detector as ad_mod
if ad_mod.shared_beat_detector_running():
st = ad_mod.shared_beat_status_snapshot()
if st:
return dict(st)
return dict(_last_thread_beat_phase)
def _beat_is_downbeat_from_sources() -> bool:
return bool(_beat_phase_from_sources().get("is_downbeat"))
def _mark_simulated_beat_phase(*, beats_per_bar: int = 4) -> None:
global _sim_beat_counter, _last_thread_beat_phase
bpb = max(1, int(beats_per_bar))
_sim_beat_counter += 1
bar_beat = ((_sim_beat_counter - 1) % bpb) + 1
is_downbeat = bar_beat == 1
_last_thread_beat_phase = {
"bar_beat": bar_beat,
"is_downbeat": is_downbeat,
}
def _queue_pending_start(
zone_id: str,
sequence_id: str,
profile_id: str,
play_options: Optional[Dict[str, Any]],
wait_for: str,
*,
bpm: float,
) -> None:
global _pending_play
clear_pending_play()
with _pending_play_lock:
_pending_play = {
"zone_id": str(zone_id),
"sequence_id": str(sequence_id),
"profile_id": str(profile_id),
"play_options": _play_options_without_wait(play_options),
"wait_for": wait_for,
}
_ensure_pending_beat_waiter(bpm)
def _ensure_pending_beat_waiter(bpm: float) -> None:
"""When nothing is playing and audio is off, emit synthetic beats until pending starts."""
from util import audio_detector as ad_mod
if ad_mod.shared_beat_detector_running():
return
with _beat_run_lock:
if _beat_run:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
global _pending_beat_task, _pending_beat_token
t = _pending_beat_task
if t and not t.done():
t.cancel()
_pending_beat_token += 1
my_tok = _pending_beat_token
_pending_beat_task = loop.create_task(_pending_beat_wait_loop(bpm, my_tok))
async def _pending_beat_wait_loop(bpm: float, my_token: int) -> None:
from util import audio_detector as ad_mod
interval = 60.0 / max(30.0, min(300.0, float(bpm)))
while True:
with _pending_play_lock:
if _pending_beat_token != my_token or _pending_play is None:
return
if ad_mod.shared_beat_detector_running():
return
await asyncio.sleep(interval)
with _pending_play_lock:
if _pending_beat_token != my_token or _pending_play is None:
return
if ad_mod.shared_beat_detector_running():
return
_mark_simulated_beat_phase()
push_thread_beat()
async def _try_consume_pending_play(*, is_downbeat: bool) -> bool:
global _pending_play
with _pending_play_lock:
pending = _pending_play
if not pending:
return False
wait_for = str(pending.get("wait_for") or "beat").strip().lower()
if wait_for == "downbeat" and not is_downbeat:
return False
_pending_play = None
_cancel_pending_beat_waiter()
await _start_immediate(
pending["zone_id"],
pending["sequence_id"],
pending["profile_id"],
pending.get("play_options"),
)
return True
def stop() -> None:
"""Stop server playback state without sending device clear (e.g. before starting another run)."""
clear_pending_play()
_halt_playback_state()
_reset_beat_side_effects()
def push_thread_beat() -> None:
@@ -920,6 +1174,12 @@ async def beat_consumer_loop() -> None:
from util.beat_driver_route import notify_beat_detected
for _ in range(n):
phase = _beat_phase_from_sources()
is_down = bool(phase.get("is_downbeat"))
try:
await _try_consume_pending_play(is_downbeat=is_down)
except Exception as e:
print(f"[sequence-playback] pending start: {e}")
try:
await process_active_beat_advance()
except Exception as e:
@@ -981,20 +1241,10 @@ async def _simulated_beat_loop(ctx: Dict[str, Any], my_token: int, bpm: float) -
return
if ad_mod.shared_beat_detector_running():
continue
_mark_simulated_beat_phase()
push_thread_beat()
def stop() -> None:
global _beat_run, _sim_beat_task, _sim_beat_token
with _beat_run_lock:
_beat_run = None
_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:
"""If zone sequence playback is running this sequence id, stop it (e.g. after save/delete)."""
sid = str(sequence_id).strip()
@@ -1007,7 +1257,11 @@ def stop_if_playing_sequence(sequence_id: str) -> bool:
cur = ctx.get("sequence_id")
if cur is None or str(cur).strip() != sid:
return False
stop()
try:
loop = asyncio.get_running_loop()
loop.create_task(stop_playback(clear_devices=True))
except RuntimeError:
stop()
return True
@@ -1016,6 +1270,29 @@ async def start(
sequence_id: str,
profile_id: str,
play_options: Optional[Dict[str, Any]] = None,
) -> None:
"""Start immediately, or queue until the next beat / downbeat (``wait_for`` in *play_options*)."""
from models.sequence import Sequence
seq_m = Sequence()
sequence_doc = seq_m.read(sequence_id)
if not sequence_doc or str(sequence_doc.get("profile_id")) != str(profile_id):
raise ValueError("sequence not found")
wait_for = _sequence_switch_wait_from_settings()
if wait_for:
bpm = _coerce_simulated_bpm(sequence_doc, play_options)
_queue_pending_start(
zone_id, sequence_id, profile_id, play_options, wait_for, bpm=bpm
)
return
await _start_immediate(zone_id, sequence_id, profile_id, play_options)
async def _start_immediate(
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
@@ -1052,14 +1329,11 @@ async def start(
ctx["zone_id"] = str(zone_id)
ctx["sequence_loop_beat"] = 0
await _deliver_sequence_presets_bulk(ctx)
from util.beat_driver_route import update_beat_route
update_beat_route({"enabled": False})
_reset_beat_side_effects()
await _prime_all_lanes(ctx)
await _deliver_zone_brightness_for_sequence(ctx)
with _beat_run_lock:
_beat_run = ctx
await _send_all_lanes(ctx)
bpm = _coerce_simulated_bpm(sequence_doc, play_options)
loop = asyncio.get_running_loop()