diff --git a/.planning/phases/05-live-show-execution/05-RESEARCH.md b/.planning/phases/05-live-show-execution/05-RESEARCH.md new file mode 100644 index 0000000..6a1855c --- /dev/null +++ b/.planning/phases/05-live-show-execution/05-RESEARCH.md @@ -0,0 +1,581 @@ +# Phase 5: Live Show Execution - Research + +**Researched:** 2026-04-07 +**Domain:** Real-time cue scheduling, canvas preview, show persistence, keyboard UX +**Confidence:** HIGH — all findings verified against existing codebase; no external library discovery needed + +## Summary + +Phase 5 is almost entirely an integration and wiring task on top of already-built infrastructure. The server-side scheduler slots into the existing `ws.py` `tick` branch with ~30 lines of Python. The live preview panel is a new HTML `
` strip that receives `preview_update` WebSocket messages and paints colored rectangles via Canvas 2D or simple DOM elements. Show persistence already works at the API level — Phase 5 connects the show selector UI to `setCurrentShowId()` and wires up a `load` message to the WebSocket so the server can acquire the cue list per connection. Keyboard shortcuts are pure `document.addEventListener('keydown')` in `app.js`. + +The only non-trivial decision already made by the context session is the seek-safe cue reset: on a `seek` event the server must mark all cues with `timestamp < new_position` as already-fired, so they don't re-trigger after a backward seek. The 60ms look-ahead window eliminates missed cues at 10Hz tick rate — this is the timing contract the planner must implement faithfully. + +The frontend preview panel can be fully DOM-driven (one `
` per device, background-color CSS updated by JS) rather than requiring a ``. This avoids coordinate math overhead and matches the flat-rect terminal aesthetic described in the context. Canvas is also valid and may be used if the planner prefers uniform rendering with the timeline. + +**Primary recommendation:** Wire scheduler into the existing `tick` branch in `ws.py`, add `preview_update` broadcast alongside each UDP send, add a minimal DOM preview strip between `.timeline-container` and `.transport-bar`, and implement 5 keyboard event listeners in `app.js`. + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +- **D-01:** Server-side scheduling — the server fires UDP commands, not the browser. On each WebSocket `tick`, the server scans the current show's cue list and dispatches `UDPSender.send()` for any block due to fire. +- **D-02:** 60ms look-ahead window — if a block's `timestamp` is within 60ms ahead of the current tick position, fire it immediately. This prevents missed cues at 10Hz tick rate (max ~100ms tick gap). Blocks fire slightly early, never late. +- **D-03:** Seek-safe reset — on `seek` events, the server resets the "already fired" cue pointer so no cues are double-fired or skipped after a seek. +- **D-04:** The server needs the current show's cue list available per-connection. Wire this up by loading the active show from `show_store` in the WebSocket handler when a `load` message arrives (browser already sends this when audio loads). +- **D-05:** Animation color + type per device — show the active animation type and its color as a simple strip per device. No per-LED frame simulation; just which animation is active and in what color. Turns dark when no block is active on a device. +- **D-06:** Panel placement: below the timeline canvas, above the transport bar — always visible during playback. Layout: TIMELINE → LIVE PREVIEW → TRANSPORT BAR. +- **D-07:** Preview updates are driven by the same WebSocket `tick` loop — when the server fires a cue, it also broadcasts a `preview_update` message so the browser canvas reflects the new state. + +### Claude's Discretion + +- **Keyboard shortcuts (UI-03):** Standard DAW conventions: Space = play/pause, Left/Right arrow = seek ±5s, Ctrl+Z = undo, Ctrl+Shift+Z or Ctrl+Y = redo, Ctrl+S = explicit save. Planner decides exact key bindings but these are safe defaults. +- **Show save/load UX:** All block mutations already auto-save via CRUD API. Add Ctrl+S as an explicit "save now" shortcut that calls the existing save endpoint. Show loading: use existing show selector dropdown + a "load" button. No need to redesign. +- **Preview panel height:** Compact — 40-60px total. One row per device, enough for a labeled color strip. Collapsible if many devices. + +### Deferred Ideas (OUT OF SCOPE) + +- Per-LED frame simulation in the preview (see what the chase animation actually looks like moving) — v2 or Phase 6+ +- Preview panel as a standalone full-screen mode — out of scope +- Keyboard shortcut customization UI — out of scope for v1 + + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| SHW-03 | Live show execution — follow MPV position, dispatch animation commands to devices via UDP at correct timestamps | D-01 through D-04 in CONTEXT.md; `ws.py` tick branch, `UDPSender.send()`, `show_store.load()`, `encode_animation_cmd()` all in place | +| UI-03 | All primary operations accessible without mouse (keyboard shortcuts for play/pause/seek/undo) | `document.addEventListener('keydown')` in `app.js`; audio element and `timeline.history` already wired | +| UI-04 | Live preview panel — simulated LED strip visualization in browser canvas, synced to playback | New DOM strip in `index.html`, driven by `preview_update` WebSocket messages from server | + + +--- + +## Standard Stack + +### Core (no new libraries needed) + +All work happens in the existing stack. No new Python packages or npm modules are required. + +| Layer | Tech | Version | Purpose | +|-------|------|---------|---------| +| Backend scheduler | Python / FastAPI | 3.11 / existing | Extend `ws.py` tick handler | +| UDP dispatch | `UDPSender` + `encode_animation_cmd` | Phase 3 built | Send animation commands to devices | +| Show data | `ShowStore` + Pydantic `ShowModel` | Phase 1 built | Load cue list per connection | +| WebSocket protocol | FastAPI WebSocket | existing | Extend with `load` show_id field + `preview_update` broadcast | +| Frontend preview | Vanilla JS + CSS | existing | New `
` strip, no canvas required | +| Keyboard shortcuts | Browser `keydown` event | native | `document.addEventListener` in `app.js` | + +**Installation:** none required. + +--- + +## Architecture Patterns + +### Recommended Project Structure (delta from current) + +``` +lightsync/ +├── api/ +│ └── ws.py # EXTEND: cue scheduler in tick branch +├── frontend/ +│ ├── index.html # EXTEND: add #live-preview div between canvas and transport +│ ├── style.css # EXTEND: .live-preview, .device-strip styles +│ └── app.js # EXTEND: preview_update handler, keyboard shortcuts +``` + +No new files strictly required. The planner may choose to extract a `preview.js` module if the preview logic grows beyond ~50 lines. + +### Pattern 1: Server-side Cue Scheduler + +**What:** Inside the `tick` branch of `websocket_endpoint`, maintain per-connection state: the loaded `ShowModel` and a `set` of already-fired cue IDs. On each tick, iterate all tracks and cues; fire any cue whose timestamp is in `[position, position + 0.060]` and is not in `fired_ids`. + +**When to use:** Every tick message while playback is active. + +**Key state variables to add to `websocket_endpoint`:** + +```python +# Inside websocket_endpoint, alongside existing per-connection state: +show: ShowModel | None = None +fired_ids: set[str] = set() +is_playing: bool = False +``` + +**Scheduler logic inside the tick branch:** + +```python +elif msg_type == "tick": + position = float(msg.get("position", 0.0)) + is_playing = True # receiving ticks implies playing + + if show is not None: + LOOKAHEAD = 0.060 # 60ms window (D-02) + udp = websocket.app.state.udp_sender + 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 + for cue in track.cues: + cue_id = str(cue.id) + if cue_id in fired_ids: + continue + if cue.timestamp > position + LOOKAHEAD: + continue + if cue.timestamp < position - 0.1: + # Too far in the past — mark fired without sending + fired_ids.add(cue_id) + continue + # Fire this cue + fired_ids.add(cue_id) + if cue.animation: + try: + payload = encode_animation_cmd(cue.animation, cue.params) + udp.send(payload, device.ip, device.port) + except ValueError: + logger.warning("[ws] unknown animation %r", cue.animation) + # Broadcast preview_update to all browser clients (D-07) + color = cue.params.get("color", [0, 255, 180]) + await manager.broadcast({ + "type": "preview_update", + "device_id": str(track.device_id), + "animation": cue.animation, + "color": color, + }) +``` + +**Seek-safe reset (D-03):** On `seek`, rebuild `fired_ids` to include all cues with `timestamp < seek_position`. This prevents re-fire of past cues and ensures future cues fire correctly. + +```python +elif msg_type == "seek": + position = float(msg.get("position", 0.0)) + last_beat_reported = None + # Rebuild fired_ids: mark everything before seek position as fired + 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 + } +``` + +**Load show on `load` message (D-04):** The browser already sends `{"type": "load", "path": "..."}` when audio loads. Extend this handler to also accept `show_id`: + +```python +elif msg_type == "load": + # Existing beat loading... + show_id = msg.get("show_id") + if show_id: + show_store = getattr(websocket.app.state, "show_store", None) + # show_store lives at module level in main.py + import lightsync.main as _main + loaded = await _main.show_store.load(show_id) + if loaded: + show = loaded + fired_ids = set() + logger.info("[ws] loaded show %s with %d tracks", show_id, len(show.tracks)) +``` + +**Anti-pattern to avoid:** Do not load the show on every tick — load once on `load` message, cache per-connection. + +### Pattern 2: Live Preview Panel (DOM-based) + +**What:** A horizontal strip of `
` elements, one per device. Each device strip has a label and a colored fill rectangle. When a `preview_update` message arrives, update the background-color of the matching device strip. When audio pauses or ends, dim all strips. + +**HTML structure:** + +```html + + +``` + +**JS message handler:** + +```javascript +// In LightSyncClient.ws.onmessage: +if (msg.type === 'preview_update') { + updatePreviewStrip(msg.device_id, msg.animation, msg.color); +} + +function updatePreviewStrip(deviceId, animation, color) { + const strip = document.querySelector(`[data-device-id="${deviceId}"]`); + if (!strip) return; + const [r, g, b] = color; + strip.style.backgroundColor = `rgb(${r},${g},${b})`; + strip.querySelector('.strip-anim').textContent = animation.toUpperCase().replace('_', ' '); +} +``` + +**CSS pattern (terminal aesthetic):** + +```css +.live-preview { + border-top: 1px solid var(--border-dim); + border-bottom: 1px solid var(--border-dim); + background: var(--bg-panel-dark); + display: flex; + flex-direction: column; + gap: 1px; + padding: 2px 0; + max-height: 60px; + overflow-y: auto; +} +.device-strip { + display: flex; + align-items: center; + height: 18px; + padding: 0 4px; + background: var(--bg-panel); + gap: 8px; + font-size: 11px; + font-family: var(--font-mono); + letter-spacing: 0.05em; + transition: background-color 0.1s; +} +.strip-label { color: var(--text-dim); text-transform: uppercase; min-width: 80px; } +.strip-color { + width: 40px; height: 10px; + background: var(--text-dim); /* inactive state */ + flex-shrink: 0; +} +.strip-anim { color: var(--text-dim); font-size: 10px; } +``` + +**Inactive state:** When no block is active on a device, strip background stays `var(--bg-panel)` and `.strip-color` stays `var(--text-dim)` — consistent with the UI's dim inactive visual language. + +**Alternative:** The preview could use a `` element (one per device), but DOM approach is simpler, achieves the same visual result for this fidelity level, and doesn't require coordinate mapping. + +### Pattern 3: Keyboard Shortcuts + +**What:** `document.addEventListener('keydown', handler)` in `app.js`, added after `wireAudio()` and `initTimeline()` are called. + +**Bindings (Claude's discretion per CONTEXT.md):** + +| Key | Action | Implementation | +|-----|--------|----------------| +| Space | Play/Pause | `audio.paused ? audio.play() : audio.pause()` | +| ArrowLeft | Seek -5s | `audio.currentTime = Math.max(0, audio.currentTime - 5)` | +| ArrowRight | Seek +5s | `audio.currentTime = Math.min(audio.duration, audio.currentTime + 5)` | +| Ctrl+Z | Undo | `timeline.history.undo()` | +| Ctrl+Shift+Z | Redo | `timeline.history.redo()` | +| Ctrl+Y | Redo (alt) | `timeline.history.redo()` | +| Ctrl+S | Explicit save | `fetch('/api/shows/${timeline.showId}', ...)` or no-op if no show loaded | + +**Guard against input elements:** Skip shortcut if `document.activeElement` is an ``, `