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

327 lines
12 KiB
Markdown

---
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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
<interfaces>
<!-- From 02-01: MPVEngine created in main.py lifespan -->
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
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Wire WebSocket command dispatch and 10Hz broadcast loop</name>
<files>lightsync/api/ws.py, lightsync/main.py</files>
<read_first>lightsync/api/ws.py, lightsync/main.py, lightsync/audio/engine.py</read_first>
<action>
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).
</action>
<verify>
<automated>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"</automated>
</verify>
<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>
<done>WebSocket dispatches transport commands to MPVEngine; broadcast_loop sends position ticks at 10Hz</done>
</task>
<task type="auto">
<name>Task 2: Add REST audio load endpoint with file validation</name>
<files>lightsync/api/audio.py, lightsync/main.py</files>
<read_first>lightsync/main.py, lightsync/api/shows.py</read_first>
<action>
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")
```
</action>
<verify>
<automated>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"</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>REST endpoints for audio load (with file validation) and state query; router wired in main.py</done>
</task>
</tasks>
<verification>
- 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
</verification>
<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>
<output>
After completion, create `.planning/phases/02-audio-engine/02-02-SUMMARY.md`
</output>