docs(05): research phase domain

This commit is contained in:
Claude
2026-04-07 08:59:07 +00:00
parent 7b37f014d0
commit 2ac23c3297

View File

@@ -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 `<div>` 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 `<div>` per device, background-color CSS updated by JS) rather than requiring a `<canvas>`. 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>
## 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
</user_constraints>
<phase_requirements>
## 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 |
</phase_requirements>
---
## 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 `<div>` 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 `<div>` 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
<!-- Between <canvas id="timeline-canvas"> and <div id="block-inspector"> -->
<div id="live-preview" class="live-preview" style="display:none;">
<!-- Populated dynamically by JS when devices load -->
</div>
```
**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 `<canvas>` 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 `<input>`, `<select>`, or `<textarea>`.
```javascript
function wireKeyboard(audio, timeline) {
document.addEventListener('keydown', (e) => {
const tag = document.activeElement?.tagName.toLowerCase();
if (['input', 'select', 'textarea'].includes(tag)) return;
if (e.code === 'Space') {
e.preventDefault();
audio.paused ? audio.play() : audio.pause();
} else if (e.code === 'ArrowLeft') {
e.preventDefault();
audio.currentTime = Math.max(0, audio.currentTime - 5);
client.send({ type: 'seek', position: audio.currentTime });
} else if (e.code === 'ArrowRight') {
e.preventDefault();
audio.currentTime = Math.min(audio.duration || 0, audio.currentTime + 5);
client.send({ type: 'seek', position: audio.currentTime });
} else if (e.ctrlKey && !e.shiftKey && e.code === 'KeyZ') {
e.preventDefault();
timeline?.history.undo();
} else if ((e.ctrlKey && e.shiftKey && e.code === 'KeyZ') || (e.ctrlKey && e.code === 'KeyY')) {
e.preventDefault();
timeline?.history.redo();
} else if (e.ctrlKey && e.code === 'KeyS') {
e.preventDefault();
if (timeline?.showId) {
fetch(`/api/shows/${timeline.showId}`)
.then(r => r.json())
.then(show => fetch(`/api/shows`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(show)
}))
.catch(e => console.warn('[save]', e));
}
}
});
}
```
Note: Ctrl+S save is a no-op if no show is loaded. The existing auto-save on every block mutation is the primary persistence mechanism — Ctrl+S is a manual trigger for peace of mind.
### Pattern 4: Show Persistence Round-Trip
**What:** Loading a show into the timeline editor (which re-populates `timeline.tracks` from show data) and sending `show_id` in the WebSocket `load` message so the server acquires the cue list.
**Current state:** `timeline.showId` is set when a show is explicitly loaded. The `setCurrentShowId()` function in `commands.js` ensures new block mutations auto-save to the show. The browser sends `{"type": "load", "path": "..."}` but does NOT currently send `show_id`.
**What Phase 5 must add:**
1. A show selector UI component (dropdown + "Load Show" button) in the sidebar or transport bar — calls `GET /api/shows` to list, then `GET /api/shows/{id}` to load full show including tracks.
2. When a show loads: call `timeline.loadTracksFromShow(show)` to populate `this.tracks` from `show.tracks`, call `setCurrentShowId(show.id)`, and send `{"type": "load", "show_id": show.id, "path": show.audio.path}` to the WebSocket.
3. On the server `load` handler: when `show_id` is present, load the show and set per-connection `show` state.
**`timeline.loadTracksFromShow(show)` — new method needed:**
```javascript
loadTracksFromShow(show) {
// Merge show tracks into timeline.tracks which already has device metadata
// Each track in show has {device_id, cues[]}
// Each track in timeline has {device_id, device_name, strip_type, cues[]}
for (const showTrack of show.tracks) {
const existing = this.tracks.find(t => t.device_id === showTrack.device_id);
if (existing) {
existing.cues = showTrack.cues.map(cue => ({...cue}));
} else {
// Device not registered locally — skip or warn
console.warn('[timeline] track for unknown device', showTrack.device_id);
}
}
}
```
**Alternative simpler approach:** Load devices from `show.devices` + cues in one call. This avoids the merge problem but requires `timeline.loadTracks()` to be extended to accept device config from the show snapshot rather than re-fetching from `/api/devices`.
### Pattern 5: index.html Layout Extension
**What:** The layout must accommodate the live preview panel. Current `main-area` contains only the toolbar, canvas, and block inspector. The preview panel must be inserted between canvas and inspector (or between inspector and transport).
**Existing layout order:**
```
.app-shell grid:
header | header
sidebar | main-area
transport | transport
.main-area (flex column, inferred):
#timeline-toolbar
#timeline-canvas
#block-inspector (hidden unless block selected)
```
**Required addition:**
```html
<main class="main-area">
<div id="timeline-toolbar" class="timeline-toolbar">...</div>
<canvas id="timeline-canvas"></canvas>
<div id="live-preview" class="live-preview"></div> <!-- NEW -->
<div id="block-inspector" class="block-inspector" style="display:none;"></div>
</main>
```
The transport bar is already a separate `<footer>` in the grid — so the preview sits between the canvas area and the transport grid row, which matches D-06.
---
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| UDP socket pooling | Custom multi-socket manager | `UDPSender` (already built) | Single shared DatagramTransport is correct for UDP multi-device sends |
| Animation encoding | Custom binary format | `encode_animation_cmd()` (Phase 3) | Protocol already defined, tested, and firmware-compatible |
| Show serialization | Custom JSON schema | `ShowModel.model_dump_json()` + `model_validate_json()` | Pydantic handles UUID serialization, datetime, nested models |
| Beat/cue timing | Custom scheduler with asyncio.sleep | Position-driven scan in tick handler | `asyncio.sleep` timing is unreliable under load; tick-driven approach eliminates timer drift entirely |
| Multi-client WebSocket broadcast | Custom connection tracking | `manager.broadcast()` (already in `ws.py`) | Dead connection cleanup already handled |
**Key insight:** The entire cue scheduler is ~30 lines of Python inside an existing handler. There is no scheduling infrastructure to build — the 10Hz tick IS the scheduler clock.
---
## Common Pitfalls
### Pitfall 1: Double-fire on backward seek
**What goes wrong:** User seeks backward to t=10s. Cues between t=10 and previous position have `cue_id` NOT in `fired_ids` (because `fired_ids` was built forward). On the next tick they fire again.
**Why it happens:** `fired_ids` only tracks forward progress. Backward seek invalidates the set.
**How to avoid:** On `seek`, rebuild `fired_ids` = all cue IDs with `timestamp < new_position - 0.060`. This is the D-03 requirement.
**Warning signs:** Devices receive two UDP packets for the same block in quick succession.
### Pitfall 2: Cues not firing when paused then resumed
**What goes wrong:** User pauses at t=15.0s. A cue at t=15.3s was not in the 60ms window at the pause moment. User resumes — first tick arrives at t=15.1s (audio may buffer). The scheduler skips the t=15.3s cue because `fired_ids` already marks it (if marked at pause time).
**Why it happens:** Incorrectly marking cues as fired on pause.
**How to avoid:** On `pause`, do NOT add anything to `fired_ids`. Only mark cues as fired when they are actually dispatched.
### Pitfall 3: Cues firing during pause (tick leakage)
**What goes wrong:** The browser sends ticks on `timeupdate` event (line 126 in `app.js`) which fires even when audio isn't playing (e.g., during seek drag). The scheduler fires cues at the seeked-to position even before the user releases the seek bar.
**Why it happens:** `timeupdate` fires when `currentTime` changes regardless of play state. The 10Hz interval loop checks `!audio.paused` but `timeupdate` does not.
**How to avoid:** In the server `tick` handler, gate cue scheduling on an `is_playing` flag. Set to `true` on `play` message, `false` on `pause` message. Only run scheduler when `is_playing`.
### Pitfall 4: Show not loaded on server when show ID sent
**What goes wrong:** Browser sends `{"type": "load", "show_id": "...", "path": "..."}` but `show_store` is accessed as `websocket.app.state.show_store` — which doesn't exist. The show_store lives at module level in `main.py`, not on `app.state`.
**Why it happens:** `show_store` is `lightsync.main.show_store` (module-level), not `app.state.show_store`.
**How to avoid:** In `ws.py`, import `import lightsync.main as _main` and use `await _main.show_store.load(show_id)`.
### Pitfall 5: Preview strips not built until devices are loaded
**What goes wrong:** `index.html` renders the `#live-preview` div empty. If JS builds device strips at init time but devices haven't loaded yet, the preview is empty.
**Why it happens:** `loadDevices()` is async; preview strip population must happen after device list resolves.
**How to avoid:** Build preview strips inside the existing `loadDevices()` callback (or in `timeline.loadTracks()` after devices are fetched).
### Pitfall 6: Space bar triggers seek bar / button click
**What goes wrong:** Space key fires both the keyboard shortcut AND a button click (if a button has focus), causing play/pause to toggle twice.
**Why it happens:** Default browser behavior for Space on focused buttons is a click event.
**How to avoid:** Call `e.preventDefault()` in the Space keydown handler. Also check `document.activeElement` is not a `<button>` before handling Space — or always `preventDefault()` for space in this handler.
### Pitfall 7: Ctrl+S triggers browser's native save dialog
**What goes wrong:** Browser intercepts Ctrl+S and opens "Save Page As" before the JS handler runs.
**Why it happens:** Ctrl+S is a native browser shortcut. JavaScript handlers must call `e.preventDefault()` before it reaches the browser.
**How to avoid:** Always call `e.preventDefault()` first in the Ctrl+S branch.
---
## Code Examples
Verified patterns from the existing codebase:
### Access UDPSender from WebSocket handler
```python
# Source: lightsync/main.py lifespan + lightsync/api/ws.py pattern
udp = websocket.app.state.udp_sender
udp.send(payload, device.ip, device.port)
```
### Access show_store from WebSocket handler
```python
# Source: lightsync/api/shows.py — module-level ref pattern
import lightsync.main as _main
show = await _main.show_store.load(show_id)
```
### Broadcast message to all clients
```python
# Source: lightsync/api/ws.py ConnectionManager.broadcast()
await manager.broadcast({
"type": "preview_update",
"device_id": str(track.device_id),
"animation": cue.animation,
"color": cue.params.get("color", [0, 255, 180]),
})
```
### Encode animation command
```python
# Source: lightsync/protocol/animation_cmd.py
from lightsync.protocol.animation_cmd import encode_animation_cmd
payload = encode_animation_cmd(cue.animation, cue.params)
# cue.params keys: color, bg_color, speed, reverse, density/size/cooling
```
### Show load/save pattern
```python
# Source: lightsync/store/show_store.py
show: ShowModel | None = await show_store.load(show_id) # returns None if not found
await show_store.save(show) # writes {show_id}.json
```
### CSS variables in use
```css
/* Source: lightsync/frontend/style.css */
--bg-primary: #0a0a0a;
--bg-panel: #0f0f0f;
--bg-panel-dark: #080808;
--border-dim: #1a4a4a;
--accent: #00ffff; /* cyan — primary highlight color */
--text-primary: #cccccc;
--text-dim: #555555;
--font-mono: 'JetBrains Mono', ...;
```
### CommandHistory undo/redo (existing)
```javascript
// Source: lightsync/frontend/timeline/history.js (via app.js initTimeline)
timeline.history.undo()
timeline.history.redo()
// history is a CommandHistory(50) — 50 step limit from Phase 4
```
### Sending seek + load messages from browser
```javascript
// Source: lightsync/frontend/app.js existing patterns
client.send({ type: 'seek', position: audio.currentTime });
client.send({ type: 'load', path: '/app/shows/filename.mp3' });
// Phase 5 extends load with: show_id: 'uuid-string'
```
---
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Server-side MPV audio engine | Browser HTML5 `<audio>` element | Phase 2 redesign | Server never has authoritative play state — browser drives all timing |
| `observe_property` for position | 10Hz tick from browser | Phase 2 | Tick IS the scheduler clock; no server-side timer needed |
| `app.state.show_store` | Module-level `lightsync.main.show_store` | Phase 1 | Must import via `lightsync.main` not `websocket.app.state` |
---
## Open Questions
1. **Show selector UI placement**
- What we know: Transport bar is already full (play, seek, time, upload, file select, load). The sidebar has two panels (DEVICES, ANIMATIONS).
- What's unclear: Whether to add show management to the sidebar or as a new toolbar section in main-area.
- Recommendation: Add a "SHOWS" panel to the sidebar (below ANIMATIONS), or extend the transport bar's select/load pattern — the existing `<select id="audio-select"> + <button id="btn-load">` pattern can be replicated for shows with minimal new layout.
2. **`show.devices` vs. live device registry for UDP dispatch**
- What we know: `ShowModel.devices` is a snapshot of `DeviceConfig[]` taken when the show was created. The live registry (`registry.devices`) may differ (device IP could have changed).
- What's unclear: Which source to use for `(ip, port)` when dispatching UDP.
- Recommendation: Use `show.devices` for the snapshot (shows are self-contained per SHW-02). Planner can choose live registry as an alternative — both are valid.
3. **`preview_update` on cue end (block expires)**
- What we know: The scheduler fires cues at `timestamp`. It does not currently track cue end (`timestamp + duration`).
- What's unclear: Whether to dim the preview strip when a block ends (D-05 says "turns dark when no block is active").
- Recommendation: In the tick handler, also check for cues whose `timestamp + duration < position` and broadcast `{"type": "preview_clear", "device_id": "..."}`. Or: the browser can derive this locally since it has `timeline.tracks` with full block data including duration.
---
## Environment Availability
Step 2.6: SKIPPED — Phase 5 is purely code changes within the existing Docker container. All dependencies (Python 3.11, FastAPI, UDPSender, browser canvas) are confirmed present from Phases 1-4.
---
## Validation Architecture
`nyquist_validation` is explicitly `false` in `.planning/config.json` — this section is skipped per instructions.
---
## Sources
### Primary (HIGH confidence)
- `lightsync/api/ws.py` — Full WebSocket handler code verified; per-connection state pattern, ConnectionManager.broadcast(), existing message types
- `lightsync/api/shows.py` — GET/POST /api/shows endpoints confirmed; show_store access via `lightsync.main` module ref
- `lightsync/store/show_store.py``load(show_id)`, `save(show)`, `list_ids()` confirmed
- `lightsync/models/show.py``ShowModel`, `TrackModel`, `CueModel` fields verified (id, timestamp, duration, animation, params)
- `lightsync/models/device.py``DeviceConfig` fields verified (id, name, strip_type, led_count, ip, port)
- `lightsync/protocol/udp_sender.py``UDPSender.send(payload, ip, port)` confirmed fire-and-forget
- `lightsync/protocol/animation_cmd.py``encode_animation_cmd(animation, params)`, `ANIMATION_IDS` confirmed
- `lightsync/main.py``app.state.udp_sender` confirmed; `show_store` is module-level, not `app.state`
- `lightsync/frontend/app.js` — 10Hz tick loop, seek/play/pause event wiring, LightSyncClient.send() confirmed
- `lightsync/frontend/index.html` — Current DOM structure confirmed; live preview insertion point identified
- `lightsync/frontend/style.css` — CSS variables confirmed; panel/border/text patterns verified
- `lightsync/frontend/timeline/timeline.js``this.tracks`, `this.showId`, `history` confirmed
- `lightsync/frontend/timeline/commands.js``setCurrentShowId()`, `currentShowId` module var confirmed
- `.planning/phases/05-live-show-execution/05-CONTEXT.md` — All locked decisions D-01 through D-07 incorporated
### Secondary (MEDIUM confidence)
- None — all findings from direct codebase inspection
### Tertiary (LOW confidence)
- None
---
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — no new libraries; all existing code verified
- Architecture: HIGH — scheduler pattern derived directly from existing `ws.py` structure
- Pitfalls: HIGH — derived from reading actual code paths (tick leakage, seek, `show_store` location)
- Preview panel: HIGH — DOM approach confirmed against existing CSS variable system
**Research date:** 2026-04-07
**Valid until:** Until code changes in ws.py, app.js, or models/show.py — this is a stable codebase at Phase 5 entry