Include all files

This commit is contained in:
Michael Hansen
2025-08-06 10:50:34 -05:00
parent 20419787cf
commit 15e0b4be80
46 changed files with 815 additions and 91 deletions
+100 -51
View File
@@ -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
View File
@@ -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
View File
@@ -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
+7 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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)