Merge branch 'main' into issue_128
This commit is contained in:
24 files changed
+1753
-185
No files matched your search
@@ -104,6 +104,7 @@ async def main() -> None:
|
||||
default="0.0.0.0",
|
||||
help="Address for ESPHome server (default: 0.0.0.0)",
|
||||
)
|
||||
# Note that default port is also set in docker-entrypoint.sh
|
||||
parser.add_argument(
|
||||
"--port", type=int, default=6053, help="Port for ESPHome server (default: 6053)"
|
||||
)
|
||||
|
||||
@@ -1,94 +1,108 @@
|
||||
"""Media player using mpv in a subprocess."""
|
||||
|
||||
# mpv_player.py
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from threading import Lock
|
||||
from typing import List, Optional, Union
|
||||
from typing import Union, List, Callable, Optional
|
||||
|
||||
from mpv import MPV
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from .player.libmpv import LibMpvPlayer
|
||||
|
||||
|
||||
class MpvMediaPlayer:
|
||||
def __init__(self, device: Optional[str] = None) -> None:
|
||||
self.player = MPV()
|
||||
"""
|
||||
Linux Voice Assistant MediaPlayer implementation based on libmpv.
|
||||
|
||||
if device:
|
||||
self.player["audio-device"] = device
|
||||
This class provides the MediaPlayer interface expected by LVA and
|
||||
delegates all playback logic to LibMpvPlayer.
|
||||
"""
|
||||
|
||||
self.is_playing = False
|
||||
|
||||
self._playlist: List[str] = []
|
||||
def __init__(self, device: str | None = None) -> None:
|
||||
self._log = logging.getLogger(self.__class__.__name__)
|
||||
self._player = LibMpvPlayer(device=device)
|
||||
self._done_callback: Optional[Callable[[], None]] = None
|
||||
self._done_callback_lock = Lock()
|
||||
|
||||
self._duck_volume: int = 50
|
||||
self._unduck_volume: int = 100
|
||||
|
||||
self.player.event_callback("end-file")(self._on_end_file)
|
||||
self._log.debug("MpvMediaPlayer initialized (device=%s)", device)
|
||||
|
||||
def play(
|
||||
self,
|
||||
url: Union[str, List[str]],
|
||||
done_callback: Optional[Callable[[], None]] = None,
|
||||
stop_first: bool = True,
|
||||
stop_first: bool = False,
|
||||
) -> None:
|
||||
self.stop()
|
||||
"""
|
||||
Play a media URL.
|
||||
|
||||
if isinstance(url, str):
|
||||
self._playlist = [url]
|
||||
else:
|
||||
self._playlist = url
|
||||
Args:
|
||||
url: Media URL or list of URLs (LVA currently uses a single URL).
|
||||
done_callback: Optional callback invoked when playback finishes.
|
||||
stop_first: Kept for API compatibility.
|
||||
"""
|
||||
# LVA currently only uses single URLs
|
||||
if isinstance(url, list):
|
||||
self._log.debug("Received URL list, using first entry")
|
||||
url = url[0]
|
||||
|
||||
next_url = self._playlist.pop(0)
|
||||
_LOGGER.debug("Playing %s", next_url)
|
||||
# Track is changing
|
||||
if self._done_callback is not None:
|
||||
self._log.debug(
|
||||
"Stopping active playback before starting new media"
|
||||
)
|
||||
# Not self.stop() → this would call done_callback
|
||||
self._player.stop(for_replacement=True)
|
||||
self._done_callback = None
|
||||
|
||||
self._log.info("Playing media: %s", url)
|
||||
self._log.debug(
|
||||
"play(url=%s, stop_first=%s, done_callback=%s)",
|
||||
url,
|
||||
stop_first,
|
||||
bool(done_callback),
|
||||
)
|
||||
|
||||
self._done_callback = done_callback
|
||||
self.is_playing = True
|
||||
self.player.play(next_url)
|
||||
self._player.play(url, done_callback=done_callback, stop_first=stop_first)
|
||||
|
||||
def pause(self) -> None:
|
||||
self.player.pause = True
|
||||
self.is_playing = False
|
||||
"""Pause playback."""
|
||||
self._log.debug("pause() called")
|
||||
self._player.pause()
|
||||
|
||||
def resume(self) -> None:
|
||||
self.player.pause = False
|
||||
if self._playlist:
|
||||
self.is_playing = True
|
||||
"""Resume playback."""
|
||||
self._log.debug("resume() called")
|
||||
self._player.resume()
|
||||
|
||||
def stop(self) -> None:
|
||||
self.player.stop()
|
||||
self._playlist.clear()
|
||||
"""Stop playback and invoke the done callback if present."""
|
||||
self._log.debug("stop() called")
|
||||
|
||||
def duck(self) -> None:
|
||||
self.player.volume = self._duck_volume
|
||||
self._player.stop()
|
||||
|
||||
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))
|
||||
return
|
||||
|
||||
self.is_playing = False
|
||||
|
||||
todo_callback: Optional[Callable[[], None]] = None
|
||||
with self._done_callback_lock:
|
||||
if self._done_callback:
|
||||
todo_callback = self._done_callback
|
||||
if self._done_callback:
|
||||
self._log.debug("Invoking done_callback due to stop()")
|
||||
try:
|
||||
self._done_callback()
|
||||
finally:
|
||||
self._done_callback = None
|
||||
|
||||
if todo_callback:
|
||||
try:
|
||||
todo_callback()
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error running done callback")
|
||||
def set_volume(self, volume: float) -> None:
|
||||
"""
|
||||
Set playback volume.
|
||||
|
||||
Args:
|
||||
volume: Volume in percent (0.0–100.0).
|
||||
"""
|
||||
self._log.debug("set_volume(volume=%.2f)", volume)
|
||||
self._player.set_volume(volume)
|
||||
|
||||
def duck(self, factor: float = 0.5) -> None:
|
||||
"""
|
||||
Temporarily reduce volume.
|
||||
|
||||
Args:
|
||||
factor: Volume multiplier (0.0–1.0).
|
||||
"""
|
||||
self._log.debug("duck(factor=%.2f)", factor)
|
||||
self._player.duck(factor)
|
||||
|
||||
def unduck(self) -> None:
|
||||
"""Restore volume after ducking."""
|
||||
self._log.debug("unduck() called")
|
||||
self._player.unduck()
|
||||
@@ -0,0 +1,25 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from .state import PlayerState
|
||||
|
||||
|
||||
class AudioPlayer(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def play(self, url: str) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def pause(self) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def resume(self) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def stop(self) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def state(self) -> PlayerState:
|
||||
pass
|
||||
@@ -0,0 +1,163 @@
|
||||
import threading
|
||||
from typing import Optional, Callable
|
||||
|
||||
import mpv
|
||||
|
||||
from .base import AudioPlayer
|
||||
from .state import PlayerState
|
||||
|
||||
|
||||
class LibMpvPlayer(AudioPlayer):
|
||||
"""
|
||||
AudioPlayer implementation for Linux Voice Assistant using libmpv.
|
||||
|
||||
Responsibilities:
|
||||
- mpv lifecycle and playback control
|
||||
- thread-safe state management
|
||||
- volume handling with ducking support
|
||||
"""
|
||||
|
||||
def __init__(self, device: Optional[str] = None) -> None:
|
||||
self._state: PlayerState = PlayerState.IDLE
|
||||
self._state_lock = threading.Lock()
|
||||
|
||||
# Volume handling
|
||||
self._user_volume: float = 100.0 # 0.0 – 100.0
|
||||
self._duck_factor: float = 1.0 # 0.0 – 1.0
|
||||
|
||||
# mpv setup
|
||||
self._mpv = mpv.MPV(
|
||||
audio_display=False,
|
||||
log_handler=self._on_mpv_log,
|
||||
loglevel="error",
|
||||
)
|
||||
|
||||
if device:
|
||||
self._mpv["audio-device"] = device
|
||||
|
||||
# Callback Handling
|
||||
self._done_callback: Optional[Callable[[], None]] = None
|
||||
self._suppress_end_event = False
|
||||
self._mpv.event_callback("end-file")(self._on_end_file)
|
||||
|
||||
# -------- Playback control --------
|
||||
|
||||
def play(
|
||||
self,
|
||||
url: str,
|
||||
done_callback: Optional[Callable[[], None]] = None,
|
||||
stop_first: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Start playback of a media URL.
|
||||
|
||||
Args:
|
||||
url: Media URL or local file path.
|
||||
done_callback: Optional callback invoked when playback finishes.
|
||||
stop_first: If True, start playback in paused state.
|
||||
"""
|
||||
self._done_callback = done_callback
|
||||
self._mpv.pause = stop_first
|
||||
with self._state_lock:
|
||||
self._set_state(PlayerState.LOADING)
|
||||
self._mpv.play(url)
|
||||
|
||||
def pause(self) -> None:
|
||||
"""Pause playback."""
|
||||
with self._state_lock:
|
||||
self._mpv.pause = True
|
||||
self._set_state(PlayerState.PAUSED)
|
||||
|
||||
def resume(self) -> None:
|
||||
"""Resume playback if paused."""
|
||||
with self._state_lock:
|
||||
self._mpv.pause = False
|
||||
self._set_state(PlayerState.PLAYING)
|
||||
|
||||
def stop(self, for_replacement: bool = False) -> None:
|
||||
"""
|
||||
Stop playback.
|
||||
|
||||
If called for track replacement, suppresses end-of-playback handling
|
||||
(state transition to IDLE and done callback) to allow seamless
|
||||
playback transitions.
|
||||
"""
|
||||
with self._state_lock:
|
||||
if for_replacement:
|
||||
self._suppress_end_event = True
|
||||
self._mpv.stop()
|
||||
|
||||
def state(self) -> PlayerState:
|
||||
"""Return the current player state."""
|
||||
with self._state_lock:
|
||||
return self._state
|
||||
|
||||
# -------- Volume / Ducking --------
|
||||
|
||||
def set_volume(self, volume: float) -> None:
|
||||
"""
|
||||
Set user volume.
|
||||
|
||||
Args:
|
||||
volume: Volume level (0.0–100.0).
|
||||
"""
|
||||
with self._state_lock:
|
||||
self._user_volume = max(0.0, min(100.0, float(volume)))
|
||||
self._apply_volume()
|
||||
|
||||
def duck(self, factor: float = 0.5) -> None:
|
||||
"""
|
||||
Reduce volume temporarily by a ducking factor.
|
||||
|
||||
Args:
|
||||
factor: Ducking factor (0.0–1.0).
|
||||
"""
|
||||
with self._state_lock:
|
||||
self._duck_factor = max(0.0, min(1.0, float(factor)))
|
||||
self._apply_volume()
|
||||
|
||||
def unduck(self) -> None:
|
||||
"""Restore volume to the user-defined level."""
|
||||
with self._state_lock:
|
||||
self._duck_factor = 1.0
|
||||
self._apply_volume()
|
||||
|
||||
# -------- Internal helpers --------
|
||||
|
||||
def _apply_volume(self) -> None:
|
||||
"""Apply effective volume (user volume × duck factor) to mpv."""
|
||||
effective = self._user_volume * self._duck_factor
|
||||
self._mpv.volume = max(0.0, min(100.0, effective))
|
||||
|
||||
def _on_end_file(self, event) -> None:
|
||||
callback: Optional[Callable[[], None]] = None
|
||||
|
||||
with self._state_lock:
|
||||
if self._suppress_end_event:
|
||||
self._suppress_end_event = False
|
||||
return
|
||||
|
||||
self._set_state(PlayerState.IDLE)
|
||||
callback = self._done_callback
|
||||
self._done_callback = None
|
||||
|
||||
if callback is not None:
|
||||
try:
|
||||
callback()
|
||||
except RuntimeError:
|
||||
# Callback errors must never break the player
|
||||
pass
|
||||
|
||||
def _on_mpv_log(self, level: str, prefix: str, text: str) -> None:
|
||||
"""
|
||||
Handle mpv log messages.
|
||||
|
||||
Error and fatal messages transition the player into ERROR state.
|
||||
"""
|
||||
if level in ("error", "fatal"):
|
||||
with self._state_lock:
|
||||
self._set_state(PlayerState.ERROR)
|
||||
|
||||
def _set_state(self, new_state: PlayerState) -> None:
|
||||
"""Update internal player state."""
|
||||
self._state = new_state
|
||||
@@ -0,0 +1,10 @@
|
||||
from enum import Enum, auto
|
||||
|
||||
|
||||
class PlayerState(Enum):
|
||||
IDLE = auto()
|
||||
LOADING = auto()
|
||||
PLAYING = auto()
|
||||
PAUSED = auto()
|
||||
STOPPING = auto()
|
||||
ERROR = auto()
|
||||
@@ -10,7 +10,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
try:
|
||||
from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf
|
||||
except ImportError:
|
||||
_LOGGER.fatal("pip install zeroconf")
|
||||
_LOGGER.fatal("zeroconf not installed. Please install it with: pip install zeroconf")
|
||||
raise
|
||||
|
||||
MDNS_TARGET_IP = "224.0.0.251"
|
||||
|
||||
Reference in new issue
Block a user