Files
led2/.planning/phases/05-live-show-execution/05-01-PLAN.md

369 lines
14 KiB
Markdown

---
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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- Key types and contracts the executor needs -->
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:
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add per-connection show state and load handler to ws.py</name>
<files>lightsync/api/ws.py</files>
<read_first>
- lightsync/api/ws.py
- lightsync/models/show.py
- lightsync/store/show_store.py
- lightsync/main.py
</read_first>
<action>
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
}
```
</action>
<verify>
<automated>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)
"</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>Per-connection show state is initialized, show loads on WebSocket `load` message, seek rebuilds fired_ids, play/pause gate is_playing flag.</done>
</task>
<task type="auto">
<name>Task 2: Add cue scheduling loop and preview_update broadcast to tick handler</name>
<files>lightsync/api/ws.py</files>
<read_first>
- lightsync/api/ws.py (after Task 1 modifications)
- lightsync/protocol/animation_cmd.py
- lightsync/protocol/udp_sender.py
</read_first>
<action>
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
</action>
<verify>
<automated>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)
"</automated>
</verify>
<acceptance_criteria>
- 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)
</acceptance_criteria>
<done>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.</done>
</task>
</tasks>
<verification>
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
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/05-live-show-execution/05-01-SUMMARY.md`
</output>