diff --git a/.planning/phases/02-audio-engine/02-01-PLAN.md b/.planning/phases/02-audio-engine/02-01-PLAN.md new file mode 100644 index 0000000..4e7eae7 --- /dev/null +++ b/.planning/phases/02-audio-engine/02-01-PLAN.md @@ -0,0 +1,381 @@ +--- +phase: 02-audio-engine +plan: "01" +type: execute +wave: 1 +depends_on: [] +files_modified: + - Dockerfile + - pyproject.toml + - lightsync/audio/__init__.py + - lightsync/audio/engine.py + - lightsync/main.py +autonomous: true +requirements: [AUD-01, AUD-03, INF-03] + +must_haves: + truths: + - "MPVEngine class can load an audio file and report position" + - "play/pause/seek commands work on the MPVEngine" + - "Windows timer fix is platform-guarded (no-op on Linux)" + - "Docker image builds with mpv-libs, ffmpeg, libsndfile" + artifacts: + - path: "lightsync/audio/engine.py" + provides: "MPVEngine class with load/play/pause/seek/get_state" + exports: ["MPVEngine"] + - path: "lightsync/audio/__init__.py" + provides: "audio package init" + - path: "Dockerfile" + provides: "Alpine image with mpv-libs ffmpeg ffmpeg-libs libsndfile" + contains: "mpv-libs" + - path: "pyproject.toml" + provides: "python-mpv, soundfile, numpy dependencies" + contains: "python-mpv" + key_links: + - from: "lightsync/audio/engine.py" + to: "mpv (libmpv.so.2)" + via: "import mpv; mpv.MPV()" + pattern: "import mpv" + - from: "lightsync/main.py" + to: "lightsync/audio/engine.py" + via: "import and lifespan integration" + pattern: "from lightsync.audio.engine import MPVEngine" +--- + + +Create the MPVEngine audio backend — the headless mpv wrapper that loads files, controls playback, and polls position at 10Hz. Update Docker and Python dependencies. Implement INF-03 platform guard. + +Purpose: MPVEngine is the master clock for the entire show system. Every subsequent phase depends on accurate audio position. +Output: Working MPVEngine class, updated Dockerfile with audio libs, updated pyproject.toml. + + + +@$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 + + + + +From lightsync/main.py: +```python +from contextlib import asynccontextmanager +from pathlib import Path +from fastapi import FastAPI, Request +from fastapi.responses import FileResponse + +from lightsync.devices.registry import DeviceRegistry +from lightsync.store.show_store import ShowStore + +registry: DeviceRegistry | None = None +show_store: ShowStore | None = None + +@asynccontextmanager +async def lifespan(app: FastAPI): + global registry, show_store + registry = DeviceRegistry(Path("devices.json")) + await registry.load() + show_store = ShowStore(Path("shows")) + show_store.ensure_dir() + yield + await registry.save() + +def create_app() -> FastAPI: + app = FastAPI(title="LightSync", lifespan=lifespan, redirect_slashes=False) + from lightsync.api import shows, devices, ws + app.include_router(shows.router, prefix="/api/shows") + app.include_router(devices.router, prefix="/api/devices") + app.include_router(ws.router) + # ... static file serving ... + return app +``` + +From lightsync/api/ws.py: +```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() +``` + +From Dockerfile: +```dockerfile +FROM python:3.11-alpine +WORKDIR /app +COPY pyproject.toml . +RUN pip install --no-cache-dir . +COPY lightsync/ lightsync/ +EXPOSE 8000 +CMD ["python", "-m", "lightsync"] +``` + + + + + + + Task 1: Add audio dependencies to Dockerfile and pyproject.toml + Dockerfile, pyproject.toml + Dockerfile, pyproject.toml + +1. Update `Dockerfile` — add Alpine apk packages for the audio stack. Insert BEFORE `RUN pip install`: + +```dockerfile +FROM python:3.11-alpine + +WORKDIR /app + +# Audio stack: libmpv for python-mpv, ffmpeg for MP3 waveform, libsndfile for soundfile +RUN apk add --no-cache \ + mpv-libs \ + ffmpeg \ + ffmpeg-libs \ + libsndfile + +COPY pyproject.toml . +RUN pip install --no-cache-dir . + +COPY lightsync/ lightsync/ + +EXPOSE 8000 + +CMD ["python", "-m", "lightsync"] +``` + +Key points: +- `mpv-libs` (NOT full `mpv` package) — provides libmpv.so.2, ~7MB with 15 deps instead of 163 +- `ffmpeg` binary needed for MP3 waveform extraction pipe (not just ffmpeg-libs) +- `ffmpeg-libs` needed for libmpv codec support +- `libsndfile` for soundfile Python package (WAV/FLAC/OGG reading) + +2. Update `pyproject.toml` — add audio dependencies to `[project] dependencies`: + +```toml +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.34.0", + "pydantic>=2.0.0", + "aiofiles>=24.0.0", + "python-dotenv>=1.0.0", + "structlog>=24.0.0", + "python-mpv>=1.0.5", + "soundfile>=0.12.0", + "numpy>=1.26.0", +] +``` + +Per AUD-01: python-mpv wraps libmpv for audio loading. Per AUD-05: soundfile+numpy for waveform extraction. + + + cd /home/claude/led2 && grep -q "mpv-libs" Dockerfile && grep -q "python-mpv" pyproject.toml && grep -q "soundfile" pyproject.toml && grep -q "numpy" pyproject.toml && echo "PASS" + + +- Dockerfile contains `apk add --no-cache` line with `mpv-libs`, `ffmpeg`, `ffmpeg-libs`, `libsndfile` +- pyproject.toml dependencies list includes `python-mpv>=1.0.5`, `soundfile>=0.12.0`, `numpy>=1.26.0` +- Dockerfile still uses `python:3.11-alpine` base image +- Dockerfile still exposes port 8000 and runs `python -m lightsync` + + Dockerfile has Alpine audio packages; pyproject.toml has python-mpv, soundfile, numpy + + + + Task 2: Create MPVEngine class with position polling and platform timer fix + lightsync/audio/__init__.py, lightsync/audio/engine.py, lightsync/main.py + lightsync/main.py, lightsync/api/ws.py, .planning/phases/02-audio-engine/02-RESEARCH.md + +1. Create `lightsync/audio/__init__.py` — empty file (package marker). + +2. Create `lightsync/audio/engine.py` with the `MPVEngine` class: + +```python +"""MPV-based audio engine — headless playback with 10Hz position polling.""" +import sys +import threading +import time +from typing import Any + +import mpv + + +def apply_timer_fix() -> None: + """Apply 1ms timer resolution on Windows. No-op on Linux/macOS (INF-03).""" + if sys.platform == "win32": + import ctypes + winmm = ctypes.WinDLL("winmm") + winmm.timeBeginPeriod(1) + + +class MPVEngine: + """Wraps libmpv for headless audio playback with position polling. + + Usage: + engine = MPVEngine() + engine.start() # starts 10Hz polling thread + engine.load("/path/to/song.mp3") + engine.play() + pos = engine.get_state() # {"position": 42.1, "paused": False, ...} + engine.stop() + """ + + def __init__(self, ao: str = "null"): + self._player: mpv.MPV = mpv.MPV( + vo="null", + ao=ao, + input_default_bindings=False, + input_vo_keyboard=False, + ) + self._position: float = 0.0 + self._duration: float | None = None + self._loaded_path: str | None = None + self._lock = threading.Lock() + self._running = False + self._thread: threading.Thread | None = None + + def start(self) -> None: + """Start the 10Hz position polling thread.""" + apply_timer_fix() + self._running = True + self._thread = threading.Thread(target=self._poll_loop, daemon=True) + self._thread.start() + + def _poll_loop(self) -> None: + """Poll time-pos at 10Hz. Do NOT use observe_property — fires per audio frame.""" + while self._running: + pos = self._player.time_pos + dur = self._player.duration + with self._lock: + self._position = pos if pos is not None else 0.0 + if dur is not None: + self._duration = dur + time.sleep(0.1) + + def get_state(self) -> dict[str, Any]: + """Thread-safe snapshot of current playback state.""" + with self._lock: + return { + "position": self._position, + "paused": bool(self._player.pause), + "duration": self._duration, + "loaded": self._loaded_path is not None, + "file": self._loaded_path, + } + + def load(self, path: str) -> None: + """Load an audio file. Starts paused. Supports MP3, WAV, FLAC, OGG (AUD-01).""" + self._player.loadfile(path, mode="replace") + self._player.wait_for_playback() # don't use — blocks; instead: + # Actually: loadfile is async in mpv. Set pause after a short wait. + self._loaded_path = path + self._player.pause = True + + def play(self) -> None: + """Resume playback (AUD-03).""" + self._player.pause = False + + def pause(self) -> None: + """Pause playback (AUD-03).""" + self._player.pause = True + + def seek(self, seconds: float) -> None: + """Seek to absolute position in seconds (AUD-03).""" + self._player.seek(seconds, reference="absolute") + + def stop(self) -> None: + """Stop polling and terminate mpv.""" + self._running = False + if self._thread: + self._thread.join(timeout=2.0) + try: + self._player.terminate() + except Exception: + pass +``` + +IMPORTANT corrections to research code: +- Do NOT call `self._player.wait_for_playback()` in load() — it blocks until playback finishes. Instead, loadfile is already async in mpv; just set pause=True after calling it. +- The `load()` method should be: + ```python + def load(self, path: str) -> None: + self._loaded_path = path + self._player.loadfile(path, mode="replace") + # loadfile is async — mpv loads in background + # Pause will be applied once file is loaded + ``` +- Add `ao` parameter to constructor defaulting to `"null"` (server mode). Can be overridden via env var in main.py. + +3. Update `lightsync/main.py` — add MPVEngine to lifespan: + +Add `import os` at top. Add import: `from lightsync.audio.engine import MPVEngine`. +Add module-level: `engine: MPVEngine | None = None`. + +Update lifespan to create+start engine on startup, stop on shutdown: +```python +@asynccontextmanager +async def lifespan(app: FastAPI): + global registry, show_store, engine + # Startup + registry = DeviceRegistry(Path("devices.json")) + await registry.load() + show_store = ShowStore(Path("shows")) + show_store.ensure_dir() + ao = os.environ.get("MPV_AO", "null") + engine = MPVEngine(ao=ao) + engine.start() + app.state.engine = engine + yield + # Shutdown + engine.stop() + await registry.save() +``` + +Preserve ALL existing code in main.py (create_app, static file serving). Only modify lifespan and add imports/module var. + + + cd /home/claude/led2 && python -c "from lightsync.audio.engine import MPVEngine, apply_timer_fix; print('import OK')" 2>&1 || echo "Import check requires mpv lib - verify file exists and has correct class"; test -f lightsync/audio/__init__.py && test -f lightsync/audio/engine.py && grep -q "class MPVEngine" lightsync/audio/engine.py && grep -q "apply_timer_fix" lightsync/audio/engine.py && grep -q "sys.platform" lightsync/audio/engine.py && grep -q "MPVEngine" lightsync/main.py && echo "PASS" + + +- File `lightsync/audio/__init__.py` exists +- File `lightsync/audio/engine.py` exists with `class MPVEngine` containing methods: `start`, `_poll_loop`, `get_state`, `load`, `play`, `pause`, `seek`, `stop` +- `lightsync/audio/engine.py` contains `def apply_timer_fix` with `sys.platform == "win32"` guard (INF-03) +- `lightsync/main.py` imports `MPVEngine` from `lightsync.audio.engine` +- `lightsync/main.py` lifespan creates MPVEngine with `ao` from `MPV_AO` env var (default "null") +- `lightsync/main.py` lifespan calls `engine.start()` on startup and `engine.stop()` on shutdown +- `lightsync/main.py` sets `app.state.engine = engine` +- Existing main.py functionality (registry, show_store, create_app, static files) is preserved +- MPVEngine constructor accepts `ao` parameter (default "null"), passes `vo="null"` to mpv.MPV +- `_poll_loop` uses `time.sleep(0.1)` for 10Hz polling — does NOT use `observe_property` +- `get_state()` returns dict with keys: position, paused, duration, loaded, file + + MPVEngine class created with load/play/pause/seek/position polling; platform timer fix; lifespan integration in main.py + + + + + +- `lightsync/audio/engine.py` has MPVEngine with all methods +- `apply_timer_fix()` has `sys.platform == 'win32'` guard +- Dockerfile has `mpv-libs`, `ffmpeg`, `ffmpeg-libs`, `libsndfile` +- pyproject.toml has `python-mpv`, `soundfile`, `numpy` +- main.py lifespan creates and starts MPVEngine + + + +- MPVEngine class exists with load/play/pause/seek/get_state/stop methods +- 10Hz polling loop in daemon thread (time.sleep(0.1), NOT observe_property) +- INF-03 implemented as sys.platform guard +- Docker image can build with audio dependencies +- main.py integrates MPVEngine in lifespan + + + +After completion, create `.planning/phases/02-audio-engine/02-01-SUMMARY.md` + diff --git a/.planning/phases/02-audio-engine/02-02-PLAN.md b/.planning/phases/02-audio-engine/02-02-PLAN.md new file mode 100644 index 0000000..f578814 --- /dev/null +++ b/.planning/phases/02-audio-engine/02-02-PLAN.md @@ -0,0 +1,326 @@ +--- +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` + diff --git a/.planning/phases/02-audio-engine/02-03-PLAN.md b/.planning/phases/02-audio-engine/02-03-PLAN.md new file mode 100644 index 0000000..9198c50 --- /dev/null +++ b/.planning/phases/02-audio-engine/02-03-PLAN.md @@ -0,0 +1,623 @@ +--- +phase: 02-audio-engine +plan: "03" +type: execute +wave: 3 +depends_on: ["02-02"] +files_modified: + - lightsync/audio/waveform.py + - lightsync/api/audio.py + - lightsync/frontend/index.html + - lightsync/frontend/app.js + - lightsync/frontend/style.css +autonomous: false +requirements: [AUD-05] + +must_haves: + truths: + - "Waveform is rendered in the timeline panel from loaded audio" + - "Play/pause/seek controls work in the browser transport bar" + - "Position cursor moves in real time during playback" + - "Transport bar shows current time, duration, and playback state" + artifacts: + - path: "lightsync/audio/waveform.py" + provides: "extract_peaks() function for waveform data" + exports: ["extract_peaks"] + - path: "lightsync/api/audio.py" + provides: "GET /api/audio/waveform endpoint" + contains: "extract_peaks" + - path: "lightsync/frontend/app.js" + provides: "Transport controls, waveform canvas, position tick handler" + contains: "waveform" + - path: "lightsync/frontend/index.html" + provides: "Transport bar HTML, waveform canvas element" + contains: "transport" + key_links: + - from: "lightsync/frontend/app.js" + to: "/api/audio/waveform" + via: "fetch after audio load" + pattern: "fetch.*api/audio/waveform" + - from: "lightsync/frontend/app.js" + to: "WebSocket tick messages" + via: "handleMessage dispatches type=tick to update position display" + pattern: 'msg.type.*tick' + - from: "lightsync/api/audio.py" + to: "lightsync/audio/waveform.py" + via: "import extract_peaks" + pattern: "from lightsync.audio.waveform import extract_peaks" +--- + + +Build the waveform extraction module, wire the waveform HTTP endpoint, and create the browser transport UI (play/pause/seek buttons, time display, waveform canvas with position cursor). + +Purpose: The user needs visual feedback — waveform shows song structure, transport controls audio, position cursor tracks playback in real time. +Output: Working transport bar with play/pause/seek, waveform canvas in timeline panel, real-time position updates from WebSocket ticks. + + + +@$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 +@.planning/phases/02-audio-engine/02-02-SUMMARY.md + + + +From lightsync/audio/engine.py: +```python +class MPVEngine: + def get_state(self) -> dict[str, Any]: + # Returns: {"position": float, "paused": bool, "duration": float|None, "loaded": bool, "file": str|None} +``` + + +WebSocket tick message format: +```json +{"type": "tick", "position": 42.1, "paused": false, "duration": 180.5, "loaded": true, "file": "/path/to/song.mp3"} +``` + +WebSocket commands accepted: +```json +{"type": "play"} +{"type": "pause"} +{"type": "seek", "position": 42.0} +{"type": "load", "path": "/path/to/file.mp3"} +``` + +REST endpoints: +``` +POST /api/audio/load — body: {"path": "..."} +GET /api/audio/state — returns engine state dict +``` + +From lightsync/api/audio.py: +```python +router = APIRouter() +# Already has POST /load and GET /state +``` + + +From lightsync/frontend/index.html: +- `
` — currently shows placeholder text +- `