Files
linux-voice-assistant/linux_voice_assistant/mpv_player.py
T
imonlinux 64c46fce2d Update mpv_player.py
added loop: asyncio.AbstractEventLoop to __init__ and used self.loop.call_threadsafe in _run_done_callback
2025-11-17 14:42:06 -08:00

212 lines
7.2 KiB
Python

"""
Media player using mpv in a subprocess.
Includes logic to detect the audio server being used (Pirewire, PulseAudio, Alsa).
This refactored version simplifies the audio backend selection logic,
improves readability with docstrings, and standardizes method signatures.
"""
from __future__ import annotations
import asyncio
import logging
import os
from threading import Lock, Timer
from typing import Callable, Iterable, List, Optional, Sequence, Tuple, Union
from mpv import MPV
_LOGGER = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
def _set_player_opt(player: MPV, key: str, value) -> bool:
"""Safely set an mpv option, logging any errors."""
try:
player[key] = value
return True
except Exception:
_LOGGER.warning("Failed to set mpv option %r=%r", key, value, exc_info=True)
return False
def _select_backend(player: MPV, device: Optional[str]) -> None:
"""
Selects the best available audio output backend for mpv.
The selection follows a clear priority:
1. A fully qualified device string (e.g., 'alsa/plughw:...') is used directly.
2. A simple device name tries PipeWire, then PulseAudio, then ALSA.
3. No device specified (or 'default') tries the default for PipeWire, PulseAudio, and ALSA in order.
"""
candidates: List[Tuple[str, str]] = []
# Define the preferred order of audio outputs
preferred_ao_order = ["pipewire", "pulse", "alsa"]
if device:
# If a full device path is given (e.g., "alsa/plughw:...")
for prefix in preferred_ao_order:
if device.startswith(f"{prefix}/"):
candidates.append((prefix, device))
break
# If it's a simple name or "default"
if not candidates:
for ao in preferred_ao_order:
candidates.append((ao, f"{ao}/{device}"))
else:
# If no device is specified, try the default for each backend
for ao in preferred_ao_order:
candidates.append((ao, f"{ao}/default"))
# Try to set the audio output from the generated candidates
for ao, audio_device in candidates:
try:
player["ao"] = ao
player["audio-device"] = audio_device
_LOGGER.debug("mpv backend selected: ao=%s, audio-device=%s", ao, audio_device)
return
except Exception:
continue
_LOGGER.warning("No suitable audio output backend could be found.")
class MpvMediaPlayer:
"""A media player class that wraps the python-mpv library."""
def __init__(
self,
loop: asyncio.AbstractEventLoop,
device: Optional[str] = None,
initial_volume: float = 1.0
) -> None:
self.loop = loop # <-- ADDED
self.player = MPV(
video=False,
terminal=False,
log_handler=self._mpv_log,
# Set options directly in the constructor
audio_samplerate=44100,
audio_channels="stereo",
keep_open="no",
network_timeout=7,
msg_level=os.environ.get("LVA_MPV_MSG_LEVEL", "all=warn"),
)
_select_backend(self.player, device)
self.set_volume(int(initial_volume * 100))
self.is_playing: bool = False
self._playlist: List[str] = []
self._done_callback: Optional[Callable[[], None]] = None
self._done_callback_lock = Lock()
self._pre_duck_volume: Optional[int] = None
self.player.observe_property("idle-active", self._on_idle_active)
def play(
self,
url: Union[str, Sequence[str], bytes],
done_callback: Optional[Callable[[], None]] = None,
) -> None:
"""Plays a URL or a sequence of URLs."""
self.stop() # Ensure player is in a clean state
with self._done_callback_lock:
self._done_callback = done_callback
playlist: List[str] = []
if isinstance(url, (list, tuple)):
playlist = list(url)
elif isinstance(url, bytes):
playlist = [url.decode(errors="ignore")]
elif isinstance(url, str):
playlist = [url]
else:
_LOGGER.error("play() expected str, bytes, or sequence, got %r", type(url))
self._run_done_callback()
return
if not playlist:
self._run_done_callback()
return
# Load the full playlist into mpv
self.player.playlist_clear()
for item in playlist:
self.player.playlist_append(item)
self.is_playing = True
self.player.playlist_pos = 0 # Start playing from the first item
def pause(self) -> None:
"""Pauses playback."""
self.player.pause = True
def resume(self) -> None:
"""Resumes playback."""
self.player.pause = False
def stop(self) -> None:
"""Stops playback and clears the playlist."""
if self.is_playing:
self.is_playing = False
self.player.playlist_clear()
self.player.command("stop")
self._run_done_callback()
def set_volume(self, volume: int) -> None:
"""Sets the player volume from 0 to 100."""
try:
self.player.volume = max(0, min(100, volume))
except Exception:
_LOGGER.exception("set_volume() failed")
def duck(self, target_percent: int = 20) -> None:
"""Lowers the volume for an announcement."""
if self._pre_duck_volume is not None:
return
try:
self._pre_duck_volume = int(self.player.volume)
self.set_volume(target_percent)
except Exception:
_LOGGER.exception("duck() failed")
def unduck(self) -> None:
"""Restores the volume after an announcement."""
if self._pre_duck_volume is None:
return
try:
self.set_volume(self._pre_duck_volume)
finally:
self._pre_duck_volume = None
def _on_idle_active(self, _name: str, active: bool) -> None:
"""Callback triggered when mpv enters or leaves the idle state."""
if active and self.is_playing:
_LOGGER.debug("mpv became idle; treating as end-of-playback")
self.is_playing = False
self._run_done_callback()
def _run_done_callback(self) -> None:
"""Safely runs the done_callback on the main asyncio loop."""
with self._done_callback_lock:
cb = self._done_callback
self._done_callback = None
if cb:
try:
# Use call_soon_threadsafe to run the callback on the main loop
self.loop.call_soon_threadsafe(cb) # <-- MODIFIED
except Exception:
_LOGGER.exception("Error scheduling done_callback")
def _mpv_log(self, level: str, prefix: str, text: str) -> None:
"""Routes mpv's internal logs to our logger."""
msg = f"mpv[{prefix}]: {text}".rstrip()
if level == "error":
_LOGGER.error(msg)
elif level == "warn":
_LOGGER.warning(msg)
elif level == "info":
_LOGGER.info(msg)
else:
_LOGGER.debug(msg)