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

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
Dockerfile
pyproject.toml
lightsync/audio/__init__.py
lightsync/audio/engine.py
lightsync/main.py
true
AUD-01
AUD-03
INF-03
truths artifacts key_links
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
path provides exports
lightsync/audio/engine.py MPVEngine class with load/play/pause/seek/get_state
MPVEngine
path provides
lightsync/audio/__init__.py audio package init
path provides contains
Dockerfile Alpine image with mpv-libs ffmpeg ffmpeg-libs libsndfile mpv-libs
path provides contains
pyproject.toml python-mpv, soundfile, numpy dependencies python-mpv
from to via pattern
lightsync/audio/engine.py mpv (libmpv.so.2) import mpv; mpv.MPV() import mpv
from to via pattern
lightsync/main.py lightsync/audio/engine.py import and lifespan integration 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.

<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.md

From 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"]
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`:
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)
  1. 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-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> 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).
  1. Create lightsync/audio/engine.py with the MPVEngine class:
"""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 ao parameter to constructor defaulting to "null" (server mode). Can be overridden via env var in main.py.
  1. 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__.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> 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

<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>
After completion, create `.planning/phases/02-audio-engine/02-01-SUMMARY.md`