Modifed mpv_player.py to support PA and ALSA

1. **`linux_voice_assistant/mpv_player.py`**
   - Supports both **PulseAudio** and **ALSA** backends.
   - Device strings now accept:
     - Pulse: `pulse/<PA SINK NAME>` (e.g. `pulse/alsa_output.platform-soc_sound.stereo-fallback`)
     - ALSA: `alsa/plughw:CARD=seeed2micvoicec,DEV=0`
     - `default`: tries Pulse first, then ALSA.
   - Added **fast end-of-playback detection** using **either** `eof-reached` **or** `idle-active` (fixes the “hang until watchdog” after short clips).
   - Added a **watchdog** (default **8s**) to fail fast if playback never starts/finishes.
   - Pulse-friendly defaults: `audio-samplerate=44100`, `audio-channels=stereo`, `keep-open=no`, `network-timeout=7`.
   - mpv logging bridged to Python logging; level set by env **`LVA_MPV_MSG_LEVEL`** (default `all=warn`).
This commit is contained in:
imonlinux
2025-09-13 19:58:15 -07:00
committed by GitHub
parent 8d179b371c
commit a0c5af9d4c
+243 -59
View File
@@ -1,9 +1,22 @@
"""Media player using mpv in a subprocess."""
"""Media player using mpv in a subprocess.
- Supports ALSA and PulseAudio
- Duck/unduck
- Robust URL handling (str | list/tuple | bytes)
- Backwards-compatible API: play(url, done_callback=None, stop_first=False), pause, resume, stop, set_volume
- Pulse-friendly defaults (44100 Hz stereo) and short network timeout
- Default watchdog = 8s (configurable), to avoid hangs if mpv fails to finish
- End-of-playback detection uses BOTH `eof-reached` and `idle-active` to finish immediately after short clips
- mpv log level can be controlled with env var LVA_MPV_MSG_LEVEL (e.g. "all=warn", "all=info")
"""
from __future__ import annotations
import logging
from collections.abc import Callable
from threading import Lock
from typing import List, Optional, Union
import os
from collections.abc import Callable, Sequence
from threading import Lock, Timer
from typing import Optional, Union
from mpv import MPV
@@ -11,84 +24,255 @@ _LOGGER = logging.getLogger(__name__)
class MpvMediaPlayer:
def __init__(self, device: Optional[str] = None) -> None:
self.player = MPV()
def __init__(self, device: Optional[str] = None, watchdog_sec: float = 8.0) -> None:
# Bridge mpv logs into Python logging
self.player = MPV(video=False, terminal=False, log_handler=self._mpv_log)
# Pulse-friendly defaults
try:
self.player['audio-samplerate'] = 44100
self.player['audio-channels'] = 'stereo'
self.player['keep-open'] = 'no'
self.player['network-timeout'] = 7
# Log level configurable via env; default to warn
msg_level = os.environ.get("LVA_MPV_MSG_LEVEL", "all=warn")
self.player['msg-level'] = msg_level
except Exception:
pass
# Normalize/select backend and device
ao = None
norm = None
if device:
self.player["audio-device"] = device
d = device.strip()
if d.startswith("alsa/"):
ao = "alsa"; norm = d
elif d.startswith("pulse/"):
ao = "pulse"; norm = d
elif d == "default":
try:
self.player["ao"] = "pulse"; norm = "pulse/default"
except Exception:
try:
self.player["ao"] = "alsa"; norm = "alsa/default"
except Exception:
_LOGGER.warning("Neither Pulse nor ALSA available for 'default'")
else:
# Assume Pulse sink name if no prefix
ao = "pulse"; norm = f"pulse/{d}"
self.is_playing = False
if ao:
try:
self.player["ao"] = ao
except Exception:
_LOGGER.warning("Requested ao=%s not available", ao)
self._playlist: List[str] = []
if norm:
try:
self.player["audio-device"] = norm
except Exception:
_LOGGER.warning("Failed to set audio-device=%s", norm)
# State
self.is_playing: bool = False
self._done_callback: Optional[Callable[[], None]] = None
self._done_callback_lock = Lock()
self._pre_duck_volume: Optional[int] = None # stores 0..100 when ducked
self._watchdog: Optional[Timer] = None
self._watchdog_sec: float = float(watchdog_sec)
self._duck_volume: int = 50
self._unduck_volume: int = 100
# Playback end detection (both EOF and idle)
self.player.observe_property("eof-reached", self._on_eof)
self.player.observe_property("idle-active", self._on_idle_active)
self.player.event_callback("end-file")(self._on_end_file)
# -------------------- public API --------------------
def play(
self,
url: Union[str, List[str]],
url: Union[str, Sequence[str]],
done_callback: Optional[Callable[[], None]] = None,
stop_first: bool = True,
stop_first: bool = False,
) -> None:
"""Begin playback of a URL or local file. Replaces any existing playback.
Accepts a single URL (str) or a sequence of URLs; if a sequence is given, the first item is played.
"""
if stop_first:
try:
self.stop()
except Exception:
_LOGGER.debug("stop_first=True: stop() raised, continuing", exc_info=True)
if isinstance(url, str):
self._playlist = [url]
else:
self._playlist = url
next_url = self._playlist.pop(0)
_LOGGER.debug("Playing %s", next_url)
with self._done_callback_lock:
self._done_callback = done_callback
self.is_playing = True
self.player.play(next_url)
def pause(self) -> None:
self.player.pause = True
self.is_playing = False
def resume(self) -> None:
self.player.pause = False
if self._playlist:
self.is_playing = True
def stop(self) -> None:
self.player.stop()
self._playlist.clear()
def duck(self) -> None:
self.player.volume = self._duck_volume
def unduck(self) -> None:
self.player.volume = self._unduck_volume
def set_volume(self, volume: int) -> None:
volume = max(0, min(100, volume))
self.player.volume = volume
self._unduck_volume = volume
self._duck_volume = volume // 2
def _on_end_file(self, event) -> None:
if self._playlist:
self.player.play(self._playlist.pop(0))
# Normalize url input
if isinstance(url, (list, tuple)):
if not url:
_LOGGER.error("mpv play() received empty URL list")
self._run_done_callback()
return
url = url[0]
if isinstance(url, bytes):
try:
url = url.decode("utf-8")
except Exception:
url = url.decode(errors="ignore")
if not isinstance(url, str):
_LOGGER.error("mpv play() expected str URL, got %r", type(url))
self._run_done_callback()
return
self.is_playing = False
try:
self.player["mute"] = "no"
except Exception:
pass
todo_callback: Optional[Callable[[], None]] = None
self.is_playing = True
try:
self.player.play(url)
self._arm_watchdog()
except Exception:
self.is_playing = False
_LOGGER.exception("mpv failed to play %r", url)
self._run_done_callback()
def pause(self) -> None:
try:
self.player.pause = True
except Exception:
_LOGGER.exception("mpv pause() failed")
def resume(self) -> None:
try:
self.player.pause = False
except Exception:
_LOGGER.exception("mpv resume() failed")
def stop(self) -> None:
"""Stop playback immediately and clear playing state; run done callback."""
try:
try:
self.player.command('stop')
except Exception:
self.player.command('loadfile', 'null://', 'replace')
finally:
self._cancel_watchdog()
was_playing = self.is_playing
self.is_playing = False
if was_playing:
self._run_done_callback()
def set_volume(self, *args, **kwargs) -> None:
"""Set volume 0..100. Accepts positional or keyword 'volume' (backwards-compatible)."""
vol_arg = args[0] if args else kwargs.get("volume")
if vol_arg is None:
_LOGGER.error("set_volume() requires an integer (0..100)")
return
try:
vol = max(0, min(100, int(vol_arg)))
except Exception:
_LOGGER.exception("set_volume(): invalid value %r", vol_arg)
return
try:
self.player.volume = vol
except Exception:
_LOGGER.exception("mpv set_volume(%s) failed", vol)
def duck(self, target_percent: int = 20) -> None:
if self._pre_duck_volume is not None:
return # already ducked
try:
current = int(round(float(self.player.volume)))
except Exception:
current = 100
self._pre_duck_volume = max(0, min(100, current))
try:
self.player.volume = max(0, min(100, int(target_percent)))
except Exception:
_LOGGER.exception("duck(): failed to set duck volume")
def unduck(self) -> None:
if self._pre_duck_volume is None:
return
try:
self.player.volume = self._pre_duck_volume
except Exception:
_LOGGER.exception("unduck(): failed to restore volume")
finally:
self._pre_duck_volume = None
# -------------------- callbacks & watchdog --------------------
def _on_eof(self, _name: str, reached: bool) -> None:
if not reached:
return
self._cancel_watchdog()
self.is_playing = False
self._run_done_callback()
def _on_idle_active(self, _name: str, active: bool) -> None:
# When playback ends, mpv enters idle; treat that as completion.
if not self.is_playing:
return
if bool(active):
_LOGGER.debug("mpv became idle; treating as end-of-playback")
self._cancel_watchdog()
self.is_playing = False
self._run_done_callback()
def _arm_watchdog(self) -> None:
self._cancel_watchdog()
if self._watchdog_sec <= 0:
return
self._watchdog = Timer(self._watchdog_sec, self._watchdog_trip)
self._watchdog.daemon = True
self._watchdog.start()
def _cancel_watchdog(self) -> None:
if self._watchdog is not None:
try:
self._watchdog.cancel()
except Exception:
pass
self._watchdog = None
def _watchdog_trip(self) -> None:
if self.is_playing:
_LOGGER.warning("mpv watchdog fired after %.1fs; stopping playback", self._watchdog_sec)
try:
self.player.command('stop')
except Exception:
pass
self.is_playing = False
self._run_done_callback()
def _run_done_callback(self) -> None:
cb: Optional[Callable[[], None]] = None
with self._done_callback_lock:
if self._done_callback:
todo_callback = self._done_callback
cb = self._done_callback
self._done_callback = None
if todo_callback:
if cb is not None:
try:
todo_callback()
cb()
except Exception:
_LOGGER.exception("Unexpected error running done callback")
# -------------------- mpv log bridge --------------------
def _mpv_log(self, level: str, prefix: str, text: str) -> None:
msg = f"mpv[{level}] {prefix}: {text}".rstrip()
if level in ("fatal", "error"):
_LOGGER.error(msg)
elif level in ("warn", "warning"):
_LOGGER.warning(msg)
elif level in ("info",):
_LOGGER.info(msg)
else:
_LOGGER.debug(msg)