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 wake_word: MicroWakeWord
stop_word: MicroWakeWord stop_word: MicroWakeWord
music_player: MpvMediaPlayer music_player: MpvMediaPlayer
announce_player: MpvMediaPlayer
tts_player: MpvMediaPlayer tts_player: MpvMediaPlayer
media_player_entity: Optional[MediaPlayerEntity] = None media_player_entity: Optional[MediaPlayerEntity] = None
satellite: "Optional[VoiceSatelliteProtocol]" = None satellite: "Optional[VoiceSatelliteProtocol]" = None
@@ -93,7 +92,7 @@ class VoiceSatelliteProtocol(APIServer):
name="Media Player", name="Media Player",
object_id="hav_sat_media_player", object_id="hav_sat_media_player",
music_player=state.music_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) self.state.entities.append(self.state.media_player_entity)
@@ -143,6 +142,7 @@ class VoiceSatelliteProtocol(APIServer):
if not self._timer_finished: if not self._timer_finished:
self.state.stop_word.is_active = True self.state.stop_word.is_active = True
self._timer_finished = True self._timer_finished = True
self.duck()
self._play_timer_finished() self._play_timer_finished()
def handle_message(self, msg: message.Message) -> Iterable[message.Message]: def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
@@ -241,11 +241,9 @@ class VoiceSatelliteProtocol(APIServer):
if self._timer_finished: if self._timer_finished:
self._timer_finished = False self._timer_finished = False
self.state.tts_player.stop() self.state.tts_player.stop()
self.state.music_player.resume()
else: else:
_LOGGER.debug("TTS response stopped manually") _LOGGER.debug("TTS response stopped manually")
self.state.tts_player.stop() self.state.tts_player.stop()
self.state.announce_player.stop()
self._tts_finished() self._tts_finished()
def play_tts(self) -> None: def play_tts(self) -> None:
@@ -260,11 +258,11 @@ class VoiceSatelliteProtocol(APIServer):
def duck(self) -> None: def duck(self) -> None:
_LOGGER.debug("Ducking music") _LOGGER.debug("Ducking music")
self.state.music_player.set_volume(50) self.state.music_player.duck()
def unduck(self) -> None: def unduck(self) -> None:
_LOGGER.debug("Unducking music") _LOGGER.debug("Unducking music")
self.state.music_player.set_volume(100) self.state.music_player.unduck()
def _tts_finished(self) -> None: def _tts_finished(self) -> None:
self.state.stop_word.is_active = False self.state.stop_word.is_active = False
@@ -281,6 +279,7 @@ class VoiceSatelliteProtocol(APIServer):
def _play_timer_finished(self) -> None: def _play_timer_finished(self) -> None:
if not self._timer_finished: if not self._timer_finished:
self.unduck()
return return
self.state.tts_player.play( self.state.tts_player.play(
@@ -337,7 +336,6 @@ async def main() -> None:
wake_word=MicroWakeWord.from_config("wakewords/okay_nabu.json"), wake_word=MicroWakeWord.from_config("wakewords/okay_nabu.json"),
stop_word=MicroWakeWord.from_config("wakewords/stop.json"), stop_word=MicroWakeWord.from_config("wakewords/stop.json"),
music_player=MpvMediaPlayer(device=args.audio_output_device), music_player=MpvMediaPlayer(device=args.audio_output_device),
announce_player=MpvMediaPlayer(device=args.audio_output_device),
tts_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): def sd_callback(indata, frames, time, status):
state.audio_queue.put_nowait(bytes(indata)) 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() loop = asyncio.get_running_loop()
server = await loop.create_server( 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: try:
+9 -9
View File
@@ -99,16 +99,16 @@ class MediaPlayerEntity(ESPHomeEntity):
yield from self.play(msg.media_url, announcement=announcement) yield from self.play(msg.media_url, announcement=announcement)
elif msg.has_command: elif msg.has_command:
if msg.command == MediaPlayerCommand.PAUSE: if msg.command == MediaPlayerCommand.PAUSE:
if self.music_player.is_playing: self.music_player.pause()
self.music_player.pause() yield self._update_state(MediaPlayerState.PAUSED)
yield self._update_state(MediaPlayerState.PAUSED)
elif msg.command == MediaPlayerCommand.PLAY: elif msg.command == MediaPlayerCommand.PLAY:
if not self.music_player.is_playing: self.music_player.resume()
self.player.resume() yield self._update_state(MediaPlayerState.PLAYING)
yield self._update_state(MediaPlayerState.PLAYING) elif msg.has_volume:
# TODO volume = int(msg.volume * 100)
# elif msg.has_volume: self.music_player.set_volume(volume)
# self.player.set_volume() self.announce_player.set_volume(volume)
self.volume = msg.volume
elif isinstance(msg, ListEntitiesRequest): elif isinstance(msg, ListEntitiesRequest):
yield ListEntitiesMediaPlayerResponse( yield ListEntitiesMediaPlayerResponse(
object_id=self.object_id, object_id=self.object_id,
+49 -132
View File
@@ -1,174 +1,91 @@
"""Media player using mpv in a subprocess.""" """Media player using mpv in a subprocess."""
import subprocess
import logging import logging
import socket
import os
import uuid
import threading
import json
import time
import tempfile
from collections.abc import Callable from collections.abc import Callable
from typing import Optional, Union, List, Any from typing import Optional, Union, List, Any
from pathlib import Path from pathlib import Path
from threading import Lock
from mpv import MPV
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class MpvMediaPlayer: class MpvMediaPlayer:
def __init__( def __init__(self, device: Optional[str] = None) -> None:
self, self.player = MPV()
device: Optional[str] = None,
socket_path: Optional[Union[str, Path]] = None, if device:
) -> None: self.player["audio-device"] = device
self.device = device
self.is_playing = False self.is_playing = False
if socket_path: self._playlist: List[str] = []
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._done_callback: Optional[Callable[[], None]] = None 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( def play(
self, self,
url: Union[str, List[str]], url: Union[str, List[str]],
done_callback: Optional[Callable[[], None]] = None, done_callback: Optional[Callable[[], None]] = None,
) -> None: ) -> None:
self.stop()
if isinstance(url, str): if isinstance(url, str):
url = [url] self._playlist = [url]
else:
self._playlist = url
self._done_callback = done_callback self._done_callback = done_callback
self.is_playing = True self.is_playing = True
self.player.play(self._playlist.pop(0))
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
def pause(self) -> None: def pause(self) -> None:
if not self.is_playing: self.player.pause = True
return
self.send_command(["set_property", "pause", True])
self.is_playing = False self.is_playing = False
def resume(self) -> None: def resume(self) -> None:
if self.is_playing: self.player.pause = False
return if self._playlist:
self.is_playing = True
self.send_command(["set_property", "pause", False]) def stop(self) -> None:
self.is_playing = True self.player.stop()
self._playlist.clear()
def mute(self) -> None: def duck(self) -> None:
self.send_command(["set_property", "mute", True]) self.player.volume = self._duck_volume
def unmute(self) -> None: def unduck(self) -> None:
self.send_command(["set_property", "mute", False]) self.player.volume = self._unduck_volume
def set_volume(self, volume: int) -> None: 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]): self._unduck_volume = volume
if not self._socket_connected: self._duck_volume = volume // 2
_LOGGER.warning("Socket not connected: %s", cmd)
def _on_end_file(self, event):
if self._playlist:
self.player.play(self._playlist.pop(0))
return 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: todo_callback: Optional[Callable[[], None]] = None
mpv_socket.connect(self._socket_path) with self._done_callback_lock:
mpv_socket.sendall(payload) if self._done_callback:
todo_callback = self._done_callback
_LOGGER.debug("Sent command: %s", payload) self._done_callback = None
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()
if todo_callback:
try: try:
while True: todo_callback()
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")
except: except:
_LOGGER.exception("Unexpected error reading MPV socket") pass