Files
linux-voice-assistant/linux_voice_assistant/player/libmpv.py
T
Florian Asche 1fc29241b4 Add linting scripts and pipeline (#201)
* Add linting scripts and pipeline
2026-03-05 01:58:39 +01:00

200 lines
6.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import logging
import threading
from typing import Callable, Optional
import mpv
from linux_voice_assistant.player.base import AudioPlayer
from linux_voice_assistant.player.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._log = logging.getLogger(self.__class__.__name__)
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._mpv.event_callback("end-file")(self._on_end_file)
self._mpv.event_callback("start-file")(self._on_start_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.
"""
with self._state_lock:
self._log.debug("play: current_state=%s", self._state)
self._done_callback = done_callback
self._set_state(PlayerState.LOADING)
self._mpv.pause = stop_first
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."""
self._log.debug("unduck() called")
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, clears the callback to prevent
it from being invoked during the transition.
"""
self._log.debug("unduck() called")
with self._state_lock:
if for_replacement:
# Clear callback to prevent invocation during track transition
self._done_callback = None
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).
"""
self._log.debug("unduck() called")
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).
"""
self._log.debug("unduck() called")
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."""
self._log.debug("unduck() called")
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."""
self._log.debug("unduck() called")
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:
# mpv events: event.data is a MpvEventEndFile object with a 'reason' attribute
# The reason is an integer constant (see mpv.END_FILE_REASON_*)
end_file_data = event.data
reason = getattr(end_file_data, "reason", -1) if end_file_data else -1
# mpv END_FILE_REASON constants:
# 0 = eof (end of file), 1 = stop, 2 = abort, 3 = quit, 4 = error
is_eof = reason == 0
self._log.debug(
"_on_end_file: reason=%s (is_eof=%s), state=%s, has_callback=%s",
reason,
is_eof,
self._state,
self._done_callback is not None,
)
# Only process "eof" (reason=0) events as actual track completion.
# Other reasons are from track changes, stops, or errors.
if not is_eof:
self._log.debug("_on_end_file: ignoring non-eof event (reason=%s)", reason)
return
self._set_state(PlayerState.IDLE)
callback = self._done_callback
self._done_callback = None
if callback is not None:
self._log.debug("_on_end_file: invoking callback")
try:
callback()
except RuntimeError:
# Callback errors must never break the player
pass
def _on_start_file(self, event) -> None:
"""Called when mpv starts playing a file."""
self._log.debug("unduck() called")
with self._state_lock:
self._log.debug("_on_start_file: state=%s", self._state)
self._set_state(PlayerState.PLAYING)
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