"""MPV-based audio engine — headless playback with 10Hz position polling.""" import ctypes.util as _ctypes_util import sys import threading import time from typing import Any # Alpine/musl: ctypes.util.find_library can't discover libmpv without ldconfig or objdump. # Patch it to return the known path so python-mpv can load the library. _orig_find_library = _ctypes_util.find_library def _patched_find_library(name: str) -> str | None: if name == "mpv": return "/usr/lib/libmpv.so.2" return _orig_find_library(name) _ctypes_util.find_library = _patched_find_library import mpv # noqa: E402 _ctypes_util.find_library = _orig_find_library # restore after mpv loaded 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