# 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 ``, `