327 lines
12 KiB
Markdown
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>
|