--- 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`