--- phase: 02-audio-engine plan: "02" type: execute wave: 2 depends_on: ["02-01"] files_modified: - lightsync/api/ws.py - lightsync/api/audio.py - lightsync/main.py autonomous: true requirements: [AUD-02, AUD-04] must_haves: truths: - "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" artifacts: - path: "lightsync/api/ws.py" provides: "WebSocket command dispatch + broadcast_loop integration" contains: "broadcast_loop" - path: "lightsync/api/audio.py" provides: "REST endpoints for audio load" exports: ["router"] - path: "lightsync/main.py" provides: "broadcast_loop task in lifespan, audio router registered" contains: "broadcast_loop" key_links: - from: "lightsync/api/ws.py" to: "lightsync/audio/engine.py" via: "engine.play/pause/seek/load dispatch" pattern: "engine\\.(play|pause|seek|load)" - from: "lightsync/main.py" to: "lightsync/api/ws.py" via: "broadcast_loop asyncio task in lifespan" pattern: "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. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.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): ```python 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): ```python engine: MPVEngine | None = None # module-level, set in lifespan # app.state.engine = engine # also available on app state ``` From lightsync/api/ws.py (existing): ```python 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: ```python 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"]`: ```python @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) ``` 2. 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: ```python task = asyncio.create_task(broadcast_loop(lambda: engine)) ``` In lifespan shutdown (before yield cleanup), add: ```python 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" - `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 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: ```python """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 2. Update `lightsync/main.py` — register the audio router: In `create_app()`, add BEFORE the static file handler: ```python 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 - 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) After completion, create `.planning/phases/02-audio-engine/02-02-SUMMARY.md`