Refactored file with support for PW, PA, ALSA
This commit is contained in:
+166
-111
@@ -1,122 +1,165 @@
|
|||||||
# linux_voice_assistant/mpv_player.py
|
# 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
|
- Duck/unduck
|
||||||
- Robust URL handling (str | list/tuple | bytes)
|
- 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)
|
- 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
|
- End-of-playback detection uses BOTH `eof-reached` and `idle-active`
|
||||||
- mpv log level can be controlled with env var LVA_MPV_MSG_LEVEL (e.g. "all=warn", "all=info")
|
- mpv log level configurable via env var LVA_MPV_MSG_LEVEL (e.g. "all=warn", "all=info")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from collections.abc import Callable, Sequence
|
|
||||||
from threading import Lock, Timer
|
from threading import Lock, Timer
|
||||||
from typing import Optional, Union
|
from typing import Callable, Iterable, Optional, Sequence, Tuple, Union
|
||||||
|
|
||||||
from mpv import MPV
|
from mpv import MPV
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_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:
|
class MpvMediaPlayer:
|
||||||
def __init__(self, device: Optional[str] = None, watchdog_sec: float = 0.0) -> None:
|
def __init__(self, device: Optional[str] = None, watchdog_sec: float = 0.0) -> None:
|
||||||
# Bridge mpv logs into Python logging
|
# Bridge mpv logs into Python logging
|
||||||
self.player = MPV(video=False, terminal=False, log_handler=self._mpv_log)
|
self.player = MPV(video=False, terminal=False, log_handler=self._mpv_log)
|
||||||
|
|
||||||
# Pulse-friendly defaults
|
# mpv defaults (errors are logged, not swallowed)
|
||||||
try:
|
_set_player_opt(self.player, "audio-samplerate", 44100)
|
||||||
self.player['audio-samplerate'] = 44100
|
_set_player_opt(self.player, "audio-channels", "stereo")
|
||||||
self.player['audio-channels'] = 'stereo'
|
_set_player_opt(self.player, "keep-open", "no")
|
||||||
self.player['keep-open'] = 'no'
|
_set_player_opt(self.player, "network-timeout", 7)
|
||||||
self.player['network-timeout'] = 7
|
msg_level = os.environ.get("LVA_MPV_MSG_LEVEL", _DEFAULT_MSG_LEVEL)
|
||||||
# Log level configurable via env; default to warn
|
_set_player_opt(self.player, "msg-level", msg_level)
|
||||||
msg_level = os.environ.get("LVA_MPV_MSG_LEVEL", "all=warn")
|
|
||||||
self.player['msg-level'] = msg_level
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
# Env-driven AO override
|
||||||
# Normalize/select backend and device
|
env_ao = _normalize_env_ao(os.environ.get("LVA_AO"))
|
||||||
ao = None
|
|
||||||
norm = None
|
|
||||||
|
|
||||||
# Helper to try candidates in order
|
# Backend selection
|
||||||
def _try_candidates(candidates):
|
self._ao, self._audio_device = _select_backend(self.player, device, env_ao)
|
||||||
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)
|
|
||||||
|
|
||||||
# State
|
# State
|
||||||
|
|
||||||
self.is_playing: bool = False
|
self.is_playing: bool = False
|
||||||
self._done_callback: Optional[Callable[[], None]] = None
|
self._done_callback: Optional[Callable[[], None]] = None
|
||||||
self._done_callback_lock = Lock()
|
self._done_callback_lock = Lock()
|
||||||
@@ -132,12 +175,14 @@ class MpvMediaPlayer:
|
|||||||
|
|
||||||
def play(
|
def play(
|
||||||
self,
|
self,
|
||||||
url: Union[str, Sequence[str]],
|
url: Union[str, Sequence[str], bytes],
|
||||||
done_callback: Optional[Callable[[], None]] = None,
|
done_callback: Optional[Callable[[], None]] = None,
|
||||||
stop_first: bool = False,
|
stop_first: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Begin playback of a URL or local file. Replaces any existing playback.
|
"""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:
|
if stop_first:
|
||||||
try:
|
try:
|
||||||
@@ -145,59 +190,65 @@ class MpvMediaPlayer:
|
|||||||
except Exception:
|
except Exception:
|
||||||
_LOGGER.debug("stop_first=True: stop() raised, continuing", exc_info=True)
|
_LOGGER.debug("stop_first=True: stop() raised, continuing", exc_info=True)
|
||||||
|
|
||||||
|
# Store callback
|
||||||
with self._done_callback_lock:
|
with self._done_callback_lock:
|
||||||
self._done_callback = done_callback
|
self._done_callback = done_callback
|
||||||
|
|
||||||
# Normalize url input
|
# Normalize URL input
|
||||||
if isinstance(url, (list, tuple)):
|
if isinstance(url, (list, tuple)):
|
||||||
if not url:
|
if not url:
|
||||||
_LOGGER.error("mpv play() received empty URL list")
|
_LOGGER.error("mpv play() received empty URL list")
|
||||||
self._run_done_callback()
|
self._run_done_callback()
|
||||||
return
|
return
|
||||||
url = url[0]
|
url = url[0]
|
||||||
|
|
||||||
if isinstance(url, bytes):
|
if isinstance(url, bytes):
|
||||||
try:
|
try:
|
||||||
url = url.decode("utf-8")
|
url = url.decode("utf-8")
|
||||||
except Exception:
|
except Exception:
|
||||||
url = url.decode(errors="ignore")
|
url = url.decode(errors="ignore")
|
||||||
|
|
||||||
if not isinstance(url, str):
|
if not isinstance(url, str):
|
||||||
_LOGGER.error("mpv play() expected str URL, got %r", type(url))
|
_LOGGER.error("mpv play() expected str URL, got %r", type(url))
|
||||||
self._run_done_callback()
|
self._run_done_callback()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Ensure not muted
|
||||||
try:
|
try:
|
||||||
self.player["mute"] = "no"
|
self.player["mute"] = "no"
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
_LOGGER.debug("Failed to clear mute before play()", exc_info=True)
|
||||||
|
|
||||||
|
# Begin playback
|
||||||
self.is_playing = True
|
self.is_playing = True
|
||||||
try:
|
try:
|
||||||
self.player.play(url)
|
self.player.play(url)
|
||||||
self._arm_watchdog()
|
self._arm_watchdog()
|
||||||
except Exception:
|
except Exception:
|
||||||
self.is_playing = False
|
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()
|
self._run_done_callback()
|
||||||
|
|
||||||
def pause(self) -> None:
|
def pause(self) -> None:
|
||||||
try:
|
try:
|
||||||
self.player.pause = True
|
self.player.pause = True
|
||||||
except Exception:
|
except Exception:
|
||||||
_LOGGER.exception("mpv pause() failed")
|
_LOGGER.exception("mpv pause() failed", exc_info=True)
|
||||||
|
|
||||||
def resume(self) -> None:
|
def resume(self) -> None:
|
||||||
try:
|
try:
|
||||||
self.player.pause = False
|
self.player.pause = False
|
||||||
except Exception:
|
except Exception:
|
||||||
_LOGGER.exception("mpv resume() failed")
|
_LOGGER.exception("mpv resume() failed", exc_info=True)
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
"""Stop playback immediately and clear playing state; run done callback."""
|
"""Stop playback immediately and clear playing state; run done callback."""
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
self.player.command('stop')
|
self.player.command("stop")
|
||||||
except Exception:
|
except Exception:
|
||||||
self.player.command('loadfile', 'null://', 'replace')
|
# Fallback: load a null source to replace playback
|
||||||
|
self.player.command("loadfile", "null://", "replace")
|
||||||
finally:
|
finally:
|
||||||
self._cancel_watchdog()
|
self._cancel_watchdog()
|
||||||
was_playing = self.is_playing
|
was_playing = self.is_playing
|
||||||
@@ -206,7 +257,7 @@ class MpvMediaPlayer:
|
|||||||
self._run_done_callback()
|
self._run_done_callback()
|
||||||
|
|
||||||
def set_volume(self, *args, **kwargs) -> None:
|
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")
|
vol_arg = args[0] if args else kwargs.get("volume")
|
||||||
if vol_arg is None:
|
if vol_arg is None:
|
||||||
_LOGGER.error("set_volume() requires an integer (0..100)")
|
_LOGGER.error("set_volume() requires an integer (0..100)")
|
||||||
@@ -215,15 +266,16 @@ class MpvMediaPlayer:
|
|||||||
try:
|
try:
|
||||||
vol = max(0, min(100, int(vol_arg)))
|
vol = max(0, min(100, int(vol_arg)))
|
||||||
except Exception:
|
except Exception:
|
||||||
_LOGGER.exception("set_volume(): invalid value %r", vol_arg)
|
_LOGGER.exception("set_volume(): invalid value %r", vol_arg, exc_info=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.player.volume = vol
|
self.player.volume = vol
|
||||||
except Exception:
|
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:
|
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:
|
if self._pre_duck_volume is not None:
|
||||||
return # already ducked
|
return # already ducked
|
||||||
|
|
||||||
@@ -237,16 +289,17 @@ class MpvMediaPlayer:
|
|||||||
try:
|
try:
|
||||||
self.player.volume = max(0, min(100, int(target_percent)))
|
self.player.volume = max(0, min(100, int(target_percent)))
|
||||||
except Exception:
|
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:
|
def unduck(self) -> None:
|
||||||
|
"""Restore volume after duck()."""
|
||||||
if self._pre_duck_volume is None:
|
if self._pre_duck_volume is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.player.volume = self._pre_duck_volume
|
self.player.volume = self._pre_duck_volume
|
||||||
except Exception:
|
except Exception:
|
||||||
_LOGGER.exception("unduck(): failed to restore volume")
|
_LOGGER.exception("unduck(): failed to restore volume", exc_info=True)
|
||||||
finally:
|
finally:
|
||||||
self._pre_duck_volume = None
|
self._pre_duck_volume = None
|
||||||
|
|
||||||
@@ -282,16 +335,18 @@ class MpvMediaPlayer:
|
|||||||
try:
|
try:
|
||||||
self._watchdog.cancel()
|
self._watchdog.cancel()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
_LOGGER.debug("watchdog cancel raised", exc_info=True)
|
||||||
self._watchdog = None
|
self._watchdog = None
|
||||||
|
|
||||||
def _watchdog_trip(self) -> None:
|
def _watchdog_trip(self) -> None:
|
||||||
if self.is_playing:
|
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:
|
try:
|
||||||
self.player.command('stop')
|
self.player.command("stop")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
_LOGGER.debug("watchdog stop raised", exc_info=True)
|
||||||
self.is_playing = False
|
self.is_playing = False
|
||||||
self._run_done_callback()
|
self._run_done_callback()
|
||||||
|
|
||||||
@@ -305,7 +360,7 @@ class MpvMediaPlayer:
|
|||||||
try:
|
try:
|
||||||
cb()
|
cb()
|
||||||
except Exception:
|
except Exception:
|
||||||
_LOGGER.exception("Unexpected error running done callback")
|
_LOGGER.exception("Unexpected error running done callback", exc_info=True)
|
||||||
|
|
||||||
# -------------------- mpv log bridge --------------------
|
# -------------------- mpv log bridge --------------------
|
||||||
def _mpv_log(self, level: str, prefix: str, text: str) -> None:
|
def _mpv_log(self, level: str, prefix: str, text: str) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user