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:
File diff suppressed because one or more lines are too long
@@ -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}}
|
||||||
@@ -8,7 +8,7 @@ from models.device import (
|
|||||||
)
|
)
|
||||||
from models.group import Group
|
from models.group import Group
|
||||||
from models.transport import get_current_sender
|
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 util.brightness_combine import effective_brightness_for_mac
|
||||||
from models.wifi_ws_clients import (
|
from models.wifi_ws_clients import (
|
||||||
normalize_tcp_peer_ip,
|
normalize_tcp_peer_ip,
|
||||||
@@ -77,7 +77,7 @@ def _brightness_save_message_json(b_val: int) -> str:
|
|||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
devices = Device()
|
devices = Device()
|
||||||
_group_registry = Group()
|
_group_registry = Group()
|
||||||
_pi_settings = Settings()
|
_pi_settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
def _device_live_connected(dev_dict):
|
def _device_live_connected(dev_dict):
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ from models.group import Group
|
|||||||
from models.device import Device
|
from models.device import Device
|
||||||
from models.transport import get_current_sender
|
from models.transport import get_current_sender
|
||||||
from models.wifi_ws_clients import normalize_tcp_peer_ip, send_json_line_to_ip
|
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
|
from util.brightness_combine import effective_brightness_for_mac
|
||||||
import json
|
import json
|
||||||
|
|
||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
groups = Group()
|
groups = Group()
|
||||||
devices = Device()
|
devices = Device()
|
||||||
_pi_settings = Settings()
|
_pi_settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
def _group_doc_visible_for_profile(doc, profile_id):
|
def _group_doc_visible_for_profile(doc, profile_id):
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ def get_current_profile_id(session=None):
|
|||||||
@with_session
|
@with_session
|
||||||
async def list_sequences(request, session):
|
async def list_sequences(request, session):
|
||||||
"""List sequences for the current profile."""
|
"""List sequences for the current profile."""
|
||||||
|
sequences.load()
|
||||||
current_profile_id = get_current_profile_id(session)
|
current_profile_id = get_current_profile_id(session)
|
||||||
if not current_profile_id:
|
if not current_profile_id:
|
||||||
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
||||||
@@ -97,6 +98,7 @@ async def import_sequence(request, session):
|
|||||||
@with_session
|
@with_session
|
||||||
async def get_sequence(request, session, id):
|
async def get_sequence(request, session, id):
|
||||||
"""Get a specific sequence by ID (current profile only)."""
|
"""Get a specific sequence by ID (current profile only)."""
|
||||||
|
sequences.load()
|
||||||
current_profile_id = get_current_profile_id(session)
|
current_profile_id = get_current_profile_id(session)
|
||||||
seq = sequences.read(id)
|
seq = sequences.read(id)
|
||||||
if (
|
if (
|
||||||
@@ -203,15 +205,46 @@ async def delete_sequence(request, session, id):
|
|||||||
return json.dumps({"error": "Sequence not found"}), 404
|
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")
|
@controller.post("/stop")
|
||||||
@with_session
|
@with_session
|
||||||
async def stop_sequence_playback(request, session):
|
async def stop_sequence_playback(request, session):
|
||||||
"""Stop server-driven zone sequence playback."""
|
"""Stop server-driven zone sequence playback."""
|
||||||
_ = request
|
_ = request
|
||||||
try:
|
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"}
|
return json.dumps({"ok": True}), 200, {"Content-Type": "application/json"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||||
@@ -251,8 +284,12 @@ async def play_sequence(request, session, id):
|
|||||||
try:
|
try:
|
||||||
from util.sequence_playback import start
|
from util.sequence_playback import start
|
||||||
|
|
||||||
await start(zone_id, str(id), str(current_profile_id), data if isinstance(data, dict) else None)
|
play_opts = data if isinstance(data, dict) else None
|
||||||
return json.dumps({"ok": True}), 200, {"Content-Type": "application/json"}
|
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:
|
except ValueError as e:
|
||||||
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import json
|
|||||||
from microdot import Microdot, send_file
|
from microdot import Microdot, send_file
|
||||||
|
|
||||||
from models import wifi_ws_clients
|
from models import wifi_ws_clients
|
||||||
from settings import Settings
|
from settings import get_settings
|
||||||
|
|
||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
settings = Settings()
|
settings = get_settings()
|
||||||
|
|
||||||
@controller.get('')
|
@controller.get('')
|
||||||
async def get_settings(request):
|
async def get_settings(request):
|
||||||
@@ -75,7 +75,21 @@ def _validate_global_brightness(value):
|
|||||||
return v
|
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):
|
async def update_settings(request):
|
||||||
"""Update general settings."""
|
"""Update general settings."""
|
||||||
try:
|
try:
|
||||||
@@ -87,6 +101,10 @@ async def update_settings(request):
|
|||||||
elif key == 'global_brightness' and value is not None:
|
elif key == 'global_brightness' and value is not None:
|
||||||
settings[key] = _validate_global_brightness(value)
|
settings[key] = _validate_global_brightness(value)
|
||||||
global_brightness_changed = True
|
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:
|
else:
|
||||||
settings[key] = value
|
settings[key] = value
|
||||||
settings.save()
|
settings.save()
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ async def zone_content_fragment(request, session, id):
|
|||||||
@controller.get("")
|
@controller.get("")
|
||||||
@with_session
|
@with_session
|
||||||
async def list_zones(request, session):
|
async def list_zones(request, session):
|
||||||
|
zones.load()
|
||||||
profile_id = get_current_profile_id(session)
|
profile_id = get_current_profile_id(session)
|
||||||
current_zone_id = get_current_zone_id(request, session)
|
current_zone_id = get_current_zone_id(request, session)
|
||||||
zone_order = get_profile_zone_order(profile_id) if profile_id else []
|
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>")
|
@controller.get("/<id>")
|
||||||
async def get_zone(request, id):
|
async def get_zone(request, id):
|
||||||
|
zones.load()
|
||||||
z = zones.read(id)
|
z = zones.read(id)
|
||||||
if z:
|
if z:
|
||||||
return json.dumps(z), 200, {"Content-Type": "application/json"}
|
return json.dumps(z), 200, {"Content-Type": "application/json"}
|
||||||
|
|||||||
104
src/main.py
104
src/main.py
@@ -10,7 +10,7 @@ import traceback
|
|||||||
from microdot import Microdot, send_file
|
from microdot import Microdot, send_file
|
||||||
from microdot.websocket import with_websocket
|
from microdot.websocket import with_websocket
|
||||||
from microdot.session import Session
|
from microdot.session import Session
|
||||||
from settings import Settings
|
from settings import get_settings
|
||||||
|
|
||||||
import controllers.preset as preset
|
import controllers.preset as preset
|
||||||
import controllers.profile as profile
|
import controllers.profile as profile
|
||||||
@@ -159,8 +159,12 @@ async def _periodic_wifi_driver_hello_loop(settings, udp_holder) -> None:
|
|||||||
sock.setblocking(False)
|
sock.setblocking(False)
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
try:
|
try:
|
||||||
while True:
|
while not udp_holder.get("closing"):
|
||||||
await asyncio.sleep(interval)
|
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"):
|
if udp_holder.get("closing"):
|
||||||
break
|
break
|
||||||
try:
|
try:
|
||||||
@@ -244,7 +248,7 @@ async def _send_bridge_wifi_channel(settings, sender):
|
|||||||
|
|
||||||
|
|
||||||
async def main(port=80):
|
async def main(port=80):
|
||||||
settings = Settings()
|
settings = get_settings()
|
||||||
print(settings)
|
print(settings)
|
||||||
print("Starting")
|
print("Starting")
|
||||||
|
|
||||||
@@ -377,7 +381,12 @@ async def main(port=80):
|
|||||||
audio_detector.start(device=device)
|
audio_detector.start(device=device)
|
||||||
from util.audio_run_persist import write_audio_run_state
|
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()}
|
return {"ok": True, "status": audio_detector.status()}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"ok": False, "error": str(e)}, 500
|
return {"ok": False, "error": str(e)}, 500
|
||||||
@@ -391,6 +400,24 @@ async def main(port=80):
|
|||||||
write_audio_run_state(enabled=False)
|
write_audio_run_state(enabled=False)
|
||||||
return {"ok": True, "status": audio_detector.status()}
|
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')
|
@app.route('/api/audio/status')
|
||||||
async def audio_status(request):
|
async def audio_status(request):
|
||||||
_ = request
|
_ = request
|
||||||
@@ -426,6 +453,14 @@ async def main(port=80):
|
|||||||
if bs > 0:
|
if bs > 0:
|
||||||
beat_readout = str(bs)
|
beat_readout = str(bs)
|
||||||
st["beat_readout"] = beat_readout
|
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}
|
return {"status": st}
|
||||||
|
|
||||||
# Static file route
|
# Static file route
|
||||||
@@ -480,16 +515,30 @@ async def main(port=80):
|
|||||||
await _send_bridge_wifi_channel(settings, sender)
|
await _send_bridge_wifi_channel(settings, sender)
|
||||||
_prime_wifi_outbound_driver_connections()
|
_prime_wifi_outbound_driver_connections()
|
||||||
|
|
||||||
udp_holder = {"closing": False}
|
udp_holder = {"closing": False, "shutting_down": False}
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
|
server_tasks: list[asyncio.Task] = []
|
||||||
|
|
||||||
def _graceful_shutdown(*_args):
|
def _graceful_shutdown(*_args):
|
||||||
|
if udp_holder.get("shutting_down"):
|
||||||
|
raise SystemExit(0)
|
||||||
|
udp_holder["shutting_down"] = True
|
||||||
print("[server] shutting down...")
|
print("[server] shutting down...")
|
||||||
udp_holder["closing"] = True
|
udp_holder["closing"] = True
|
||||||
try:
|
try:
|
||||||
audio_detector.stop()
|
audio_detector.stop()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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")
|
u = udp_holder.get("sock")
|
||||||
if u is not None:
|
if u is not None:
|
||||||
try:
|
try:
|
||||||
@@ -498,7 +547,13 @@ async def main(port=80):
|
|||||||
pass
|
pass
|
||||||
tcp_client_registry.cancel_all_driver_tasks()
|
tcp_client_registry.cancel_all_driver_tasks()
|
||||||
if getattr(app, "server", None) is not None:
|
if getattr(app, "server", None) is not None:
|
||||||
|
try:
|
||||||
app.shutdown()
|
app.shutdown()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
for t in server_tasks:
|
||||||
|
if not t.done():
|
||||||
|
t.cancel()
|
||||||
|
|
||||||
shutdown_handlers_registered = False
|
shutdown_handlers_registered = False
|
||||||
try:
|
try:
|
||||||
@@ -511,11 +566,21 @@ async def main(port=80):
|
|||||||
|
|
||||||
# Await HTTP + UDP discovery; bind failures (e.g. port 80 in use) surface here.
|
# Await HTTP + UDP discovery; bind failures (e.g. port 80 in use) surface here.
|
||||||
try:
|
try:
|
||||||
await asyncio.gather(
|
server_tasks[:] = [
|
||||||
app.start_server(host="0.0.0.0", port=port),
|
asyncio.create_task(
|
||||||
_run_udp_discovery_server(udp_holder),
|
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),
|
_periodic_wifi_driver_hello_loop(settings, udp_holder),
|
||||||
)
|
name="hello",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
await asyncio.gather(*server_tasks)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
if e.errno == errno.EADDRINUSE:
|
if e.errno == errno.EADDRINUSE:
|
||||||
print(
|
print(
|
||||||
@@ -540,6 +605,21 @@ async def main(port=80):
|
|||||||
app.server = None
|
app.server = None
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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:
|
if shutdown_handlers_registered:
|
||||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||||
try:
|
try:
|
||||||
@@ -549,5 +629,9 @@ async def main(port=80):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import os
|
import os
|
||||||
|
|
||||||
port = int(os.environ.get("PORT", 80))
|
port = int(os.environ.get("PORT", 80))
|
||||||
|
try:
|
||||||
asyncio.run(main(port=port))
|
asyncio.run(main(port=port))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("[server] interrupted")
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class Zone(Model):
|
|||||||
"""Preset layout row (stored in ``db/zone.json``); legacy storage was ``tab.json`` / Tab.
|
"""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\"``
|
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):
|
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):
|
if "preset_group_ids" not in doc or not isinstance(doc.get("preset_group_ids"), dict):
|
||||||
doc["preset_group_ids"] = {}
|
doc["preset_group_ids"] = {}
|
||||||
changed = True
|
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:
|
if changed:
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
@@ -53,6 +59,41 @@ class Zone(Model):
|
|||||||
kind = doc.get("content_kind")
|
kind = doc.get("content_kind")
|
||||||
return kind if kind in ("presets", "sequences") else None
|
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):
|
def _enforce_content_kind_invariants(self, doc):
|
||||||
"""Presets-only zones hold no sequences; sequences-only hold no preset tiles."""
|
"""Presets-only zones hold no sequences; sequences-only hold no preset tiles."""
|
||||||
kind = self._normalized_content_kind(doc)
|
kind = self._normalized_content_kind(doc)
|
||||||
@@ -95,6 +136,7 @@ class Zone(Model):
|
|||||||
return False
|
return False
|
||||||
patch = data if isinstance(data, dict) else {}
|
patch = data if isinstance(data, dict) else {}
|
||||||
self[id_str].update(patch)
|
self[id_str].update(patch)
|
||||||
|
if "content_kind" in patch:
|
||||||
self._enforce_content_kind_invariants(self[id_str])
|
self._enforce_content_kind_invariants(self[id_str])
|
||||||
self.save()
|
self.save()
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -12,11 +12,15 @@ def _settings_path():
|
|||||||
return "settings.json"
|
return "settings.json"
|
||||||
|
|
||||||
|
|
||||||
|
_settings_singleton: "Settings | None" = None
|
||||||
|
|
||||||
|
|
||||||
class Settings(dict):
|
class Settings(dict):
|
||||||
SETTINGS_FILE = None # Set in __init__ from _settings_path()
|
SETTINGS_FILE = None # Set in __init__ from _settings_path()
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, *, quiet: bool = False):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
self._quiet = quiet
|
||||||
if Settings.SETTINGS_FILE is None:
|
if Settings.SETTINGS_FILE is None:
|
||||||
Settings.SETTINGS_FILE = _settings_path()
|
Settings.SETTINGS_FILE = _settings_path()
|
||||||
self.load() # Load settings from file during initialization
|
self.load() # Load settings from file during initialization
|
||||||
@@ -79,12 +83,21 @@ class Settings(dict):
|
|||||||
# Zone UI global brightness (0–255); shared across browsers/devices.
|
# Zone UI global brightness (0–255); shared across browsers/devices.
|
||||||
if 'global_brightness' not in self:
|
if 'global_brightness' not in self:
|
||||||
self['global_brightness'] = 255
|
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):
|
def save(self):
|
||||||
try:
|
try:
|
||||||
j = json.dumps(self)
|
j = json.dumps(self)
|
||||||
with open(self.SETTINGS_FILE, 'w') as file:
|
with open(self.SETTINGS_FILE, 'w') as file:
|
||||||
file.write(j)
|
file.write(j)
|
||||||
|
if not getattr(self, "_quiet", False):
|
||||||
print("Settings saved successfully.")
|
print("Settings saved successfully.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error saving settings: {e}")
|
print(f"Error saving settings: {e}")
|
||||||
@@ -96,9 +109,11 @@ class Settings(dict):
|
|||||||
loaded_settings = json.load(file)
|
loaded_settings = json.load(file)
|
||||||
self.update(loaded_settings)
|
self.update(loaded_settings)
|
||||||
loaded_from_file = True
|
loaded_from_file = True
|
||||||
|
if not getattr(self, "_quiet", False):
|
||||||
print("Settings loaded successfully.")
|
print("Settings loaded successfully.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error loading settings")
|
if not getattr(self, "_quiet", False):
|
||||||
|
print(f"Error loading settings: {e}")
|
||||||
self.clear()
|
self.clear()
|
||||||
finally:
|
finally:
|
||||||
# Ensure defaults are set even if file exists but is missing keys
|
# 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
|
# Only save if file didn't exist or was invalid
|
||||||
if not loaded_from_file:
|
if not loaded_from_file:
|
||||||
self.save()
|
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
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import os
|
|||||||
import queue
|
import queue
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
_HOLDOVER_BPM_MIN = 30.0
|
||||||
|
_HOLDOVER_BPM_MAX = 300.0
|
||||||
|
_HOLDOVER_MAX_S = 300.0
|
||||||
|
|
||||||
|
|
||||||
class AudioBeatDetector:
|
class AudioBeatDetector:
|
||||||
@@ -13,6 +19,11 @@ class AudioBeatDetector:
|
|||||||
self._stream = None
|
self._stream = None
|
||||||
self._running = False
|
self._running = False
|
||||||
self._stop_event = threading.Event()
|
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 = {
|
self._status = {
|
||||||
"running": False,
|
"running": False,
|
||||||
"bpm": None,
|
"bpm": None,
|
||||||
@@ -20,6 +31,11 @@ class AudioBeatDetector:
|
|||||||
"beat_seq": 0,
|
"beat_seq": 0,
|
||||||
"beat_type": "unknown",
|
"beat_type": "unknown",
|
||||||
"beat_type_confidence": 0.0,
|
"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,
|
"error": None,
|
||||||
"device": None,
|
"device": None,
|
||||||
}
|
}
|
||||||
@@ -100,6 +116,11 @@ class AudioBeatDetector:
|
|||||||
"beat_seq": 0,
|
"beat_seq": 0,
|
||||||
"beat_type": "unknown",
|
"beat_type": "unknown",
|
||||||
"beat_type_confidence": 0.0,
|
"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,
|
"error": None,
|
||||||
"device": device,
|
"device": device,
|
||||||
}
|
}
|
||||||
@@ -111,6 +132,7 @@ class AudioBeatDetector:
|
|||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
self._stop_bpm_holdover()
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
t = self._thread
|
t = self._thread
|
||||||
@@ -139,11 +161,159 @@ class AudioBeatDetector:
|
|||||||
self._running = False
|
self._running = False
|
||||||
self._thread = None
|
self._thread = None
|
||||||
self._stream = None
|
self._stream = None
|
||||||
|
self._pending_reset = False
|
||||||
self._status["running"] = False
|
self._status["running"] = False
|
||||||
|
|
||||||
def status(self):
|
def status(self):
|
||||||
with self._lock:
|
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):
|
def _set_error(self, msg):
|
||||||
print(f"[audio] {msg}")
|
print(f"[audio] {msg}")
|
||||||
@@ -152,7 +322,28 @@ class AudioBeatDetector:
|
|||||||
self._status["running"] = False
|
self._status["running"] = False
|
||||||
self._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()
|
now = time.time()
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._status["last_beat_ts"] = now
|
self._status["last_beat_ts"] = now
|
||||||
@@ -160,6 +351,16 @@ class AudioBeatDetector:
|
|||||||
self._status["beat_type"] = beat_type
|
self._status["beat_type"] = beat_type
|
||||||
self._status["beat_type_confidence"] = float(beat_type_confidence or 0.0)
|
self._status["beat_type_confidence"] = float(beat_type_confidence or 0.0)
|
||||||
self._status["beat_seq"] = int(self._status.get("beat_seq", 0)) + 1
|
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:
|
try:
|
||||||
from util import sequence_playback as seq_pb
|
from util import sequence_playback as seq_pb
|
||||||
|
|
||||||
@@ -210,15 +411,17 @@ class AudioBeatDetector:
|
|||||||
flux_weight=0.3,
|
flux_weight=0.3,
|
||||||
threshold_multiplier=1.35,
|
threshold_multiplier=1.35,
|
||||||
ema_alpha=0.08,
|
ema_alpha=0.08,
|
||||||
min_ioi_ms=85.0,
|
min_ioi_ms=100.0,
|
||||||
bpm_window=8,
|
bpm_window=8,
|
||||||
post_url="",
|
post_url="",
|
||||||
aubio_method="default",
|
aubio_method="default",
|
||||||
aubio_threshold=0.12,
|
aubio_threshold=0.14,
|
||||||
silence_gate_db=-58.0,
|
beats_per_bar=4,
|
||||||
)
|
)
|
||||||
runtime = beat_mod.BeatDetectRuntime(args)
|
runtime = beat_mod.BeatDetectRuntime(args)
|
||||||
runtime.setup(sample_rate=sample_rate)
|
runtime.setup(sample_rate=sample_rate)
|
||||||
|
with self._lock:
|
||||||
|
self._runtime = runtime
|
||||||
hop_size = runtime.frame_size
|
hop_size = runtime.frame_size
|
||||||
|
|
||||||
audio_q = queue.Queue(maxsize=64)
|
audio_q = queue.Queue(maxsize=64)
|
||||||
@@ -243,10 +446,12 @@ class AudioBeatDetector:
|
|||||||
stream.start()
|
stream.start()
|
||||||
try:
|
try:
|
||||||
while not self._stop_event.is_set():
|
while not self._stop_event.is_set():
|
||||||
|
self._process_pending_reset(runtime)
|
||||||
try:
|
try:
|
||||||
frame = audio_q.get(timeout=0.1)
|
frame = audio_q.get(timeout=0.1)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
continue
|
continue
|
||||||
|
self._process_pending_reset(runtime)
|
||||||
if frame.shape[0] != hop_size:
|
if frame.shape[0] != hop_size:
|
||||||
if frame.shape[0] > hop_size:
|
if frame.shape[0] > hop_size:
|
||||||
frame = frame[:hop_size]
|
frame = frame[:hop_size]
|
||||||
@@ -260,6 +465,11 @@ class AudioBeatDetector:
|
|||||||
bpm,
|
bpm,
|
||||||
beat_type=event.get("beat_type", "unknown"),
|
beat_type=event.get("beat_type", "unknown"),
|
||||||
beat_type_confidence=event.get("beat_type_confidence", 0.0),
|
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:
|
finally:
|
||||||
try:
|
try:
|
||||||
@@ -280,6 +490,7 @@ class AudioBeatDetector:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
self._running = False
|
self._running = False
|
||||||
self._status["running"] = False
|
self._status["running"] = False
|
||||||
|
self._runtime = None
|
||||||
|
|
||||||
|
|
||||||
# Set from ``main`` so sequence playback can tell real audio from simulated beats.
|
# 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"))
|
return bool(d.status().get("running"))
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
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
|
||||||
|
|||||||
@@ -30,20 +30,64 @@ def read_audio_run_state() -> Dict[str, Any]:
|
|||||||
except (OSError, json.JSONDecodeError, TypeError):
|
except (OSError, json.JSONDecodeError, TypeError):
|
||||||
return {"enabled": False, "device": None}
|
return {"enabled": False, "device": None}
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
return {"enabled": False, "device": None}
|
return {
|
||||||
|
"enabled": False,
|
||||||
|
"device": None,
|
||||||
|
"device_override": "",
|
||||||
|
"device_select": "",
|
||||||
|
}
|
||||||
enabled = bool(raw.get("enabled"))
|
enabled = bool(raw.get("enabled"))
|
||||||
dev = raw.get("device", None)
|
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:
|
def write_audio_run_state(
|
||||||
"""Write run intent. When ``enabled`` is false, keep ``device`` from the previous file for next start."""
|
*,
|
||||||
|
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()
|
path = _db_path()
|
||||||
prev = read_audio_run_state()
|
prev = read_audio_run_state()
|
||||||
if enabled:
|
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:
|
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:
|
try:
|
||||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
|||||||
@@ -299,6 +299,10 @@ def _apply_manual_beat_route_standalone_overlay(
|
|||||||
return
|
return
|
||||||
names = [str(n).strip() for n in device_names if str(n).strip()]
|
names = [str(n).strip() for n in device_names if str(n).strip()]
|
||||||
with _route_lock:
|
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] = {
|
_lane_manual[-1] = {
|
||||||
"device_names": names,
|
"device_names": names,
|
||||||
"wire_preset_id": str(wire_preset_id).strip(),
|
"wire_preset_id": str(wire_preset_id).strip(),
|
||||||
@@ -350,6 +354,11 @@ def set_sequence_manual_lane_route(
|
|||||||
"manual_beat_n": mn,
|
"manual_beat_n": mn,
|
||||||
"beat_counter": bc,
|
"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()
|
_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()
|
_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(
|
def sync_beat_route_from_push_sequence(
|
||||||
sequence: List[Any],
|
sequence: List[Any],
|
||||||
target_macs: Optional[List[str]] = None,
|
target_macs: Optional[List[str]] = None,
|
||||||
@@ -438,6 +490,7 @@ def sync_beat_route_from_push_sequence(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
_apply_manual_beat_route(device_names, wire_preset_id, preset_body)
|
_apply_manual_beat_route(device_names, wire_preset_id, preset_body)
|
||||||
|
mark_manual_select_sent_for_targets(device_names, wire_preset_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
wire_id, body = _single_manual_wire_preset(merged_presets)
|
wire_id, body = _single_manual_wire_preset(merged_presets)
|
||||||
@@ -547,6 +600,7 @@ def notify_beat_detected() -> None:
|
|||||||
if not _lane_manual:
|
if not _lane_manual:
|
||||||
return
|
return
|
||||||
work = []
|
work = []
|
||||||
|
seen_targets: Set[Tuple[Tuple[str, ...], str]] = set()
|
||||||
for key in sorted(_lane_manual.keys()):
|
for key in sorted(_lane_manual.keys()):
|
||||||
e = _lane_manual[key]
|
e = _lane_manual[key]
|
||||||
names = e.get("device_names") or []
|
names = e.get("device_names") or []
|
||||||
@@ -555,6 +609,8 @@ def notify_beat_detected() -> None:
|
|||||||
pattern = str(e.get("pattern") or "")
|
pattern = str(e.get("pattern") or "")
|
||||||
if pattern and not _pattern_supports_manual(pattern):
|
if pattern and not _pattern_supports_manual(pattern):
|
||||||
continue
|
continue
|
||||||
|
if e.pop("suppress_next_notify", False):
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
n = int(e.get("manual_beat_n") or 1)
|
n = int(e.get("manual_beat_n") or 1)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -564,7 +620,12 @@ def notify_beat_detected() -> None:
|
|||||||
c = int(e["beat_counter"])
|
c = int(e["beat_counter"])
|
||||||
if (c - 1) % n != 0:
|
if (c - 1) % n != 0:
|
||||||
continue
|
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:
|
if work:
|
||||||
_preset_session_beats += 1
|
_preset_session_beats += 1
|
||||||
if not work:
|
if not work:
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ _sim_beat_token = 0
|
|||||||
_beat_run: Optional[Dict[str, Any]] = None
|
_beat_run: Optional[Dict[str, Any]] = None
|
||||||
_beat_run_lock = threading.Lock()
|
_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]:
|
def _norm_mac(raw: Any) -> Optional[str]:
|
||||||
from models.device import normalize_mac
|
from models.device import normalize_mac
|
||||||
@@ -299,21 +309,6 @@ def _resolve_colors_with_palette_refs(
|
|||||||
return out
|
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(
|
def _display_preset_for_step(
|
||||||
preset_id: str,
|
preset_id: str,
|
||||||
presets_map: Dict[str, Any],
|
presets_map: Dict[str, Any],
|
||||||
@@ -348,6 +343,129 @@ def _preset_inner_from_display_preset(display_preset: Dict[str, Any]) -> Dict[st
|
|||||||
return inner
|
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:
|
def _parse_zone_brightness_value(zone_doc: Any) -> int:
|
||||||
"""Zone slider value stored on the zone row (0–255); default 255 if unset."""
|
"""Zone slider value stored on the zone row (0–255); default 255 if unset."""
|
||||||
from util.brightness_combine import clamp255
|
from util.brightness_combine import clamp255
|
||||||
@@ -363,37 +481,33 @@ def _parse_zone_brightness_value(zone_doc: Any) -> int:
|
|||||||
return 255
|
return 255
|
||||||
|
|
||||||
|
|
||||||
def _inner_wire_b_with_sequence_zone_brightness(
|
async def _deliver_zone_brightness_for_sequence(ctx: Dict[str, Any]) -> None:
|
||||||
inner: Dict[str, Any],
|
"""Apply zone/global/group/device brightness like the zone slider (not inside preset ``b``)."""
|
||||||
zone_doc: Dict[str, Any],
|
from models.transport import get_current_sender
|
||||||
*,
|
from util.brightness_combine import effective_brightness_for_mac
|
||||||
target_mac: Optional[str],
|
from util.driver_delivery import deliver_json_messages
|
||||||
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)
|
sender = get_current_sender()
|
||||||
base = clamp255(out.get("b", 127))
|
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)
|
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(
|
eff = effective_brightness_for_mac(
|
||||||
settings_obj,
|
settings_obj,
|
||||||
groups_model,
|
groups_model,
|
||||||
devices_model,
|
devices_model,
|
||||||
target_mac,
|
mac,
|
||||||
zone_brightness=zb,
|
zone_brightness=zb,
|
||||||
)
|
)
|
||||||
out["b"] = multiply_brightness_factors([base, eff])
|
msg = json.dumps({"v": "1", "b": eff, "save": True}, separators=(",", ":"))
|
||||||
else:
|
await deliver_json_messages(sender, [msg], [mac], devices_model, delay_s=0.05)
|
||||||
out["b"] = multiply_brightness_factors([base, zb])
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _device_names_to_macs(device_names: List[str], devices: Any) -> List[str]:
|
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)
|
return list(z_macs)
|
||||||
|
|
||||||
|
|
||||||
def _build_sequence_wire_presets_map(ctx: Dict[str, Any]) -> Dict[str, Any]:
|
def _coerce_loop(sequence_doc: Dict[str, Any]) -> bool:
|
||||||
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
|
raw = sequence_doc.get("loop", sequence_doc.get("sequence_loop", True))
|
||||||
presets_map: Dict[str, Any] = ctx["presets_map"]
|
if isinstance(raw, bool):
|
||||||
palette_colors: List[Any] = ctx["palette_colors"]
|
return raw
|
||||||
inner_by_wire: Dict[str, Any] = {}
|
if raw is None:
|
||||||
for pid in _ordered_unique_preset_ids_from_lanes(lanes):
|
return True
|
||||||
disp = _display_preset_for_step(pid, presets_map, palette_colors)
|
if isinstance(raw, int):
|
||||||
if not disp:
|
return raw != 0
|
||||||
continue
|
if isinstance(raw, str):
|
||||||
inner_by_wire[str(pid)] = _preset_inner_from_display_preset(disp)
|
lo = raw.strip().lower()
|
||||||
return inner_by_wire
|
if lo in ("false", "0", "no", "off"):
|
||||||
|
return False
|
||||||
|
if lo in ("true", "1", "yes", "on"):
|
||||||
async def _deliver_sequence_presets_bulk(ctx: Dict[str, Any]) -> None:
|
return True
|
||||||
"""Push all preset definitions used in the sequence once; step advances use select (auto) only."""
|
return True
|
||||||
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_auto(preset: Dict[str, Any]) -> bool:
|
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 []
|
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(
|
async def _send_lane(
|
||||||
lane_index: int,
|
lane_index: int,
|
||||||
st: Dict[str, Any],
|
st: Dict[str, Any],
|
||||||
ctx: Dict[str, Any],
|
ctx: Dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Apply the current step (select or manual route). Presets must already be on devices."""
|
||||||
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
|
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
|
||||||
sequence_doc: Dict[str, Any] = ctx["sequence_doc"]
|
sequence_doc: Dict[str, Any] = ctx["sequence_doc"]
|
||||||
presets_map: Dict[str, Any] = ctx["presets_map"]
|
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"]
|
palette_colors: List[Any] = ctx["palette_colors"]
|
||||||
num_lanes = ctx["num_lanes"]
|
devices = ctx["devices"]
|
||||||
|
|
||||||
if st.get("done"):
|
if st.get("done"):
|
||||||
return
|
return
|
||||||
@@ -660,14 +641,14 @@ async def _send_lane(
|
|||||||
display_preset = _display_preset_for_step(preset_id, presets_map, palette_colors)
|
display_preset = _display_preset_for_step(preset_id, presets_map, palette_colors)
|
||||||
if not display_preset:
|
if not display_preset:
|
||||||
return
|
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(
|
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 = _split_device_names_for_lane(
|
||||||
device_names,
|
device_names,
|
||||||
lane_index,
|
lane_index,
|
||||||
num_lanes,
|
int(ctx["num_lanes"]),
|
||||||
partition_shared_zone=not _lane_has_non_empty_lanes_group_ids(sequence_doc, lane_index),
|
partition_shared_zone=not _lane_has_non_empty_lanes_group_ids(sequence_doc, lane_index),
|
||||||
)
|
)
|
||||||
if gids and not device_names:
|
if gids and not device_names:
|
||||||
@@ -676,6 +657,7 @@ async def _send_lane(
|
|||||||
from models.transport import get_current_sender
|
from models.transport import get_current_sender
|
||||||
from util.beat_driver_route import (
|
from util.beat_driver_route import (
|
||||||
clear_sequence_manual_lane_route,
|
clear_sequence_manual_lane_route,
|
||||||
|
mark_sequence_manual_lane_select_sent,
|
||||||
set_sequence_manual_lane_route,
|
set_sequence_manual_lane_route,
|
||||||
)
|
)
|
||||||
from util.driver_delivery import deliver_json_messages
|
from util.driver_delivery import deliver_json_messages
|
||||||
@@ -688,20 +670,8 @@ async def _send_lane(
|
|||||||
if not macs:
|
if not macs:
|
||||||
return
|
return
|
||||||
|
|
||||||
bulk = ctx.get("_sequence_wire_presets")
|
|
||||||
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)
|
wire = str(preset_id)
|
||||||
|
auto = _coerce_auto(display_preset)
|
||||||
if auto:
|
if auto:
|
||||||
clear_sequence_manual_lane_route(lane_index)
|
clear_sequence_manual_lane_route(lane_index)
|
||||||
sel: Dict[str, Any] = {}
|
sel: Dict[str, Any] = {}
|
||||||
@@ -713,19 +683,16 @@ async def _send_lane(
|
|||||||
msg = json.dumps({"v": "1", "select": sel}, separators=(",", ":"))
|
msg = json.dumps({"v": "1", "select": sel}, separators=(",", ":"))
|
||||||
await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05)
|
await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05)
|
||||||
else:
|
else:
|
||||||
|
inner = _preset_inner_from_display_preset(display_preset)
|
||||||
set_sequence_manual_lane_route(lane_index, device_names, wire, inner)
|
set_sequence_manual_lane_route(lane_index, device_names, wire, inner)
|
||||||
return
|
sel: Dict[str, Any] = {}
|
||||||
|
for n in device_names:
|
||||||
await _deliver_preset_for_devices(
|
if n:
|
||||||
preset_id,
|
sel[str(n)] = [wire]
|
||||||
display_preset,
|
if sel:
|
||||||
device_names,
|
msg = json.dumps({"v": "1", "select": sel}, separators=(",", ":"))
|
||||||
devices,
|
await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05)
|
||||||
lane_index=lane_index,
|
mark_sequence_manual_lane_select_sent(lane_index)
|
||||||
zone_doc=zone_doc,
|
|
||||||
settings_obj=ctx.get("settings"),
|
|
||||||
groups_model=groups,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_all_lanes(ctx: Dict[str, Any]) -> None:
|
async def _send_all_lanes(ctx: Dict[str, Any]) -> None:
|
||||||
@@ -745,7 +712,7 @@ def _build_ctx(
|
|||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
from models.device import Device
|
from models.device import Device
|
||||||
from models.group import Group
|
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]
|
lanes = [x for x in _normalize_sequence_lanes(sequence_doc) if len(x) > 0]
|
||||||
if not lanes:
|
if not lanes:
|
||||||
@@ -764,9 +731,9 @@ def _build_ctx(
|
|||||||
"presets_map": presets_map,
|
"presets_map": presets_map,
|
||||||
"devices": devices,
|
"devices": devices,
|
||||||
"groups": groups,
|
"groups": groups,
|
||||||
"settings": Settings(),
|
"settings": get_settings(),
|
||||||
"palette_colors": palette_colors,
|
"palette_colors": palette_colors,
|
||||||
"loop": True,
|
"loop": _coerce_loop(sequence_doc),
|
||||||
"advance_mode": "beats",
|
"advance_mode": "beats",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -897,7 +864,294 @@ async def process_active_beat_advance() -> None:
|
|||||||
else:
|
else:
|
||||||
ctx["sequence_loop_beat"] = int(ctx.get("sequence_loop_beat", 0)) + 1
|
ctx["sequence_loop_beat"] = int(ctx.get("sequence_loop_beat", 0)) + 1
|
||||||
if all(s.get("done") for s in lane_states):
|
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:
|
def push_thread_beat() -> None:
|
||||||
@@ -920,6 +1174,12 @@ async def beat_consumer_loop() -> None:
|
|||||||
from util.beat_driver_route import notify_beat_detected
|
from util.beat_driver_route import notify_beat_detected
|
||||||
|
|
||||||
for _ in range(n):
|
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:
|
try:
|
||||||
await process_active_beat_advance()
|
await process_active_beat_advance()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -981,20 +1241,10 @@ async def _simulated_beat_loop(ctx: Dict[str, Any], my_token: int, bpm: float) -
|
|||||||
return
|
return
|
||||||
if ad_mod.shared_beat_detector_running():
|
if ad_mod.shared_beat_detector_running():
|
||||||
continue
|
continue
|
||||||
|
_mark_simulated_beat_phase()
|
||||||
push_thread_beat()
|
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:
|
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)."""
|
"""If zone sequence playback is running this sequence id, stop it (e.g. after save/delete)."""
|
||||||
sid = str(sequence_id).strip()
|
sid = str(sequence_id).strip()
|
||||||
@@ -1007,6 +1257,10 @@ def stop_if_playing_sequence(sequence_id: str) -> bool:
|
|||||||
cur = ctx.get("sequence_id")
|
cur = ctx.get("sequence_id")
|
||||||
if cur is None or str(cur).strip() != sid:
|
if cur is None or str(cur).strip() != sid:
|
||||||
return False
|
return False
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
loop.create_task(stop_playback(clear_devices=True))
|
||||||
|
except RuntimeError:
|
||||||
stop()
|
stop()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -1016,6 +1270,29 @@ async def start(
|
|||||||
sequence_id: str,
|
sequence_id: str,
|
||||||
profile_id: str,
|
profile_id: str,
|
||||||
play_options: Optional[Dict[str, Any]] = None,
|
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:
|
) -> None:
|
||||||
global _beat_run, _sim_beat_task, _sim_beat_token
|
global _beat_run, _sim_beat_task, _sim_beat_token
|
||||||
from models.preset import Preset
|
from models.preset import Preset
|
||||||
@@ -1052,14 +1329,11 @@ async def start(
|
|||||||
ctx["zone_id"] = str(zone_id)
|
ctx["zone_id"] = str(zone_id)
|
||||||
ctx["sequence_loop_beat"] = 0
|
ctx["sequence_loop_beat"] = 0
|
||||||
|
|
||||||
await _deliver_sequence_presets_bulk(ctx)
|
_reset_beat_side_effects()
|
||||||
|
await _prime_all_lanes(ctx)
|
||||||
from util.beat_driver_route import update_beat_route
|
await _deliver_zone_brightness_for_sequence(ctx)
|
||||||
|
|
||||||
update_beat_route({"enabled": False})
|
|
||||||
with _beat_run_lock:
|
with _beat_run_lock:
|
||||||
_beat_run = ctx
|
_beat_run = ctx
|
||||||
await _send_all_lanes(ctx)
|
|
||||||
|
|
||||||
bpm = _coerce_simulated_bpm(sequence_doc, play_options)
|
bpm = _coerce_simulated_bpm(sequence_doc, play_options)
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
|
|||||||
Reference in New Issue
Block a user