From 64c49c9e64fc2e26105d76c4fd4944a429f22d53 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 10:11:03 +0000 Subject: [PATCH] feat(05-01): add cue scheduling loop and preview_update broadcast to tick handler - LOOKAHEAD = 0.060 (60ms window per D-02) - Scheduler iterates show.tracks/cues on each tick, gated on is_playing and show - Cues within lookahead window fire UDP via encode_animation_cmd + UDPSender.send - Past cues (>100ms behind) silently marked fired without UDP dispatch - active_cue tracking per track enables preview state for already-fired blocks - preview_update messages broadcast to all clients on each tick (D-07) - Clear preview_update (animation: null) sent when no active cue on a device --- lightsync/api/ws.py | 62 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/lightsync/api/ws.py b/lightsync/api/ws.py index 4ca45b5..335dc79 100644 --- a/lightsync/api/ws.py +++ b/lightsync/api/ws.py @@ -129,6 +129,68 @@ async def websocket_endpoint(websocket: WebSocket): "tempo": tempo, })) + # ── Cue scheduler (Phase 5 — D-01, D-02) ────────────────── + if is_playing and show is not None: + LOOKAHEAD = 0.060 # 60ms look-ahead window (D-02) + udp = websocket.app.state.udp_sender + preview_updates = [] # batch preview messages + + for track in show.tracks: + device = next( + (d for d in show.devices if str(d.id) == str(track.device_id)), + None, + ) + if device is None: + continue + + active_cue = None # track which cue is active for preview + + for cue in track.cues: + cue_id = str(cue.id) + if cue_id in fired_ids: + # Check if this already-fired cue is still active (for preview) + if cue.timestamp <= position < cue.timestamp + cue.duration: + active_cue = cue + continue + if cue.timestamp > position + LOOKAHEAD: + continue # not due yet + if cue.timestamp < position - 0.1: + # Too far in the past — mark fired without sending + fired_ids.add(cue_id) + continue + + # Fire this cue (D-01) + fired_ids.add(cue_id) + active_cue = cue + if cue.animation: + try: + payload = encode_animation_cmd(cue.animation, cue.params) + udp.send(payload, device.ip, device.port) + except (ValueError, KeyError): + logger.warning("[ws] unknown animation %r", cue.animation) + + # Build preview update for this device (D-07) + if active_cue and active_cue.animation: + color = active_cue.params.get("color", [0, 255, 180]) + preview_updates.append({ + "type": "preview_update", + "device_id": str(track.device_id), + "animation": active_cue.animation, + "color": color, + }) + else: + # No active cue — send clear (D-05: turns dark) + preview_updates.append({ + "type": "preview_update", + "device_id": str(track.device_id), + "animation": None, + "color": None, + }) + + # Broadcast all preview updates + for pu in preview_updates: + await manager.broadcast(pu) + elif msg_type == "play": logger.info("[ws] play event at position=%.3f", float(msg.get("position", 0))) last_beat_reported = None # reset beat tracking on play