---
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