diff --git a/lightsync/audio/__init__.py b/lightsync/audio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lightsync/audio/engine.py b/lightsync/audio/engine.py new file mode 100644 index 0000000..7ca6d6a --- /dev/null +++ b/lightsync/audio/engine.py @@ -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 diff --git a/lightsync/main.py b/lightsync/main.py index 1a819e4..8b3dba0 100644 --- a/lightsync/main.py +++ b/lightsync/main.py @@ -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()