Files
led2/.planning/phases/02-audio-engine/02-02-PLAN.md

12 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
02-audio-engine 02 execute 2
02-01
lightsync/api/ws.py
lightsync/api/audio.py
lightsync/main.py
true
AUD-02
AUD-04
truths artifacts key_links
WebSocket broadcasts position ticks at 10Hz to all connected clients
Browser receives {type: 'tick', position: N, paused: bool, duration: N} messages
Transport commands (play/pause/seek/load) sent from browser are dispatched to MPVEngine
Audio file can be loaded via REST POST /api/audio/load
path provides contains
lightsync/api/ws.py WebSocket command dispatch + broadcast_loop integration broadcast_loop
path provides exports
lightsync/api/audio.py REST endpoints for audio load
router
path provides contains
lightsync/main.py broadcast_loop task in lifespan, audio router registered broadcast_loop
from to via pattern
lightsync/api/ws.py lightsync/audio/engine.py engine.play/pause/seek/load dispatch engine.(play|pause|seek|load)
from to via pattern
lightsync/main.py lightsync/api/ws.py broadcast_loop asyncio task in lifespan asyncio.create_task.*broadcast_loop
Wire the WebSocket hub to broadcast MPVEngine position at 10Hz and dispatch transport commands from the browser. Add REST endpoint for audio file loading.

Purpose: The browser needs real-time position data to drive the timeline cursor and transport UI. Commands must flow back to control playback. Output: Working bidirectional WebSocket (position ticks out, commands in), REST audio load endpoint.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/02-audio-engine/02-RESEARCH.md @.planning/phases/02-audio-engine/02-01-SUMMARY.md

From lightsync/audio/engine.py (created in 02-01):

class MPVEngine:
    def __init__(self, ao: str = "null"): ...
    def start(self) -> None: ...
    def get_state(self) -> dict[str, Any]:
        # Returns: {"position": float, "paused": bool, "duration": float|None, "loaded": bool, "file": str|None}
    def load(self, path: str) -> None: ...
    def play(self) -> None: ...
    def pause(self) -> None: ...
    def seek(self, seconds: float) -> None: ...
    def stop(self) -> None: ...

From lightsync/main.py (after 02-01):

engine: MPVEngine | None = None  # module-level, set in lifespan
# app.state.engine = engine  # also available on app state

From lightsync/api/ws.py (existing):

class ConnectionManager:
    active_connections: list[WebSocket]
    async def connect(self, ws: WebSocket) -> None
    def disconnect(self, ws: WebSocket) -> None
    async def broadcast(self, message: dict) -> None

manager = ConnectionManager()

@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    # Currently echoes back with type=ack
Task 1: Wire WebSocket command dispatch and 10Hz broadcast loop lightsync/api/ws.py, lightsync/main.py lightsync/api/ws.py, lightsync/main.py, lightsync/audio/engine.py 1. Update `lightsync/api/ws.py` — replace the echo stub with command dispatch:

Keep the existing ConnectionManager class and manager instance UNCHANGED.

Add a broadcast_loop async function at module level:

import asyncio

async def broadcast_loop(get_engine) -> None:
    """Broadcast playback state to all WebSocket clients at 10Hz (AUD-04).

    Args:
        get_engine: callable returning the MPVEngine instance (avoids import cycle)
    """
    while True:
        await asyncio.sleep(0.1)
        engine = get_engine()
        if engine and manager.active_connections:
            state = engine.get_state()
            await manager.broadcast({"type": "tick", **state})

Replace the websocket_endpoint receive loop body — dispatch on msg["type"]:

@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    await manager.connect(websocket)
    try:
        while True:
            data = await websocket.receive_text()
            msg = json.loads(data)
            msg_type = msg.get("type")

            # Import engine reference from main (module-level var)
            from lightsync.main import engine

            if engine is None:
                await websocket.send_text(json.dumps({"type": "error", "message": "engine not ready"}))
                continue

            if msg_type == "play":
                engine.play()
                await websocket.send_text(json.dumps({"type": "ack", "command": "play"}))
            elif msg_type == "pause":
                engine.pause()
                await websocket.send_text(json.dumps({"type": "ack", "command": "pause"}))
            elif msg_type == "seek":
                position = float(msg.get("position", 0))
                engine.seek(position)
                await websocket.send_text(json.dumps({"type": "ack", "command": "seek", "position": position}))
            elif msg_type == "load":
                path = msg.get("path", "")
                if path:
                    engine.load(path)
                    await websocket.send_text(json.dumps({"type": "ack", "command": "load", "path": path}))
                else:
                    await websocket.send_text(json.dumps({"type": "error", "message": "missing path"}))
            else:
                await websocket.send_text(json.dumps({"type": "ack", "echo": msg}))
    except WebSocketDisconnect:
        manager.disconnect(websocket)
  1. Update lightsync/main.py — start broadcast_loop as asyncio task in lifespan:

Add import asyncio at top. Add import: from lightsync.api.ws import manager, broadcast_loop.

In lifespan, AFTER engine.start(), add:

    task = asyncio.create_task(broadcast_loop(lambda: engine))

In lifespan shutdown (before yield cleanup), add:

    yield
    task.cancel()
    try:
        await task
    except asyncio.CancelledError:
        pass
    engine.stop()
    await registry.save()

The get_engine callable pattern avoids circular imports (ws.py does not import engine at module level). cd /home/claude/led2 && grep -q "broadcast_loop" lightsync/api/ws.py && grep -q "broadcast_loop" lightsync/main.py && grep -q "asyncio.create_task" lightsync/main.py && grep -q '"type": "tick"' lightsync/api/ws.py && grep -q 'msg_type == "play"' lightsync/api/ws.py && grep -q 'msg_type == "pause"' lightsync/api/ws.py && grep -q 'msg_type == "seek"' lightsync/api/ws.py && echo "PASS" <acceptance_criteria>

  • lightsync/api/ws.py contains async def broadcast_loop(get_engine) function
  • broadcast_loop sends {"type": "tick", "position": ..., "paused": ..., "duration": ..., "loaded": ..., "file": ...} at 10Hz
  • broadcast_loop uses await asyncio.sleep(0.1) — NOT a threading timer
  • broadcast_loop only broadcasts when manager.active_connections is non-empty
  • WebSocket endpoint dispatches play, pause, seek, load commands to engine
  • Each command sends back {"type": "ack", "command": "..."} confirmation
  • seek command reads msg["position"] as float
  • load command reads msg["path"] as string
  • Unknown message types still get echo response (backward compat)
  • lightsync/main.py starts broadcast_loop via asyncio.create_task in lifespan
  • lightsync/main.py cancels the task in lifespan shutdown
  • ConnectionManager class is NOT modified </acceptance_criteria> WebSocket dispatches transport commands to MPVEngine; broadcast_loop sends position ticks at 10Hz
Task 2: Add REST audio load endpoint with file validation lightsync/api/audio.py, lightsync/main.py lightsync/main.py, lightsync/api/shows.py 1. Create `lightsync/api/audio.py` with REST endpoints:
"""Audio loading and status endpoints."""
import asyncio
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel

router = APIRouter()

SUPPORTED_EXTENSIONS = {".mp3", ".wav", ".flac", ".ogg"}


class LoadRequest(BaseModel):
    path: str


class AudioState(BaseModel):
    position: float
    paused: bool
    duration: float | None
    loaded: bool
    file: str | None


@router.post("/load")
async def load_audio(req: LoadRequest, request: Request):
    """Load an audio file by server path (AUD-01). YouTube URL deferred to Phase 6 (AUD-02 stub)."""
    engine = request.app.state.engine
    if engine is None:
        raise HTTPException(status_code=503, detail="Audio engine not ready")

    p = Path(req.path)
    if not p.exists():
        raise HTTPException(status_code=404, detail=f"File not found: {req.path}")
    if p.suffix.lower() not in SUPPORTED_EXTENSIONS:
        raise HTTPException(status_code=400, detail=f"Unsupported format: {p.suffix}. Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}")

    await asyncio.to_thread(engine.load, req.path)
    return {"status": "loaded", "path": req.path}


@router.get("/state")
async def get_audio_state(request: Request):
    """Get current audio playback state."""
    engine = request.app.state.engine
    if engine is None:
        raise HTTPException(status_code=503, detail="Audio engine not ready")
    return engine.get_state()

Key points:

  • POST /api/audio/load accepts {"path": "/path/to/file.mp3"}
  • Validates file exists and extension is in SUPPORTED_EXTENSIONS
  • Uses asyncio.to_thread for engine.load() to avoid blocking event loop (per research pitfall 3)
  • GET /api/audio/state returns current engine state (useful for initial page load)
  • AUD-02 (YouTube) is noted as Phase 6 stub — not implemented here
  1. Update lightsync/main.py — register the audio router:

In create_app(), add BEFORE the static file handler:

from lightsync.api import shows, devices, ws, audio
# ... existing router includes ...
app.include_router(audio.router, prefix="/api/audio")
cd /home/claude/led2 && test -f lightsync/api/audio.py && grep -q "class LoadRequest" lightsync/api/audio.py && grep -q 'prefix="/api/audio"' lightsync/main.py && grep -q "SUPPORTED_EXTENSIONS" lightsync/api/audio.py && grep -q "asyncio.to_thread" lightsync/api/audio.py && echo "PASS" - File `lightsync/api/audio.py` exists with `router = APIRouter()` - `POST /load` endpoint accepts `LoadRequest(path: str)`, validates file existence and extension - Supported extensions: `.mp3`, `.wav`, `.flac`, `.ogg` - `engine.load()` called via `asyncio.to_thread` (non-blocking) - Returns `{"status": "loaded", "path": "..."}` on success - Returns 404 if file not found, 400 if unsupported format, 503 if engine not ready - `GET /state` endpoint returns engine.get_state() dict - `lightsync/main.py` includes `audio.router` at prefix `/api/audio` - Audio router registered BEFORE static file catch-all handler REST endpoints for audio load (with file validation) and state query; router wired in main.py - WebSocket sends position ticks with `type: "tick"` at 10Hz - WebSocket dispatches play/pause/seek/load commands - POST /api/audio/load validates and loads audio files - GET /api/audio/state returns playback state - broadcast_loop started in lifespan, cancelled on shutdown

<success_criteria>

  • Position ticks broadcast at 10Hz over WebSocket (AUD-04)
  • Transport commands dispatched from WebSocket to MPVEngine
  • REST audio load endpoint with file validation (AUD-01 integration)
  • AUD-02 noted as Phase 6 deferral
  • No blocking calls in async context (asyncio.to_thread for load) </success_criteria>
After completion, create `.planning/phases/02-audio-engine/02-02-SUMMARY.md`