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

382 lines
13 KiB
Markdown

---
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"
---
<objective>
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.
</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
<interfaces>
<!-- Existing codebase contracts the executor needs -->
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"]
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add audio dependencies to Dockerfile and pyproject.toml</name>
<files>Dockerfile, pyproject.toml</files>
<read_first>Dockerfile, pyproject.toml</read_first>
<action>
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.
</action>
<verify>
<automated>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"</automated>
</verify>
<acceptance_criteria>
- 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`
</acceptance_criteria>
<done>Dockerfile has Alpine audio packages; pyproject.toml has python-mpv, soundfile, numpy</done>
</task>
<task type="auto">
<name>Task 2: Create MPVEngine class with position polling and platform timer fix</name>
<files>lightsync/audio/__init__.py, lightsync/audio/engine.py, lightsync/main.py</files>
<read_first>lightsync/main.py, lightsync/api/ws.py, .planning/phases/02-audio-engine/02-RESEARCH.md</read_first>
<action>
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.
</action>
<verify>
<automated>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"</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>MPVEngine class created with load/play/pause/seek/position polling; platform timer fix; lifespan integration in main.py</done>
</task>
</tasks>
<verification>
- `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
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/02-audio-engine/02-01-SUMMARY.md`
</output>