First working version

This commit is contained in:
Michael Hansen
2025-08-05 16:16:41 -05:00
parent 469ce9c3e8
commit 20419787cf
3 changed files with 64 additions and 152 deletions
+6 -11
View File
@@ -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:
+9 -9
View File
@@ -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,
+49 -132
View File
@@ -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