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

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
lightsync/api/ws.py
true
SHW-03
truths artifacts key_links
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
path provides contains
lightsync/api/ws.py Server-side cue scheduler integrated into tick handler LOOKAHEAD = 0.060
from to via pattern
lightsync/api/ws.py lightsync/protocol/animation_cmd.encode_animation_cmd import and call in tick branch encode_animation_cmd
from to via pattern
lightsync/api/ws.py lightsync/protocol/udp_sender.UDPSender.send websocket.app.state.udp_sender.send() udp_sender.send
from to via pattern
lightsync/api/ws.py lightsync/main.show_store import lightsync.main and call show_store.load() _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.

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

From 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:
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:
from lightsync.protocol.animation_cmd import encode_animation_cmd
  1. 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.

  1. Extend the load message handler (the existing elif 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)
  1. Extend the play handler to set is_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
  1. Extend the pause handler to set is_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
  1. Extend the seek handler to rebuild fired_ids per 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
        }
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:

# ── 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) " <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> 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

<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>
After completion, create `.planning/phases/05-live-show-execution/05-01-SUMMARY.md`