diff --git a/hav_sat/__main__.py b/hav_sat/__main__.py index 9faa68c..b3631fd 100644 --- a/hav_sat/__main__.py +++ b/hav_sat/__main__.py @@ -72,7 +72,6 @@ class ServerState: wake_word: MicroWakeWord stop_word: MicroWakeWord music_player: MpvMediaPlayer - announce_player: MpvMediaPlayer tts_player: MpvMediaPlayer media_player_entity: Optional[MediaPlayerEntity] = None satellite: "Optional[VoiceSatelliteProtocol]" = None @@ -93,7 +92,7 @@ class VoiceSatelliteProtocol(APIServer): name="Media Player", object_id="hav_sat_media_player", music_player=state.music_player, - announce_player=state.announce_player, + announce_player=state.tts_player, ) self.state.entities.append(self.state.media_player_entity) @@ -143,6 +142,7 @@ class VoiceSatelliteProtocol(APIServer): if not self._timer_finished: self.state.stop_word.is_active = True self._timer_finished = True + self.duck() self._play_timer_finished() def handle_message(self, msg: message.Message) -> Iterable[message.Message]: @@ -241,11 +241,9 @@ class VoiceSatelliteProtocol(APIServer): if self._timer_finished: self._timer_finished = False self.state.tts_player.stop() - self.state.music_player.resume() else: _LOGGER.debug("TTS response stopped manually") self.state.tts_player.stop() - self.state.announce_player.stop() self._tts_finished() def play_tts(self) -> None: @@ -260,11 +258,11 @@ class VoiceSatelliteProtocol(APIServer): def duck(self) -> None: _LOGGER.debug("Ducking music") - self.state.music_player.set_volume(50) + self.state.music_player.duck() def unduck(self) -> None: _LOGGER.debug("Unducking music") - self.state.music_player.set_volume(100) + self.state.music_player.unduck() def _tts_finished(self) -> None: self.state.stop_word.is_active = False @@ -281,6 +279,7 @@ class VoiceSatelliteProtocol(APIServer): def _play_timer_finished(self) -> None: if not self._timer_finished: + self.unduck() return self.state.tts_player.play( @@ -337,7 +336,6 @@ async def main() -> None: wake_word=MicroWakeWord.from_config("wakewords/okay_nabu.json"), stop_word=MicroWakeWord.from_config("wakewords/stop.json"), music_player=MpvMediaPlayer(device=args.audio_output_device), - announce_player=MpvMediaPlayer(device=args.audio_output_device), tts_player=MpvMediaPlayer(device=args.audio_output_device), ) @@ -349,12 +347,9 @@ async def main() -> None: def sd_callback(indata, frames, time, status): state.audio_queue.put_nowait(bytes(indata)) - # for idx, dev in enumerate(sd.query_devices()): - # _LOGGER.debug(f"{idx}: {dev['name']}") - loop = asyncio.get_running_loop() server = await loop.create_server( - lambda: VoiceSatelliteProtocol(state), host="127.0.0.1", port=6053 + lambda: VoiceSatelliteProtocol(state), host="0.0.0.0", port=6053 ) try: diff --git a/hav_sat/entity.py b/hav_sat/entity.py index 2788512..4240241 100644 --- a/hav_sat/entity.py +++ b/hav_sat/entity.py @@ -99,16 +99,16 @@ class MediaPlayerEntity(ESPHomeEntity): yield from self.play(msg.media_url, announcement=announcement) elif msg.has_command: if msg.command == MediaPlayerCommand.PAUSE: - if self.music_player.is_playing: - self.music_player.pause() - yield self._update_state(MediaPlayerState.PAUSED) + self.music_player.pause() + yield self._update_state(MediaPlayerState.PAUSED) elif msg.command == MediaPlayerCommand.PLAY: - if not self.music_player.is_playing: - self.player.resume() - yield self._update_state(MediaPlayerState.PLAYING) - # TODO - # elif msg.has_volume: - # self.player.set_volume() + self.music_player.resume() + yield self._update_state(MediaPlayerState.PLAYING) + elif msg.has_volume: + volume = int(msg.volume * 100) + self.music_player.set_volume(volume) + self.announce_player.set_volume(volume) + self.volume = msg.volume elif isinstance(msg, ListEntitiesRequest): yield ListEntitiesMediaPlayerResponse( object_id=self.object_id, diff --git a/hav_sat/mpv_player.py b/hav_sat/mpv_player.py index 551f0c0..5eb5288 100644 --- a/hav_sat/mpv_player.py +++ b/hav_sat/mpv_player.py @@ -1,174 +1,91 @@ """Media player using mpv in a subprocess.""" -import subprocess import logging -import socket -import os -import uuid -import threading -import json -import time -import tempfile from collections.abc import Callable from typing import Optional, Union, List, Any from pathlib import Path +from threading import Lock + +from mpv import MPV _LOGGER = logging.getLogger(__name__) class MpvMediaPlayer: - def __init__( - self, - device: Optional[str] = None, - socket_path: Optional[Union[str, Path]] = None, - ) -> None: - self.device = device + def __init__(self, device: Optional[str] = None) -> None: + self.player = MPV() + + if device: + self.player["audio-device"] = device + self.is_playing = False - if socket_path: - self._socket_path = str(Path(socket_path).resolve()) - else: - base_path = ( - Path(os.environ.get("XDG_RUNTIME_DIR") or "/dev/shm") / "mpv-sockets" - ) - base_path.mkdir(parents=True, exist_ok=True) - self._socket_path = str(base_path / f"mpv-{uuid.uuid4().hex}.sock") - - self._playlist_file = tempfile.NamedTemporaryFile( - "w+", suffix=".m3u8", encoding="utf-8" - ) - - cmd = [ - "mpv", - f"--input-ipc-server={self._socket_path}", - "--no-video", - "--quiet", - "--term-playing-msg=", - "--msg-level=all=no", - "--idle=yes", # keep alive - ] - if device: - cmd.append(f"--audio-device={device}") - - _LOGGER.debug(cmd) - - self._proc = subprocess.Popen(cmd) - self._socket_connected = False - + self._playlist: List[str] = [] self._done_callback: Optional[Callable[[], None]] = None + self._done_callback_lock = Lock() - threading.Thread(target=self._read_socket, daemon=True).start() + self._duck_volume:int = 50 + self._unduck_volume:int = 100 + + self.player.event_callback("end-file")(self._on_end_file) def play( self, url: Union[str, List[str]], done_callback: Optional[Callable[[], None]] = None, ) -> None: + self.stop() + if isinstance(url, str): - url = [url] + self._playlist = [url] + else: + self._playlist = url self._done_callback = done_callback self.is_playing = True - - if len(url) == 1: - self.send_command(["loadfile", url[0], "replace"]) - else: - self._playlist_file.seek(0) - for item in url: - print(item, file=self._playlist_file, flush=True) - - self.send_command(["loadlist", self._playlist_file.name, "replace"]) - - def stop(self) -> None: - self.send_command(["stop"]) - self.is_playing = False + self.player.play(self._playlist.pop(0)) def pause(self) -> None: - if not self.is_playing: - return - - self.send_command(["set_property", "pause", True]) + self.player.pause = True self.is_playing = False def resume(self) -> None: - if self.is_playing: - return + self.player.pause = False + if self._playlist: + self.is_playing = True - self.send_command(["set_property", "pause", False]) - self.is_playing = True + def stop(self) -> None: + self.player.stop() + self._playlist.clear() - def mute(self) -> None: - self.send_command(["set_property", "mute", True]) + def duck(self) -> None: + self.player.volume = self._duck_volume - def unmute(self) -> None: - self.send_command(["set_property", "mute", False]) + def unduck(self) -> None: + self.player.volume = self._unduck_volume def set_volume(self, volume: int) -> None: - self.send_command(["set_property", "volume", volume]) + volume = max(0, min(100, volume)) + self.player.volume = volume - def send_command(self, cmd: List[Any]): - if not self._socket_connected: - _LOGGER.warning("Socket not connected: %s", cmd) + self._unduck_volume = volume + self._duck_volume = volume // 2 + + def _on_end_file(self, event): + if self._playlist: + self.player.play(self._playlist.pop(0)) return - payload = json.dumps({"command": cmd}).encode("utf-8") + b"\n" + self.is_playing = False - with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as mpv_socket: - mpv_socket.connect(self._socket_path) - mpv_socket.sendall(payload) - - _LOGGER.debug("Sent command: %s", payload) - - def _read_socket(self): - # Connect to socket - with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as mpv_socket: - timeout = time.monotonic() + 5 - while time.monotonic() < timeout: - try: - mpv_socket.connect(self._socket_path) - self._socket_connected = True - break - except (ConnectionRefusedError, FileNotFoundError, OSError): - pass - - if not self._socket_connected: - _LOGGER.warning("Failed to connect to socket: %s", self._socket_path) - return - - _LOGGER.debug("Connected to socket: %s", self._socket_path) - - # Read from socket - mpv_buffer = bytes() + todo_callback: Optional[Callable[[], None]] = None + with self._done_callback_lock: + if self._done_callback: + todo_callback = self._done_callback + self._done_callback = None + if todo_callback: try: - while True: - chunk = mpv_socket.recv(4096) - if not chunk: - break - - mpv_buffer += chunk - while b"\n" in mpv_buffer: - line, mpv_buffer = mpv_buffer.split(b"\n", 1) - event = json.loads(line.decode()) - _LOGGER.debug("Received event: %s", event) - - event_type = event.get("event") - - if event_type == "start-file": - self.is_playing = True - continue - - if event_type != "end-file": - continue - - self.is_playing = False - - if not self._done_callback: - continue - - try: - self._done_callback() - except: - _LOGGER.exception("Unexpected error in callback") + todo_callback() except: - _LOGGER.exception("Unexpected error reading MPV socket") + pass