382 lines
13 KiB
Markdown
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>
|