diff --git a/CHANGELOG.md b/CHANGELOG.md
index 59a90bb..895588a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unreleased
+- Add support for custom/external wake words
+- Add `--download-dir
` to store downloaded wake word models/configs
- Switch to `soundcard` instead of `sounddevice`
- Add `--list-input-devices` and `--list-output-devices`
- Use `pymicro-wakeword` for microWakeWord
diff --git a/linux_voice_assistant/__main__.py b/linux_voice_assistant/__main__.py
index f267493..c1cc12f 100644
--- a/linux_voice_assistant/__main__.py
+++ b/linux_voice_assistant/__main__.py
@@ -63,6 +63,11 @@ async def main() -> None:
"--wake-model", default="okay_nabu", help="Id of active wake model"
)
parser.add_argument("--stop-model", default="stop", help="Id of stop model")
+ parser.add_argument(
+ "--download-dir",
+ default=_REPO_DIR / "local",
+ help="Directory to download custom wake word models, etc.",
+ )
parser.add_argument(
"--refractory-seconds",
default=2.0,
@@ -120,6 +125,9 @@ async def main() -> None:
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
_LOGGER.debug(args)
+ args.download_dir = Path(args.download_dir)
+ args.download_dir.mkdir(parents=True, exist_ok=True)
+
# Resolve microphone
if args.audio_input_device is not None:
try:
@@ -133,6 +141,7 @@ async def main() -> None:
# Load available wake words
wake_word_dirs = [Path(ww_dir) for ww_dir in args.wake_word_dir]
+ wake_word_dirs.append(args.download_dir / "external_wake_words")
available_wake_words: Dict[str, AvailableWakeWord] = {}
for wake_word_dir in wake_word_dirs:
@@ -227,6 +236,7 @@ async def main() -> None:
preferences=preferences,
preferences_path=preferences_path,
refractory_seconds=args.refractory_seconds,
+ download_dir=args.download_dir,
)
if args.enable_thinking_sound:
diff --git a/linux_voice_assistant/api_server.py b/linux_voice_assistant/api_server.py
index 3d3f58e..c0685c8 100644
--- a/linux_voice_assistant/api_server.py
+++ b/linux_voice_assistant/api_server.py
@@ -9,8 +9,8 @@ from typing import TYPE_CHECKING, List, Optional
# pylint: disable=no-name-in-module
from aioesphomeapi._frame_helper.packets import make_plain_text_packets
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
- ConnectRequest,
- ConnectResponse,
+ AuthenticationRequest,
+ AuthenticationResponse,
DisconnectRequest,
DisconnectResponse,
HelloRequest,
@@ -57,8 +57,8 @@ class APIServer(asyncio.Protocol):
)
return
- if isinstance(msg_inst, ConnectRequest):
- self.send_messages([ConnectResponse()])
+ if isinstance(msg_inst, AuthenticationRequest):
+ self.send_messages([AuthenticationResponse()])
elif isinstance(msg_inst, DisconnectRequest):
self.send_messages([DisconnectResponse()])
_LOGGER.debug("Disconnect requested")
diff --git a/linux_voice_assistant/models.py b/linux_voice_assistant/models.py
index 9210081..03c28ba 100644
--- a/linux_voice_assistant/models.py
+++ b/linux_voice_assistant/models.py
@@ -72,6 +72,7 @@ class ServerState:
timer_finished_sound: str
preferences: Preferences
preferences_path: Path
+ download_dir: Path
media_player_entity: "Optional[MediaPlayerEntity]" = None
satellite: "Optional[VoiceSatelliteProtocol]" = None
diff --git a/linux_voice_assistant/satellite.py b/linux_voice_assistant/satellite.py
index 92cdec6..9f1aa78 100644
--- a/linux_voice_assistant/satellite.py
+++ b/linux_voice_assistant/satellite.py
@@ -1,9 +1,14 @@
"""Voice satellite protocol."""
+import hashlib
import logging
+import posixpath
+import shutil
import time
from collections.abc import Iterable
from typing import Dict, Optional, Set, Union
+from urllib.parse import urlparse, urlunparse
+from urllib.request import urlopen
# pylint: disable=no-name-in-module
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
@@ -19,6 +24,7 @@ from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
VoiceAssistantConfigurationRequest,
VoiceAssistantConfigurationResponse,
VoiceAssistantEventResponse,
+ VoiceAssistantExternalWakeWord,
VoiceAssistantRequest,
VoiceAssistantSetConfiguration,
VoiceAssistantTimerEventResponse,
@@ -34,9 +40,8 @@ from pymicro_wakeword import MicroWakeWord
from pyopen_wakeword import OpenWakeWord
from .api_server import APIServer
-from .entity import MediaPlayerEntity
from .entity import MediaPlayerEntity, ThinkingSoundEntity
-from .models import ServerState
+from .models import AvailableWakeWord, ServerState, WakeWordType
from .util import call_all
_LOGGER = logging.getLogger(__name__)
@@ -104,6 +109,7 @@ class VoiceSatelliteProtocol(APIServer):
self._continue_conversation = False
self._timer_finished = False
self._processing = False
+ self._external_wake_words: Dict[str, VoiceAssistantExternalWakeWord] = {}
def _set_thinking_sound_enabled(self, new_state: bool) -> None:
self.state.thinking_sound_enabled = bool(new_state)
@@ -115,6 +121,7 @@ class VoiceSatelliteProtocol(APIServer):
_LOGGER.debug("Thinking sound disabled")
pass
self.state.save_preferences()
+
def handle_voice_event(
self, event_type: VoiceAssistantEventType, data: Dict[str, str]
@@ -226,15 +233,31 @@ class VoiceSatelliteProtocol(APIServer):
if isinstance(msg, ListEntitiesRequest):
yield ListEntitiesDoneResponse()
elif isinstance(msg, VoiceAssistantConfigurationRequest):
- yield VoiceAssistantConfigurationResponse(
- available_wake_words=[
+ available_wake_words = [
+ VoiceAssistantWakeWord(
+ id=ww.id,
+ wake_word=ww.wake_word,
+ trained_languages=ww.trained_languages,
+ )
+ for ww in self.state.available_wake_words.values()
+ ]
+
+ for eww in msg.external_wake_words:
+ if eww.model_type != "micro":
+ continue
+
+ available_wake_words.append(
VoiceAssistantWakeWord(
- id=ww.id,
- wake_word=ww.wake_word,
- trained_languages=ww.trained_languages,
+ id=eww.id,
+ wake_word=eww.wake_word,
+ trained_languages=eww.trained_languages,
)
- for ww in self.state.available_wake_words.values()
- ],
+ )
+
+ self._external_wake_words[eww.id] = eww
+
+ yield VoiceAssistantConfigurationResponse(
+ available_wake_words=available_wake_words,
active_wake_words=[
ww.id
for ww in self.state.wake_words.values()
@@ -255,7 +278,16 @@ class VoiceSatelliteProtocol(APIServer):
model_info = self.state.available_wake_words.get(wake_word_id)
if not model_info:
- continue
+ # Check external wake words (may require download)
+ external_wake_word = self._external_wake_words.get(wake_word_id)
+ if not external_wake_word:
+ continue
+
+ model_info = self._download_external_wake_word(external_wake_word)
+ if not model_info:
+ continue
+
+ self.state.available_wake_words[wake_word_id] = model_info
_LOGGER.debug("Loading wake word: %s", model_info.wake_word_path)
self.state.wake_words[wake_word_id] = model_info.load()
@@ -352,3 +384,70 @@ class VoiceSatelliteProtocol(APIServer):
def connection_lost(self, exc):
super().connection_lost(exc)
_LOGGER.info("Disconnected from Home Assistant")
+
+ def _download_external_wake_word(
+ self, external_wake_word: VoiceAssistantExternalWakeWord
+ ) -> Optional[AvailableWakeWord]:
+ eww_dir = self.state.download_dir / "external_wake_words"
+ eww_dir.mkdir(parents=True, exist_ok=True)
+
+ config_path = eww_dir / f"{external_wake_word.id}.json"
+ should_download_config = not config_path.exists()
+
+ # Check if we need to download the model file
+ model_path = eww_dir / f"{external_wake_word.id}.tflite"
+ should_download_model = True
+ if model_path.exists():
+ model_size = model_path.stat().st_size
+ if model_size == external_wake_word.model_size:
+ with open(model_path, "rb") as model_file:
+ model_hash = hashlib.sha256(model_file.read()).hexdigest()
+
+ if model_hash == external_wake_word.model_hash:
+ should_download_model = False
+ _LOGGER.debug(
+ "Model size and hash match for %s. Skipping download.",
+ external_wake_word.id,
+ )
+
+ if should_download_config or should_download_model:
+ # Download config
+ _LOGGER.debug("Downloading %s to %s", external_wake_word.url, config_path)
+ with urlopen(external_wake_word.url) as request:
+ if request.status != 200:
+ _LOGGER.warning(
+ "Failed to download: %s, status=%s",
+ external_wake_word.url,
+ request.status,
+ )
+ return None
+
+ with open(config_path, "wb") as model_file:
+ shutil.copyfileobj(request, model_file)
+
+ if should_download_model:
+ # Download model file
+ parsed_url = urlparse(external_wake_word.url)
+ parsed_url = parsed_url._replace(
+ path=posixpath.join(posixpath.dirname(parsed_url.path), model_path.name)
+ )
+ model_url = urlunparse(parsed_url)
+
+ _LOGGER.debug("Downloading %s to %s", model_url, model_path)
+ with urlopen(model_url) as request:
+ if request.status != 200:
+ _LOGGER.warning(
+ "Failed to download: %s, status=%s", model_url, request.status
+ )
+ return None
+
+ with open(model_path, "wb") as model_file:
+ shutil.copyfileobj(request, model_file)
+
+ return AvailableWakeWord(
+ id=external_wake_word.id,
+ type=WakeWordType.MICRO_WAKE_WORD,
+ wake_word=external_wake_word.wake_word,
+ trained_languages=external_wake_word.trained_languages,
+ wake_word_path=config_path,
+ )
diff --git a/pyproject.toml b/pyproject.toml
index fee8ba2..03e57f6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,7 +25,7 @@ classifiers = [
]
requires-python = ">=3.9.0"
dependencies = [
- "aioesphomeapi==37.2.1",
+ "aioesphomeapi==42.7.0",
"soundcard<1",
"numpy>=2,<3",
"pymicro-wakeword>=2,<3",