Include all files
This commit is contained in:
+100
-51
@@ -1,66 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import asyncio
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Optional, List, Union, Dict
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from google.protobuf import message
|
||||
# pylint: disable=no-name-in-module
|
||||
import sounddevice as sd
|
||||
from aioesphomeapi.api_pb2 import (
|
||||
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
||||
DeviceInfoRequest,
|
||||
DeviceInfoResponse,
|
||||
ListEntitiesRequest,
|
||||
ListEntitiesDoneResponse,
|
||||
VoiceAssistantConfigurationRequest,
|
||||
VoiceAssistantConfigurationResponse,
|
||||
VoiceAssistantWakeWord,
|
||||
SubscribeVoiceAssistantRequest,
|
||||
VoiceAssistantEventResponse,
|
||||
VoiceAssistantRequest,
|
||||
VoiceAssistantAudio,
|
||||
ListEntitiesMediaPlayerResponse,
|
||||
MediaPlayerStateResponse,
|
||||
ListEntitiesRequest,
|
||||
MediaPlayerCommandRequest,
|
||||
SubscribeHomeAssistantStatesRequest,
|
||||
VoiceAssistantAnnounceFinished,
|
||||
VoiceAssistantAnnounceRequest,
|
||||
VoiceAssistantAudio,
|
||||
VoiceAssistantConfigurationRequest,
|
||||
VoiceAssistantConfigurationResponse,
|
||||
VoiceAssistantEventResponse,
|
||||
VoiceAssistantRequest,
|
||||
VoiceAssistantTimerEventResponse,
|
||||
VoiceAssistantWakeWord,
|
||||
)
|
||||
from aioesphomeapi.model import (
|
||||
VoiceAssistantFeature,
|
||||
VoiceAssistantEventType,
|
||||
VoiceAssistantFeature,
|
||||
VoiceAssistantTimerEventType,
|
||||
)
|
||||
from google.protobuf import message
|
||||
|
||||
from .api_server import APIServer
|
||||
from .microwakeword import MicroWakeWord
|
||||
from .entity import ESPHomeEntity, MediaPlayerEntity
|
||||
from .util import get_mac, call_all
|
||||
from .microwakeword import MicroWakeWord
|
||||
from .mpv_player import MpvMediaPlayer
|
||||
from .util import call_all, get_mac, is_arm
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_MODULE_DIR = Path(__file__).parent
|
||||
_REPO_DIR = _MODULE_DIR.parent
|
||||
_WAKEWORDS_DIR = _REPO_DIR / "wakewords"
|
||||
_SOUNDS_DIR = _REPO_DIR / "sounds"
|
||||
|
||||
|
||||
class Sound(Enum):
|
||||
WAKEUP = auto()
|
||||
TIMER_FINISHED = auto()
|
||||
|
||||
|
||||
_DIR = Path(__file__).parent
|
||||
_SOUNDS_DIR = _DIR / "sounds"
|
||||
|
||||
DEFAULT_SOUNDS: Dict[Sound, Path] = {
|
||||
Sound.WAKEUP: _SOUNDS_DIR / "wake_word_triggered.flac",
|
||||
Sound.TIMER_FINISHED: _SOUNDS_DIR / "timer_finished.flac",
|
||||
}
|
||||
if is_arm():
|
||||
_LIB_DIR = _REPO_DIR / "lib" / "linux_arm64"
|
||||
else:
|
||||
_LIB_DIR = _REPO_DIR / "lib" / "linux_amd64"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -73,10 +65,15 @@ class ServerState:
|
||||
stop_word: MicroWakeWord
|
||||
music_player: MpvMediaPlayer
|
||||
tts_player: MpvMediaPlayer
|
||||
wakeup_sound: str
|
||||
timer_finished_sound: str
|
||||
media_player_entity: Optional[MediaPlayerEntity] = None
|
||||
satellite: "Optional[VoiceSatelliteProtocol]" = None
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class VoiceSatelliteProtocol(APIServer):
|
||||
|
||||
def __init__(self, state: ServerState) -> None:
|
||||
@@ -105,7 +102,7 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
def handle_voice_event(
|
||||
self, event_type: VoiceAssistantEventType, data: Dict[str, str]
|
||||
) -> None:
|
||||
_LOGGER.debug("%s: %s", event_type.name, data)
|
||||
_LOGGER.debug("Voice event: type=%s, data=%s", event_type.name, data)
|
||||
|
||||
if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START:
|
||||
self._tts_url = data.get("url")
|
||||
@@ -122,7 +119,7 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
self.play_tts()
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END:
|
||||
if data.get("continue_conversation") == "1":
|
||||
self._continue_conversation
|
||||
self._continue_conversation = True
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END:
|
||||
self._tts_url = data.get("url")
|
||||
self.play_tts()
|
||||
@@ -131,6 +128,8 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
if not self._tts_played:
|
||||
self._tts_finished()
|
||||
|
||||
self._tts_played = False
|
||||
|
||||
# TODO: handle error
|
||||
|
||||
def handle_timer_event(
|
||||
@@ -138,6 +137,7 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
event_type: VoiceAssistantTimerEventType,
|
||||
msg: VoiceAssistantTimerEventResponse,
|
||||
) -> None:
|
||||
_LOGGER.debug("Timer event: type=%s", event_type.name)
|
||||
if event_type == VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_FINISHED:
|
||||
if not self._timer_finished:
|
||||
self.state.stop_word.is_active = True
|
||||
@@ -211,7 +211,7 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
active_wake_words=[self.state.wake_word.id],
|
||||
max_active_wake_words=1,
|
||||
)
|
||||
_LOGGER.debug("Registered voice assistant")
|
||||
_LOGGER.info("Connected to Home Assistant")
|
||||
|
||||
def handle_audio(self, audio_chunk: bytes) -> None:
|
||||
|
||||
@@ -224,6 +224,8 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
if self._timer_finished:
|
||||
# Stop timer instead
|
||||
self._timer_finished = False
|
||||
self.state.tts_player.stop()
|
||||
_LOGGER.debug("Stopping timer finished sound")
|
||||
return
|
||||
|
||||
wake_word_phrase = self.state.wake_word.wake_word
|
||||
@@ -233,17 +235,17 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
)
|
||||
self.duck()
|
||||
self._is_streaming_audio = True
|
||||
self.state.tts_player.play(str(DEFAULT_SOUNDS[Sound.WAKEUP]))
|
||||
self.state.tts_player.play(self.state.wakeup_sound)
|
||||
|
||||
def stop(self) -> None:
|
||||
self.state.stop_word.is_active = False
|
||||
self.state.tts_player.stop()
|
||||
|
||||
if self._timer_finished:
|
||||
self._timer_finished = False
|
||||
self.state.tts_player.stop()
|
||||
_LOGGER.debug("Stopping timer finished sound")
|
||||
else:
|
||||
_LOGGER.debug("TTS response stopped manually")
|
||||
self.state.tts_player.stop()
|
||||
self._tts_finished()
|
||||
|
||||
def play_tts(self) -> None:
|
||||
@@ -283,12 +285,16 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
return
|
||||
|
||||
self.state.tts_player.play(
|
||||
str(DEFAULT_SOUNDS[Sound.TIMER_FINISHED]),
|
||||
self.state.timer_finished_sound,
|
||||
done_callback=lambda: call_all(
|
||||
lambda: time.sleep(1.0), self._play_timer_finished
|
||||
),
|
||||
)
|
||||
|
||||
def connection_lost(self, exc):
|
||||
super().connection_lost(exc)
|
||||
_LOGGER.info("Disconnected from Home Assistant")
|
||||
|
||||
|
||||
def process_audio(state: ServerState):
|
||||
|
||||
@@ -313,30 +319,72 @@ def process_audio(state: ServerState):
|
||||
audio_chunk
|
||||
):
|
||||
state.satellite.stop()
|
||||
except:
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error handling audio")
|
||||
|
||||
except:
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error processing audio")
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--audio-input-device", default="default")
|
||||
parser.add_argument("--audio-output-device")
|
||||
parser.add_argument("--name", required=True)
|
||||
parser.add_argument(
|
||||
"--audio-input-device",
|
||||
default="default",
|
||||
help="sounddevice name for input device",
|
||||
)
|
||||
parser.add_argument("--audio-input-block-size", type=int, default=1024)
|
||||
parser.add_argument("--audio-output-device", help="mpv name for output device")
|
||||
parser.add_argument(
|
||||
"--wake-model",
|
||||
default=_WAKEWORDS_DIR / "okay_nabu.json",
|
||||
help="Path to microWakeWord model config for wake word",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stop-model",
|
||||
default=_WAKEWORDS_DIR / "stop.json",
|
||||
help="Path to microWakeWord model config for stop word",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--wakeup-sound", default=str(_SOUNDS_DIR / "wake_word_triggered.flac")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timer-finished-sound", default=str(_SOUNDS_DIR / "timer_finished.flac")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
default="0.0.0.0",
|
||||
help="Address for ESPHome server (default: 0.0.0.0)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port", type=int, default=6053, help="Port for ESPHome server (default: 6053)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--debug", action="store_true", help="Print DEBUG messages to console"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
|
||||
_LOGGER.debug(args)
|
||||
|
||||
libtensorflowlite_c_path = _LIB_DIR / "libtensorflowlite_c.so"
|
||||
_LOGGER.debug("libtensorflowlite_c path: %s", libtensorflowlite_c_path)
|
||||
|
||||
state = ServerState(
|
||||
name="test",
|
||||
name=args.name,
|
||||
mac_address=get_mac(),
|
||||
audio_queue=Queue(),
|
||||
entities=[],
|
||||
wake_word=MicroWakeWord.from_config("wakewords/okay_nabu.json"),
|
||||
stop_word=MicroWakeWord.from_config("wakewords/stop.json"),
|
||||
wake_word=MicroWakeWord.from_config(args.wake_model, libtensorflowlite_c_path),
|
||||
stop_word=MicroWakeWord.from_config(args.stop_model, libtensorflowlite_c_path),
|
||||
music_player=MpvMediaPlayer(device=args.audio_output_device),
|
||||
tts_player=MpvMediaPlayer(device=args.audio_output_device),
|
||||
wakeup_sound=args.wakeup_sound,
|
||||
timer_finished_sound=args.timer_finished_sound,
|
||||
)
|
||||
|
||||
process_audio_thread = threading.Thread(
|
||||
@@ -344,25 +392,26 @@ async def main() -> None:
|
||||
)
|
||||
process_audio_thread.start()
|
||||
|
||||
def sd_callback(indata, frames, time, status):
|
||||
def sd_callback(indata, _frames, _time, _status):
|
||||
state.audio_queue.put_nowait(bytes(indata))
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
server = await loop.create_server(
|
||||
lambda: VoiceSatelliteProtocol(state), host="0.0.0.0", port=6053
|
||||
lambda: VoiceSatelliteProtocol(state), host=args.host, port=args.port
|
||||
)
|
||||
|
||||
try:
|
||||
_LOGGER.debug("Opening audio input device: %s", args.audio_input_device)
|
||||
with sd.RawInputStream(
|
||||
samplerate=16000,
|
||||
blocksize=1024,
|
||||
blocksize=args.audio_input_block_size,
|
||||
device=args.audio_input_device,
|
||||
dtype="int16",
|
||||
channels=1,
|
||||
callback=sd_callback,
|
||||
):
|
||||
async with server:
|
||||
_LOGGER.info("Server started")
|
||||
_LOGGER.info("Server started (host=%s, port=%s)", args.host, args.port)
|
||||
await server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
+19
-20
@@ -1,25 +1,25 @@
|
||||
"""Partial ESPHome server implementation."""
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import Optional, TYPE_CHECKING, List, Union
|
||||
|
||||
|
||||
import logging
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Iterable
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
# pylint: disable=no-name-in-module
|
||||
from aioesphomeapi._frame_helper.packets import make_plain_text_packets
|
||||
from google.protobuf import message
|
||||
from aioesphomeapi.core import MESSAGE_TYPE_TO_PROTO
|
||||
from aioesphomeapi.api_pb2 import (
|
||||
HelloRequest,
|
||||
HelloResponse,
|
||||
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
||||
ConnectRequest,
|
||||
ConnectResponse,
|
||||
DisconnectRequest,
|
||||
DisconnectResponse,
|
||||
HelloRequest,
|
||||
HelloResponse,
|
||||
PingRequest,
|
||||
PingResponse,
|
||||
)
|
||||
from aioesphomeapi.core import MESSAGE_TYPE_TO_PROTO
|
||||
from google.protobuf import message
|
||||
|
||||
PROTO_TO_MESSAGE_TYPE = {v: k for k, v in MESSAGE_TYPE_TO_PROTO.items()}
|
||||
|
||||
@@ -62,10 +62,10 @@ class APIServer(asyncio.Protocol):
|
||||
elif isinstance(msg_inst, DisconnectRequest):
|
||||
self.send_messages([DisconnectResponse()])
|
||||
_LOGGER.debug("Disconnect requested")
|
||||
if self.transport:
|
||||
self.transport.close()
|
||||
self.transport = None
|
||||
self.writelines = None
|
||||
if self._transport:
|
||||
self._transport.close()
|
||||
self._transport = None
|
||||
self._writelines = None
|
||||
elif isinstance(msg_inst, PingRequest):
|
||||
self.send_messages([PingResponse()])
|
||||
elif msgs := self.handle_message(msg_inst):
|
||||
@@ -75,7 +75,7 @@ class APIServer(asyncio.Protocol):
|
||||
self.send_messages(msgs)
|
||||
|
||||
def send_messages(self, msgs: List[message.Message]):
|
||||
if self.writelines is None:
|
||||
if self._writelines is None:
|
||||
return
|
||||
|
||||
packets = [
|
||||
@@ -83,11 +83,11 @@ class APIServer(asyncio.Protocol):
|
||||
for msg in msgs
|
||||
]
|
||||
packet_bytes = make_plain_text_packets(packets)
|
||||
self.writelines(packet_bytes)
|
||||
self._writelines(packet_bytes)
|
||||
|
||||
def connection_made(self, transport) -> None:
|
||||
self.transport = transport
|
||||
self.writelines = self.transport.writelines
|
||||
self._transport = transport
|
||||
self._writelines = transport.writelines
|
||||
|
||||
def data_received(self, data: bytes):
|
||||
if self._buffer is None:
|
||||
@@ -139,9 +139,8 @@ class APIServer(asyncio.Protocol):
|
||||
return cstr[original_pos:new_pos]
|
||||
|
||||
def connection_lost(self, exc):
|
||||
self.transport = None
|
||||
self.writelines = None
|
||||
_LOGGER.debug("Connection closed")
|
||||
self._transport = None
|
||||
self._writelines = None
|
||||
|
||||
def _read_varuint(self) -> int:
|
||||
"""Read a varuint from the buffer or -1 if the buffer runs out of bytes."""
|
||||
|
||||
+7
-7
@@ -1,17 +1,17 @@
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Iterable
|
||||
from typing import Callable, Optional, List, Union
|
||||
from typing import Callable, List, Optional, Union
|
||||
|
||||
from aioesphomeapi import MediaPlayerState
|
||||
from google.protobuf import message
|
||||
from aioesphomeapi.api_pb2 import (
|
||||
ListEntitiesRequest,
|
||||
MediaPlayerStateResponse,
|
||||
# pylint: disable=no-name-in-module
|
||||
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
||||
ListEntitiesMediaPlayerResponse,
|
||||
SubscribeHomeAssistantStatesRequest,
|
||||
ListEntitiesRequest,
|
||||
MediaPlayerCommandRequest,
|
||||
MediaPlayerStateResponse,
|
||||
SubscribeHomeAssistantStatesRequest,
|
||||
)
|
||||
from aioesphomeapi.model import MediaPlayerCommand, MediaPlayerState
|
||||
from google.protobuf import message
|
||||
|
||||
from .api_server import APIServer
|
||||
from .mpv_player import MpvMediaPlayer
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import ctypes
|
||||
import json
|
||||
import numpy as np
|
||||
import statistics
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
from typing import Union, List, Deque
|
||||
from typing import Deque, List, Union
|
||||
|
||||
import numpy as np
|
||||
from pymicro_features import MicroFrontend
|
||||
|
||||
_SAMPLES_PER_SECOND = 16000
|
||||
@@ -24,13 +24,14 @@ class TfLiteQuantizationParams(ctypes.Structure):
|
||||
class MicroWakeWord:
|
||||
def __init__(
|
||||
self,
|
||||
id: str,
|
||||
id: str, # pylint: disable=redefined-builtin
|
||||
wake_word: str,
|
||||
tflite_model: Union[str, Path],
|
||||
probability_cutoff: float,
|
||||
sliding_window_size: int,
|
||||
refractory_seconds: float,
|
||||
trained_languages: List[str],
|
||||
libtensorflowlite_c_path: Union[str, Path],
|
||||
):
|
||||
self.id = id
|
||||
self.wake_word = wake_word
|
||||
@@ -44,7 +45,7 @@ class MicroWakeWord:
|
||||
|
||||
# Load the shared library
|
||||
self.lib = ctypes.cdll.LoadLibrary(
|
||||
str(Path("libtensorflowlite_c.so").resolve())
|
||||
str(Path(libtensorflowlite_c_path).resolve())
|
||||
)
|
||||
|
||||
# Define required argument/return types for C API
|
||||
@@ -153,6 +154,7 @@ class MicroWakeWord:
|
||||
@staticmethod
|
||||
def from_config(
|
||||
config_path: Union[str, Path],
|
||||
libtensorflowlite_c_path: Union[str, Path],
|
||||
refractory_seconds: float = _DEFAULT_REFRACTORY,
|
||||
) -> "MicroWakeWord":
|
||||
"""Load a microWakeWord model from a JSON config file.
|
||||
@@ -178,6 +180,7 @@ class MicroWakeWord:
|
||||
sliding_window_size=micro_config["sliding_window_size"],
|
||||
refractory_seconds=refractory_seconds,
|
||||
trained_languages=micro_config.get("trained_languages", []),
|
||||
libtensorflowlite_c_path=libtensorflowlite_c_path,
|
||||
)
|
||||
|
||||
def process_streaming(self, audio_bytes: bytes) -> bool:
|
||||
|
||||
+11
-8
@@ -2,9 +2,8 @@
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Optional, Union, List, Any
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from mpv import MPV
|
||||
|
||||
@@ -24,8 +23,8 @@ class MpvMediaPlayer:
|
||||
self._done_callback: Optional[Callable[[], None]] = None
|
||||
self._done_callback_lock = Lock()
|
||||
|
||||
self._duck_volume:int = 50
|
||||
self._unduck_volume:int = 100
|
||||
self._duck_volume: int = 50
|
||||
self._unduck_volume: int = 100
|
||||
|
||||
self.player.event_callback("end-file")(self._on_end_file)
|
||||
|
||||
@@ -33,6 +32,7 @@ class MpvMediaPlayer:
|
||||
self,
|
||||
url: Union[str, List[str]],
|
||||
done_callback: Optional[Callable[[], None]] = None,
|
||||
stop_first: bool = True,
|
||||
) -> None:
|
||||
self.stop()
|
||||
|
||||
@@ -41,9 +41,12 @@ class MpvMediaPlayer:
|
||||
else:
|
||||
self._playlist = url
|
||||
|
||||
next_url = self._playlist.pop(0)
|
||||
_LOGGER.debug("Playing %s", next_url)
|
||||
|
||||
self._done_callback = done_callback
|
||||
self.is_playing = True
|
||||
self.player.play(self._playlist.pop(0))
|
||||
self.player.play(next_url)
|
||||
|
||||
def pause(self) -> None:
|
||||
self.player.pause = True
|
||||
@@ -71,7 +74,7 @@ class MpvMediaPlayer:
|
||||
self._unduck_volume = volume
|
||||
self._duck_volume = volume // 2
|
||||
|
||||
def _on_end_file(self, event):
|
||||
def _on_end_file(self, event) -> None:
|
||||
if self._playlist:
|
||||
self.player.play(self._playlist.pop(0))
|
||||
return
|
||||
@@ -87,5 +90,5 @@ class MpvMediaPlayer:
|
||||
if todo_callback:
|
||||
try:
|
||||
todo_callback()
|
||||
except:
|
||||
pass
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error running done callback")
|
||||
|
||||
+7
-1
@@ -1,7 +1,8 @@
|
||||
"""Utility methods."""
|
||||
|
||||
from collections.abc import Callable
|
||||
import platform
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@@ -14,3 +15,8 @@ def get_mac() -> str:
|
||||
def call_all(*callables: Optional[Callable[[], None]]) -> None:
|
||||
for item in filter(None, callables):
|
||||
item()
|
||||
|
||||
|
||||
def is_arm() -> bool:
|
||||
machine = platform.machine()
|
||||
return ("arm" in machine) or ("aarch" in machine)
|
||||
|
||||
Reference in New Issue
Block a user