diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index f2f4607..e37c2b1 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -109,9 +109,9 @@ Plans: **Plans**: 3 plans Plans: -- [ ] 05-01: Cue scheduler — position-driven scheduling loop, pre-fire offset, seek-safe cue pointer reset -- [ ] 05-02: Live preview panel — canvas LED strip visualization synced to playback via WebSocket position ticks -- [ ] 05-03: Show persistence + keyboard shortcuts — complete save/load round-trip, keyboard bindings for all primary operations +- [ ] 05-01-PLAN.md — Cue scheduler: server-side position-driven scheduling, 60ms look-ahead, seek-safe fired_ids reset +- [ ] 05-02-PLAN.md — Live preview panel + show selector: DOM device strips, preview_update handler, show load into timeline +- [ ] 05-03-PLAN.md — Keyboard shortcuts: Space, arrows, Ctrl+Z/Y/S for mouse-free operation **UI hint**: yes ### Phase 6: AI Sync diff --git a/.planning/phases/05-live-show-execution/05-01-PLAN.md b/.planning/phases/05-live-show-execution/05-01-PLAN.md new file mode 100644 index 0000000..6f2499c --- /dev/null +++ b/.planning/phases/05-live-show-execution/05-01-PLAN.md @@ -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" +--- + + +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 + + + +After completion, create `.planning/phases/05-live-show-execution/05-01-SUMMARY.md` + diff --git a/.planning/phases/05-live-show-execution/05-02-PLAN.md b/.planning/phases/05-live-show-execution/05-02-PLAN.md new file mode 100644 index 0000000..4ba3742 --- /dev/null +++ b/.planning/phases/05-live-show-execution/05-02-PLAN.md @@ -0,0 +1,499 @@ +--- +phase: 05-live-show-execution +plan: 02 +type: execute +wave: 2 +depends_on: + - 05-01 +files_modified: + - lightsync/frontend/index.html + - lightsync/frontend/style.css + - lightsync/frontend/app.js +autonomous: true +requirements: + - UI-04 + - SHW-03 + +must_haves: + truths: + - "Live preview panel shows active animation color and type per device, updated in sync with playback" + - "Devices with no active cue show a dim inactive indicator" + - "A show can be selected from a dropdown and loaded into the timeline + server" + - "Preview panel is visible between timeline canvas and block inspector when a show is loaded" + artifacts: + - path: "lightsync/frontend/index.html" + provides: "Live preview div and show selector UI elements" + contains: "id=\"live-preview\"" + - path: "lightsync/frontend/style.css" + provides: "Terminal-aesthetic styles for preview panel and device strips" + contains: ".live-preview" + - path: "lightsync/frontend/app.js" + provides: "preview_update WebSocket handler, show selector logic, preview strip builder" + contains: "preview_update" + key_links: + - from: "lightsync/frontend/app.js" + to: "lightsync/api/ws.py" + via: "WebSocket onmessage handler for preview_update messages" + pattern: "msg\\.type === .preview_update" + - from: "lightsync/frontend/app.js" + to: "/api/shows" + via: "fetch to list and load shows" + pattern: "fetch.*api/shows" + - from: "lightsync/frontend/app.js" + to: "lightsync/frontend/timeline/commands.js" + via: "setCurrentShowId() call on show load" + pattern: "setCurrentShowId" +--- + + +Live preview panel and show selector: add the browser-side UI for live show execution feedback and show management. + +Purpose: Implements UI-04 (live preview panel) and the frontend half of SHW-03 (show load into timeline + server). The preview panel shows which animation is active on each device during playback. The show selector lets users load saved shows into the timeline editor and connect them to the server-side cue scheduler. + +Output: Modified `index.html` (new DOM elements), `style.css` (preview panel styles), and `app.js` (preview handler, show selector, preview strip builder). + + + +@$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 +@.planning/phases/05-live-show-execution/05-UI-SPEC.md +@.planning/phases/05-live-show-execution/05-01-SUMMARY.md + + + +```css +--bg-primary: #0a0a0a; +--bg-panel: #0f0f0f; +--bg-panel-dark: #080808; +--border-dim: #1a4a4a; +--accent: #00ffff; +--text-primary: #cccccc; +--text-dim: #555555; +--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; +``` + + +```javascript +// Active cue: +{ type: "preview_update", device_id: "uuid", animation: "chase", color: [0, 255, 180] } +// Inactive (no cue): +{ type: "preview_update", device_id: "uuid", animation: null, color: null } +``` + + +``` +GET /api/shows -> [{ id, name, created_at }] +GET /api/shows/{id} -> ShowModel (full, including tracks + devices) +``` + + +```javascript +// client.send(msg) — WebSocket send +// timeline — TimelineCanvas instance (global in app.js) +// timeline.tracks — [{device_id, device_name, strip_type, cues: []}] +// timeline.showId — current show UUID or null +// timeline.loadTracks(devices) — called with device array from /api/devices +``` + + +```javascript +import { setCurrentShowId } from './timeline/commands.js'; +// Already imported in app.js: DeleteBlockCommand +// Need to also import: setCurrentShowId +``` + + +```html +... +LOAD +``` + + + + + + + Task 1: Add preview panel HTML and show selector to index.html, add CSS styles + lightsync/frontend/index.html, lightsync/frontend/style.css + + - lightsync/frontend/index.html + - lightsync/frontend/style.css + - .planning/phases/05-live-show-execution/05-UI-SPEC.md + + +**index.html changes:** + +1. Add the live preview panel div between `` and `` inside ``: +```html + + + +``` +The `#live-preview` div starts hidden (`display:none` inline). JS sets `display:flex` when a show loads. +Content (device strips) is populated dynamically by JS — the div starts empty. + +2. Add show selector controls to the transport bar `