docs(05): create phase plan — 3 plans across 3 waves
This commit is contained in:
368
.planning/phases/05-live-show-execution/05-01-PLAN.md
Normal file
368
.planning/phases/05-live-show-execution/05-01-PLAN.md
Normal file
@@ -0,0 +1,368 @@
|
||||
---
|
||||
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>
|
||||
Reference in New Issue
Block a user