14 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 05-live-show-execution | 01 | execute | 1 |
|
true |
|
|
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.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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.mdFrom lightsync/models/show.py:
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:
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:
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:
def send(self, payload: bytes, ip: str, port: int) -> None: # synchronous, fire-and-forget
From lightsync/main.py:
# 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:
async def load(self, show_id: str) -> ShowModel | None:
- Add imports at top of file:
from lightsync.protocol.animation_cmd import encode_animation_cmd
- Add per-connection state variables after
last_beat_reported:
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.
- Extend the
loadmessage handler (the existingelif msg_type == "load":block). After the existing beat-loading logic, add show loading per D-04:
# 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)
- Extend the
playhandler to setis_playing = True:
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
- Extend the
pausehandler to setis_playing = False(per Pitfall 2 — do NOT touch fired_ids on pause):
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
- Extend the
seekhandler to rebuildfired_idsper D-03. Mark all cues before seek position as already-fired:
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
}
Insert this code block inside the if msg_type == "tick": branch, after the existing beat notification logic:
# ── 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-02udp.send()is synchronous (fire-and-forget UDP) — safe to call without awaitencode_animation_cmdimported at top of file (from Task 1)active_cuetracks 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)
"
<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 Nonegate condition - lightsync/api/ws.py contains
await manager.broadcastcall for preview updates - lightsync/api/ws.py parses without syntax errors
- The scheduler loop iterates
show.tracksandtrack.cues - Cues past position by more than 100ms are silently marked fired (no UDP send) </acceptance_criteria> 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.
- lightsync/api/ws.py contains
<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>