Files
led2/lightsync/audio/engine.py
Claude 9239988d38 fix(02): Alpine libmpv/libsndfile symlinks, upload endpoint, browser file picker
- Dockerfile: add libmpv.so + libsndfile.so symlinks (Alpine/musl has no ldconfig)
- engine.py: monkey-patch ctypes.util.find_library as fallback for Alpine
- audio.py: add /upload (multipart) and /files endpoints
- pyproject.toml: add python-multipart
- frontend: replace path input with upload button + file selector dropdown
2026-04-06 15:34:11 +00:00

114 lines
3.8 KiB
Python

"""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