Files
led2/.planning/phases/05-live-show-execution/05-RESEARCH.md
2026-04-07 08:59:07 +00:00

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 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

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:

  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:

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

  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.pyload(show_id), save(show), list_ids() confirmed
  • lightsync/models/show.pyShowModel, TrackModel, CueModel fields verified (id, timestamp, duration, animation, params)
  • lightsync/models/device.pyDeviceConfig fields verified (id, name, strip_type, led_count, ip, port)
  • lightsync/protocol/udp_sender.pyUDPSender.send(payload, ip, port) confirmed fire-and-forget
  • lightsync/protocol/animation_cmd.pyencode_animation_cmd(animation, params), ANIMATION_IDS confirmed
  • lightsync/main.pyapp.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.jsthis.tracks, this.showId, history confirmed
  • lightsync/frontend/timeline/commands.jssetCurrentShowId(), 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