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

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

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

View File

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