docs(05): research phase domain
This commit is contained in:
581
.planning/phases/05-live-show-execution/05-RESEARCH.md
Normal file
581
.planning/phases/05-live-show-execution/05-RESEARCH.md
Normal 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
|
||||
Reference in New Issue
Block a user