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:
0
lightsync/audio/__init__.py
Normal file
0
lightsync/audio/__init__.py
Normal file
101
lightsync/audio/engine.py
Normal file
101
lightsync/audio/engine.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user