30 KiB
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 dispatchesUDPSender.send()for any block due to fire. - D-02: 60ms look-ahead window — if a block's
timestampis 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
seekevents, 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_storein the WebSocket handler when aloadmessage 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
tickloop — when the server fires a cue, it also broadcasts apreview_updatemessage 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:
# 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:
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.
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:
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:
<!-- 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:
// 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):
.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>.
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:
- A show selector UI component (dropdown + "Load Show" button) in the sidebar or transport bar — calls
GET /api/showsto list, thenGET /api/shows/{id}to load full show including tracks. - When a show loads: call
timeline.loadTracksFromShow(show)to populatethis.tracksfromshow.tracks, callsetCurrentShowId(show.id), and send{"type": "load", "show_id": show.id, "path": show.audio.path}to the WebSocket. - On the server
loadhandler: whenshow_idis present, load the show and set per-connectionshowstate.
timeline.loadTracksFromShow(show) — new method needed:
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:
<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
# 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
# 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
# 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
# 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
# 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
/* 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)
// 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
// 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
-
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.
-
show.devicesvs. live device registry for UDP dispatch- What we know:
ShowModel.devicesis a snapshot ofDeviceConfig[]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.devicesfor the snapshot (shows are self-contained per SHW-02). Planner can choose live registry as an alternative — both are valid.
- What we know:
-
preview_updateon 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 < positionand broadcast{"type": "preview_clear", "device_id": "..."}. Or: the browser can derive this locally since it hastimeline.trackswith full block data including duration.
- What we know: The scheduler fires cues at
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 typeslightsync/api/shows.py— GET/POST /api/shows endpoints confirmed; show_store access vialightsync.mainmodule reflightsync/store/show_store.py—load(show_id),save(show),list_ids()confirmedlightsync/models/show.py—ShowModel,TrackModel,CueModelfields verified (id, timestamp, duration, animation, params)lightsync/models/device.py—DeviceConfigfields verified (id, name, strip_type, led_count, ip, port)lightsync/protocol/udp_sender.py—UDPSender.send(payload, ip, port)confirmed fire-and-forgetlightsync/protocol/animation_cmd.py—encode_animation_cmd(animation, params),ANIMATION_IDSconfirmedlightsync/main.py—app.state.udp_senderconfirmed;show_storeis module-level, notapp.statelightsync/frontend/app.js— 10Hz tick loop, seek/play/pause event wiring, LightSyncClient.send() confirmedlightsync/frontend/index.html— Current DOM structure confirmed; live preview insertion point identifiedlightsync/frontend/style.css— CSS variables confirmed; panel/border/text patterns verifiedlightsync/frontend/timeline/timeline.js—this.tracks,this.showId,historyconfirmedlightsync/frontend/timeline/commands.js—setCurrentShowId(),currentShowIdmodule 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.pystructure - Pitfalls: HIGH — derived from reading actual code paths (tick leakage, seek,
show_storelocation) - 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