--- phase: 05-live-show-execution plan: 01 type: execute wave: 1 depends_on: [] files_modified: - lightsync/api/ws.py autonomous: true requirements: - SHW-03 must_haves: truths: - "Server fires UDP animation commands when audio position reaches a cue timestamp" - "Seeking resets the cue pointer so past cues are marked fired and future cues re-arm" - "Loading a show via WebSocket populates per-connection cue list from show_store" - "Cues within 60ms ahead of tick position fire immediately (never late)" - "Pausing does not add cues to fired_ids; cues only fire during active playback" artifacts: - path: "lightsync/api/ws.py" provides: "Server-side cue scheduler integrated into tick handler" contains: "LOOKAHEAD = 0.060" key_links: - from: "lightsync/api/ws.py" to: "lightsync/protocol/animation_cmd.encode_animation_cmd" via: "import and call in tick branch" pattern: "encode_animation_cmd" - from: "lightsync/api/ws.py" to: "lightsync/protocol/udp_sender.UDPSender.send" via: "websocket.app.state.udp_sender.send()" pattern: "udp_sender\\.send" - from: "lightsync/api/ws.py" to: "lightsync/main.show_store" via: "import lightsync.main and call show_store.load()" pattern: "_main\\.show_store\\.load" --- Server-side cue scheduler: extend the WebSocket tick handler to fire UDP animation commands at correct timestamps during live show playback. Purpose: This is the core execution engine for SHW-03. Audio position ticks from the browser drive the scheduler, which scans the loaded show's cue list and dispatches UDP commands to devices. Seek-safe reset prevents double-fire or missed cues. Output: Modified `lightsync/api/ws.py` with cue scheduling logic, show loading per-connection, seek-safe fired_ids rebuild, play/pause gating, and `preview_update` broadcast. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/05-live-show-execution/05-CONTEXT.md @.planning/phases/05-live-show-execution/05-RESEARCH.md From lightsync/models/show.py: ```python class CueModel(BaseModel): id: UUID timestamp: float duration: float = 4.0 mode: Literal["animation", "frame_sequence"] = "animation" animation: str | None = None params: dict[str, Any] = Field(default_factory=dict) class TrackModel(BaseModel): device_id: UUID cues: list[CueModel] = Field(default_factory=list) class ShowModel(BaseModel): id: UUID devices: list[DeviceConfig] = Field(default_factory=list) tracks: list[TrackModel] = Field(default_factory=list) ``` From lightsync/models/device.py: ```python class DeviceConfig(BaseModel): id: UUID name: str strip_type: str led_count: int ip: str = "127.0.0.1" port: int = 21324 ``` From lightsync/protocol/animation_cmd.py: ```python def encode_animation_cmd(animation: str, params: dict) -> bytes: # animation must be in ANIMATION_IDS: solid_color, chase, pulse, rainbow, strobe, color_wipe, fire ``` From lightsync/protocol/udp_sender.py: ```python def send(self, payload: bytes, ip: str, port: int) -> None: # synchronous, fire-and-forget ``` From lightsync/main.py: ```python # Module-level — NOT on app.state show_store: ShowStore | None = None # On app.state: app.state.udp_sender # UDPSender instance app.state.beats # dict: filepath -> {"tempo": float, "beats": [float]} ``` From lightsync/store/show_store.py: ```python async def load(self, show_id: str) -> ShowModel | None: ``` Task 1: Add per-connection show state and load handler to ws.py lightsync/api/ws.py - lightsync/api/ws.py - lightsync/models/show.py - lightsync/store/show_store.py - lightsync/main.py Extend the `websocket_endpoint` function in `lightsync/api/ws.py`: 1. Add imports at top of file: ```python from lightsync.protocol.animation_cmd import encode_animation_cmd ``` 2. Add per-connection state variables after `last_beat_reported`: ```python show: ShowModel | None = None # loaded show for cue scheduling fired_ids: set[str] = set() # cues already dispatched this playback pass is_playing: bool = False # gate cue scheduling on play state ``` Note: Do NOT import ShowModel at module level — use TYPE_CHECKING or import inside the function to avoid circular imports. Check if `from __future__ import annotations` is already present (it is, line 1) — that handles forward refs. Import `ShowModel` at top with other imports. 3. Extend the `load` message handler (the existing `elif msg_type == "load":` block). After the existing beat-loading logic, add show loading per D-04: ```python # Show cue list loading (Phase 5 — D-04) show_id = msg.get("show_id") if show_id: import lightsync.main as _main loaded = await _main.show_store.load(show_id) if loaded: show = loaded fired_ids = set() is_playing = False logger.info("[ws] loaded show %s with %d tracks", show_id, len(show.tracks)) else: logger.warning("[ws] show %s not found", show_id) ``` 4. Extend the `play` handler to set `is_playing = True`: ```python elif msg_type == "play": logger.info("[ws] play event at position=%.3f", float(msg.get("position", 0))) last_beat_reported = None is_playing = True # enable cue scheduling ``` 5. Extend the `pause` handler to set `is_playing = False` (per Pitfall 2 — do NOT touch fired_ids on pause): ```python elif msg_type == "pause": logger.info("[ws] pause event at position=%.3f", float(msg.get("position", 0))) is_playing = False # disable cue scheduling; do NOT modify fired_ids ``` 6. Extend the `seek` handler to rebuild `fired_ids` per D-03. Mark all cues before seek position as already-fired: ```python elif msg_type == "seek": position = float(msg.get("position", 0.0)) logger.info("[ws] seek to position=%.3f", position) last_beat_reported = None # Rebuild fired_ids: mark everything before seek position as fired (D-03) if show is not None: fired_ids = { str(cue.id) for track in show.tracks for cue in track.cues if cue.timestamp < position - 0.060 } ``` cd /home/claude/led2 && python -c " import ast, sys with open('lightsync/api/ws.py') as f: src = f.read() tree = ast.parse(src) # Check key patterns exist checks = [ 'show_id' in src, 'fired_ids' in src, 'is_playing' in src, 'encode_animation_cmd' in src, '_main.show_store.load' in src, 'LOOKAHEAD' not in src, # not yet — that's task 2 ] # Verify no syntax errors (ast.parse succeeded) print('Syntax: OK') for i, c in enumerate(checks[:5]): print(f'Check {i+1}: {\"PASS\" if c else \"FAIL\"}') sys.exit(0 if all(checks[:5]) else 1) " - lightsync/api/ws.py contains `show: ShowModel | None = None` (or equivalent type annotation) - lightsync/api/ws.py contains `fired_ids: set` initialization - lightsync/api/ws.py contains `is_playing: bool = False` - lightsync/api/ws.py contains `_main.show_store.load(show_id)` - lightsync/api/ws.py contains seek handler with `fired_ids = {` set comprehension rebuilding fired_ids - lightsync/api/ws.py play handler sets `is_playing = True` - lightsync/api/ws.py pause handler sets `is_playing = False` and does NOT modify fired_ids Per-connection show state is initialized, show loads on WebSocket `load` message, seek rebuilds fired_ids, play/pause gate is_playing flag. Task 2: Add cue scheduling loop and preview_update broadcast to tick handler lightsync/api/ws.py - lightsync/api/ws.py (after Task 1 modifications) - lightsync/protocol/animation_cmd.py - lightsync/protocol/udp_sender.py Add the cue scheduling loop inside the `tick` branch of `websocket_endpoint`, AFTER the existing beat-checking logic. Gate the entire scheduler on `is_playing and show is not None` per Pitfall 3 (prevents cue firing during seek drag / pause). Insert this code block inside the `if msg_type == "tick":` branch, after the existing beat notification logic: ```python # ── 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) ``` Key implementation details: - `LOOKAHEAD = 0.060` — 60ms window per D-02 - `udp.send()` is synchronous (fire-and-forget UDP) — safe to call without await - `encode_animation_cmd` imported at top of file (from Task 1) - `active_cue` tracks which cue is currently playing on each track for preview state, not just newly-fired cues - Preview updates broadcast to ALL connected browser clients via `manager.broadcast()` - Both "active" and "inactive/clear" states are broadcast so the preview can dim devices with no active block cd /home/claude/led2 && python -c " import ast with open('lightsync/api/ws.py') as f: src = f.read() checks = { 'LOOKAHEAD = 0.060': 'LOOKAHEAD = 0.060' in src, 'udp.send': 'udp.send(payload, device.ip, device.port)' in src, 'encode_animation_cmd call': 'encode_animation_cmd(cue.animation' in src, 'preview_update broadcast': 'preview_update' in src, 'is_playing gate': 'is_playing and show is not None' in src, 'manager.broadcast': 'await manager.broadcast(pu)' in src or 'await manager.broadcast(' in src, } ast.parse(src) # syntax check print('Syntax: OK') for name, ok in checks.items(): print(f'{name}: {\"PASS\" if ok else \"FAIL\"}') import sys; sys.exit(0 if all(checks.values()) else 1) " - lightsync/api/ws.py contains `LOOKAHEAD = 0.060` - lightsync/api/ws.py contains `udp.send(payload, device.ip, device.port)` - lightsync/api/ws.py contains `encode_animation_cmd(cue.animation, cue.params)` - lightsync/api/ws.py contains `"type": "preview_update"` message construction - lightsync/api/ws.py contains `is_playing and show is not None` gate condition - lightsync/api/ws.py contains `await manager.broadcast` call for preview updates - lightsync/api/ws.py parses without syntax errors - The scheduler loop iterates `show.tracks` and `track.cues` - Cues past position by more than 100ms are silently marked fired (no UDP send) Cue scheduler fires UDP animation commands at correct timestamps during playback. Preview update messages are broadcast on each tick. Cue timing is within 60ms look-ahead window. Past cues are silently skipped. 1. `python -c "import ast; ast.parse(open('lightsync/api/ws.py').read()); print('OK')"` — no syntax errors 2. `grep -c 'LOOKAHEAD' lightsync/api/ws.py` returns 1 3. `grep -c 'preview_update' lightsync/api/ws.py` returns at least 2 (message type string + dict construction) 4. `grep -c 'fired_ids' lightsync/api/ws.py` returns at least 4 (init, add, rebuild, check) 5. `grep 'is_playing' lightsync/api/ws.py` shows True/False assignments and gate condition - The WebSocket tick handler scans the loaded show's cue list on each tick - Cues within [position, position+60ms] fire UDP via encode_animation_cmd + UDPSender.send - Seek rebuilds fired_ids to mark all cues before seek_position as already-fired - Play sets is_playing=True, pause sets is_playing=False (no fired_ids modification on pause) - Show loads from show_store on WebSocket "load" message with show_id field - preview_update messages broadcast to all browser clients on each tick After completion, create `.planning/phases/05-live-show-execution/05-01-SUMMARY.md`