13 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 02-audio-engine | 01 | execute | 1 |
|
true |
|
|
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.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/02-audio-engine/02-RESEARCH.mdFrom lightsync/main.py:
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:
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:
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"]
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 fullmpvpackage) — provides libmpv.so.2, ~7MB with 15 deps instead of 163ffmpegbinary needed for MP3 waveform extraction pipe (not just ffmpeg-libs)ffmpeg-libsneeded for libmpv codec supportlibsndfilefor soundfile Python package (WAV/FLAC/OGG reading)
- Update
pyproject.toml— add audio dependencies to[project] dependencies:
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" <acceptance_criteria>
- Dockerfile contains
apk add --no-cacheline withmpv-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-alpinebase image - Dockerfile still exposes port 8000 and runs
python -m lightsync</acceptance_criteria> Dockerfile has Alpine audio packages; pyproject.toml has python-mpv, soundfile, numpy
- Create
lightsync/audio/engine.pywith theMPVEngineclass:
"""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: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
aoparameter to constructor defaulting to"null"(server mode). Can be overridden via env var in main.py.
- 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:
@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" <acceptance_criteria>
- File
lightsync/audio/__init__.pyexists - File
lightsync/audio/engine.pyexists withclass MPVEnginecontaining methods:start,_poll_loop,get_state,load,play,pause,seek,stop lightsync/audio/engine.pycontainsdef apply_timer_fixwithsys.platform == "win32"guard (INF-03)lightsync/main.pyimportsMPVEnginefromlightsync.audio.enginelightsync/main.pylifespan creates MPVEngine withaofromMPV_AOenv var (default "null")lightsync/main.pylifespan callsengine.start()on startup andengine.stop()on shutdownlightsync/main.pysetsapp.state.engine = engine- Existing main.py functionality (registry, show_store, create_app, static files) is preserved
- MPVEngine constructor accepts
aoparameter (default "null"), passesvo="null"to mpv.MPV _poll_loopusestime.sleep(0.1)for 10Hz polling — does NOT useobserve_propertyget_state()returns dict with keys: position, paused, duration, loaded, file </acceptance_criteria> MPVEngine class created with load/play/pause/seek/position polling; platform timer fix; lifespan integration in main.py
<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>