feat(02-01): implement MPVEngine with position polling and lifespan integration

- Create lightsync/audio/__init__.py package marker
- Create lightsync/audio/engine.py with MPVEngine class
  - Headless mpv wrapper (vo=null, ao from MPV_AO env var default null)
  - 10Hz polling loop in daemon thread (time.sleep(0.1), not observe_property)
  - Thread-safe get_state() returning position, paused, duration, loaded, file
  - load/play/pause/seek/stop methods (AUD-01, AUD-03)
  - apply_timer_fix() with sys.platform == win32 guard (INF-03)
- Update main.py lifespan to create/start/stop MPVEngine
  - MPV_AO env var controls audio output (default null for server mode)
  - engine exposed via app.state.engine for API routes
This commit is contained in:
Claude
2026-04-06 13:03:43 +00:00
parent 474b4a71d5
commit 5f75a19868
3 changed files with 110 additions and 1 deletions

View File

101
lightsync/audio/engine.py Normal file
View File

@@ -0,0 +1,101 @@
"""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._loaded_path = path
self._player.loadfile(path, mode="replace")
# loadfile is async in mpv — file loads in background
# pause=True will take effect once mpv has loaded the file
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

View File

@@ -1,3 +1,4 @@
import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request
@@ -6,22 +7,29 @@ from fastapi.staticfiles import StaticFiles
from lightsync.devices.registry import DeviceRegistry
from lightsync.store.show_store import ShowStore
from lightsync.audio.engine import MPVEngine
# Module-level containers populated in lifespan
registry: DeviceRegistry | None = None
show_store: ShowStore | None = None
engine: MPVEngine | None = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global registry, show_store
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()