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 |
|
|
true |
|
|
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.mdFrom 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
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)
- 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.pycontainsasync 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_connectionsis non-empty - WebSocket endpoint dispatches
play,pause,seek,loadcommands to engine - Each command sends back
{"type": "ack", "command": "..."}confirmation seekcommand readsmsg["position"]as floatloadcommand readsmsg["path"]as string- Unknown message types still get echo response (backward compat)
lightsync/main.pystarts broadcast_loop viaasyncio.create_taskin lifespanlightsync/main.pycancels 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
"""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/loadaccepts{"path": "/path/to/file.mp3"}- Validates file exists and extension is in SUPPORTED_EXTENSIONS
- Uses
asyncio.to_threadforengine.load()to avoid blocking event loop (per research pitfall 3) GET /api/audio/statereturns current engine state (useful for initial page load)- AUD-02 (YouTube) is noted as Phase 6 stub — not implemented here
- 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")
<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>