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