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

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

View File

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

View File

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

View File

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

View File

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