First working version
This commit is contained in:
+6
-11
@@ -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:
|
||||||
|
|||||||
+6
-6
@@ -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)
|
||||||
# TODO
|
elif msg.has_volume:
|
||||||
# elif msg.has_volume:
|
volume = int(msg.volume * 100)
|
||||||
# self.player.set_volume()
|
self.music_player.set_volume(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,
|
||||||
|
|||||||
+48
-131
@@ -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.send_command(["set_property", "pause", False])
|
|
||||||
self.is_playing = True
|
self.is_playing = True
|
||||||
|
|
||||||
def mute(self) -> None:
|
def stop(self) -> None:
|
||||||
self.send_command(["set_property", "mute", True])
|
self.player.stop()
|
||||||
|
self._playlist.clear()
|
||||||
|
|
||||||
def unmute(self) -> None:
|
def duck(self) -> None:
|
||||||
self.send_command(["set_property", "mute", False])
|
self.player.volume = self._duck_volume
|
||||||
|
|
||||||
|
def unduck(self) -> None:
|
||||||
|
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"
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
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
|
self.is_playing = False
|
||||||
|
|
||||||
if not self._done_callback:
|
todo_callback: Optional[Callable[[], None]] = None
|
||||||
continue
|
with self._done_callback_lock:
|
||||||
|
if self._done_callback:
|
||||||
|
todo_callback = self._done_callback
|
||||||
|
self._done_callback = None
|
||||||
|
|
||||||
|
if todo_callback:
|
||||||
try:
|
try:
|
||||||
self._done_callback()
|
todo_callback()
|
||||||
except:
|
except:
|
||||||
_LOGGER.exception("Unexpected error in callback")
|
pass
|
||||||
except:
|
|
||||||
_LOGGER.exception("Unexpected error reading MPV socket")
|
|
||||||
|
|||||||
Reference in New Issue
Block a user