docs(02): create phase 02 audio engine plans (3 plans, 3 waves)
This commit is contained in:
381
.planning/phases/02-audio-engine/02-01-PLAN.md
Normal file
381
.planning/phases/02-audio-engine/02-01-PLAN.md
Normal file
@@ -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"
|
||||
---
|
||||
|
||||
<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>
|
||||
Reference in New Issue
Block a user