Refactored file with support for PW, PA, ALSA

This commit is contained in:
imonlinux
2025-09-27 22:05:47 -07:00
committed by GitHub
parent 74032adb9b
commit 14779b4439
+166 -111
View File
@@ -1,122 +1,165 @@
# linux_voice_assistant/mpv_player.py
"""Media player using mpv in a subprocess.
"""
Media player using mpv in a subprocess.
- Supports ALSA and PulseAudio
- Supports ALSA, PulseAudio, and PipeWire (native)
- Env override for backend: LVA_AO=pipewire|pulse|alsa (aliases: native/pw -> pipewire, pulseaudio -> pulse)
- 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
- Watchdog is **disabled by default** (set watchdog_sec > 0 to enable)
- 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")
- End-of-playback detection uses BOTH `eof-reached` and `idle-active`
- mpv log level configurable via env var LVA_MPV_MSG_LEVEL (e.g. "all=warn", "all=info")
"""
from __future__ import annotations
import logging
import os
from collections.abc import Callable, Sequence
from threading import Lock, Timer
from typing import Optional, Union
from typing import Callable, Iterable, Optional, Sequence, Tuple, Union
from mpv import MPV
_LOGGER = logging.getLogger(__name__)
# ---- constants & helpers -----------------------------------------------------
_DEFAULT_MSG_LEVEL = "all=warn"
# Map friendly env tokens to canonical mpv `ao` names
_AO_ENV_ALIASES = {
"native": "pipewire",
"pw": "pipewire",
"pulseaudio": "pulse",
}
_AO_ALLOWED = {"pipewire", "pulse", "alsa"}
def _normalize_env_ao(value: Optional[str]) -> Optional[str]:
"""Normalize LVA_AO environment override into one of _AO_ALLOWED or None."""
if not value:
return None
v = value.strip().lower()
v = _AO_ENV_ALIASES.get(v, v)
return v if v in _AO_ALLOWED else None
def _set_player_opt(player: MPV, key: str, value) -> None:
"""Set an mpv option with logging on failure."""
try:
player[key] = value
except Exception:
_LOGGER.exception("Failed to set mpv option %r=%r", key, value, exc_info=True)
def _select_backend(
player: MPV,
device: Optional[str],
env_ao: Optional[str],
) -> Tuple[Optional[str], Optional[str]]:
"""
Decide and apply (ao, audio-device) after probing candidates in order.
Returns:
(ao, audio_device) — the values successfully applied (or (None, None)).
"""
ao: Optional[str] = None
norm: Optional[str] = None
def try_candidates(candidates: Iterable[Tuple[str, str]]) -> bool:
nonlocal ao, norm
for cand_ao, cand_norm in candidates:
try:
player["ao"] = cand_ao # probe
ao, norm = cand_ao, cand_norm
return True
except Exception:
# Keep trying next backend
continue
return False
if device:
d = device.strip()
if d.startswith("alsa/"):
ao, norm = "alsa", d
elif d.startswith("pulse/"):
ao, norm = "pulse", d
elif d.startswith(("pipewire/", "pw/")):
ao, norm = "pipewire", d.replace("pw/", "pipewire/", 1)
elif d == "default":
# Preference: env override -> PipeWire -> Pulse -> ALSA
order: list[Tuple[str, str]] = []
if env_ao:
order.append((env_ao, f"{env_ao}/default"))
order += [
("pipewire", "pipewire/default"),
("pulse", "pulse/default"),
("alsa", "alsa/default"),
]
if not try_candidates(order):
_LOGGER.warning(
"No suitable audio output (PipeWire/Pulse/ALSA) available for 'default'"
)
else:
# No explicit prefix: respect env override first; otherwise PW -> Pulse -> ALSA
tried = False
if env_ao:
tried = try_candidates(
[(env_ao, f"{env_ao}/{d if env_ao != 'alsa' else 'default'}")]
)
if not tried:
try_candidates(
[
("pipewire", f"pipewire/{d}"),
("pulse", f"pulse/{d}"),
("alsa", "alsa/default"),
]
)
else:
# No device specified: env override first, then PW -> Pulse -> ALSA
order: list[Tuple[str, str]] = []
if env_ao:
order.append((env_ao, f"{env_ao}/default"))
order += [
("pipewire", "pipewire/default"),
("pulse", "pulse/default"),
("alsa", "alsa/default"),
]
try_candidates(order)
# Apply selections (safe setters log on failure)
if ao:
_set_player_opt(player, "ao", ao)
if norm:
_set_player_opt(player, "audio-device", norm)
if ao or norm:
_LOGGER.debug("mpv backend selected: ao=%s, audio-device=%s", ao, norm)
return ao, norm
# ---- class -------------------------------------------------------------------
class MpvMediaPlayer:
def __init__(self, device: Optional[str] = None, watchdog_sec: float = 0.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
# mpv defaults (errors are logged, not swallowed)
_set_player_opt(self.player, "audio-samplerate", 44100)
_set_player_opt(self.player, "audio-channels", "stereo")
_set_player_opt(self.player, "keep-open", "no")
_set_player_opt(self.player, "network-timeout", 7)
msg_level = os.environ.get("LVA_MPV_MSG_LEVEL", _DEFAULT_MSG_LEVEL)
_set_player_opt(self.player, "msg-level", msg_level)
# Normalize/select backend and device
ao = None
norm = None
# Env-driven AO override
env_ao = _normalize_env_ao(os.environ.get("LVA_AO"))
# Helper to try candidates in order
def _try_candidates(candidates):
nonlocal ao, norm
for cand_ao, cand_norm in candidates:
try:
self.player["ao"] = cand_ao
ao = cand_ao
norm = cand_norm
return True
except Exception:
continue
return False
if device:
d = device.strip()
if d.startswith("alsa/"):
ao = "alsa"; norm = d
elif d.startswith("pulse/"):
ao = "pulse"; norm = d
elif d.startswith(("pipewire/", "pw/")):
ao = "pipewire"; norm = d.replace("pw/", "pipewire/", 1)
elif d == "default":
# Preference order: env override -> PipeWire -> Pulse -> ALSA
order = []
if ao_env in {"pipewire","pulse","alsa"}:
order.append((ao_env, f"{ao_env}/default"))
order.extend([
("pipewire", "pipewire/default"),
("pulse", "pulse/default"),
("alsa", "alsa/default"),
])
_try_candidates(order)
if ao is None:
_LOGGER.warning("No suitable audio output (PipeWire/Pulse/ALSA) available for 'default'")
else:
# No explicit prefix: respect env override if set; otherwise prefer PipeWire, then Pulse, then ALSA
tried = False
if ao_env in {"pipewire","pulse","alsa"}:
tried = _try_candidates([(ao_env, f"{ao_env}/{d if ao_env!='alsa' else 'default'}")])
if not tried:
_try_candidates([
("pipewire", f"pipewire/{d}"),
("pulse", f"pulse/{d}"),
("alsa", "alsa/default"),
])
else:
# No device specified: env override first (if valid), then PipeWire, Pulse, ALSA
order = []
if ao_env in {"pipewire","pulse","alsa"}:
order.append((ao_env, f"{ao_env}/default"))
order.extend([
("pipewire", "pipewire/default"),
("pulse", "pulse/default"),
("alsa", "alsa/default"),
])
_try_candidates(order)
if ao:
try:
self.player["ao"] = ao
except Exception:
_LOGGER.warning("Requested ao=%s not available", ao)
if norm:
try:
self.player["audio-device"] = norm
except Exception:
_LOGGER.warning("Failed to set audio-device=%s", norm)
# Backend selection
self._ao, self._audio_device = _select_backend(self.player, device, env_ao)
# State
self.is_playing: bool = False
self._done_callback: Optional[Callable[[], None]] = None
self._done_callback_lock = Lock()
@@ -132,12 +175,14 @@ class MpvMediaPlayer:
def play(
self,
url: Union[str, Sequence[str]],
url: Union[str, Sequence[str], bytes],
done_callback: Optional[Callable[[], None]] = None,
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.
Accepts a single URL (str/bytes) or a sequence of URLs; if a sequence is given,
the first item is played.
"""
if stop_first:
try:
@@ -145,59 +190,65 @@ class MpvMediaPlayer:
except Exception:
_LOGGER.debug("stop_first=True: stop() raised, continuing", exc_info=True)
# Store callback
with self._done_callback_lock:
self._done_callback = done_callback
# Normalize url input
# 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
# Ensure not muted
try:
self.player["mute"] = "no"
except Exception:
pass
_LOGGER.debug("Failed to clear mute before play()", exc_info=True)
# Begin playback
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)
_LOGGER.exception("mpv failed to play %r", url, exc_info=True)
self._run_done_callback()
def pause(self) -> None:
try:
self.player.pause = True
except Exception:
_LOGGER.exception("mpv pause() failed")
_LOGGER.exception("mpv pause() failed", exc_info=True)
def resume(self) -> None:
try:
self.player.pause = False
except Exception:
_LOGGER.exception("mpv resume() failed")
_LOGGER.exception("mpv resume() failed", exc_info=True)
def stop(self) -> None:
"""Stop playback immediately and clear playing state; run done callback."""
try:
try:
self.player.command('stop')
self.player.command("stop")
except Exception:
self.player.command('loadfile', 'null://', 'replace')
# Fallback: load a null source to replace playback
self.player.command("loadfile", "null://", "replace")
finally:
self._cancel_watchdog()
was_playing = self.is_playing
@@ -206,7 +257,7 @@ class MpvMediaPlayer:
self._run_done_callback()
def set_volume(self, *args, **kwargs) -> None:
"""Set volume 0..100. Accepts positional or keyword 'volume' (backwards-compatible)."""
"""Set volume 0..100. Accepts positional or keyword 'volume' (back-compat)."""
vol_arg = args[0] if args else kwargs.get("volume")
if vol_arg is None:
_LOGGER.error("set_volume() requires an integer (0..100)")
@@ -215,15 +266,16 @@ class MpvMediaPlayer:
try:
vol = max(0, min(100, int(vol_arg)))
except Exception:
_LOGGER.exception("set_volume(): invalid value %r", vol_arg)
_LOGGER.exception("set_volume(): invalid value %r", vol_arg, exc_info=True)
return
try:
self.player.volume = vol
except Exception:
_LOGGER.exception("mpv set_volume(%s) failed", vol)
_LOGGER.exception("mpv set_volume(%s) failed", vol, exc_info=True)
def duck(self, target_percent: int = 20) -> None:
"""Temporarily lower volume for TTS/alerts; call unduck() to restore."""
if self._pre_duck_volume is not None:
return # already ducked
@@ -237,16 +289,17 @@ class MpvMediaPlayer:
try:
self.player.volume = max(0, min(100, int(target_percent)))
except Exception:
_LOGGER.exception("duck(): failed to set duck volume")
_LOGGER.exception("duck(): failed to set duck volume", exc_info=True)
def unduck(self) -> None:
"""Restore volume after duck()."""
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")
_LOGGER.exception("unduck(): failed to restore volume", exc_info=True)
finally:
self._pre_duck_volume = None
@@ -282,16 +335,18 @@ class MpvMediaPlayer:
try:
self._watchdog.cancel()
except Exception:
pass
_LOGGER.debug("watchdog cancel raised", exc_info=True)
self._watchdog = None
def _watchdog_trip(self) -> None:
if self.is_playing:
_LOGGER.warning("mpv watchdog fired after %.1fs; stopping playback", self._watchdog_sec)
_LOGGER.warning(
"mpv watchdog fired after %.1fs; stopping playback", self._watchdog_sec
)
try:
self.player.command('stop')
self.player.command("stop")
except Exception:
pass
_LOGGER.debug("watchdog stop raised", exc_info=True)
self.is_playing = False
self._run_done_callback()
@@ -305,7 +360,7 @@ class MpvMediaPlayer:
try:
cb()
except Exception:
_LOGGER.exception("Unexpected error running done callback")
_LOGGER.exception("Unexpected error running done callback", exc_info=True)
# -------------------- mpv log bridge --------------------
def _mpv_log(self, level: str, prefix: str, text: str) -> None: