Support multiple wake words

This commit is contained in:
Michael Hansen
2025-09-15 11:29:28 -05:00
parent 2c937e3cb3
commit e26864a191
4 changed files with 55 additions and 24 deletions
+1
View File
@@ -2,6 +2,7 @@
## Unreleased ## Unreleased
- Support multiple wake words
- Save active wake words to preferences JSON file - Save active wake words to preferences JSON file
- Refactor main into separate files - Refactor main into separate files
+34 -12
View File
@@ -6,7 +6,7 @@ import logging
import threading import threading
from pathlib import Path from pathlib import Path
from queue import Queue from queue import Queue
from typing import Dict from typing import Dict, List
import sounddevice as sd import sounddevice as sd
@@ -109,16 +109,27 @@ async def main() -> None:
_LOGGER.debug("libtensorflowlite_c path: %s", libtensorflowlite_c_path) _LOGGER.debug("libtensorflowlite_c path: %s", libtensorflowlite_c_path)
# Load wake/stop models # Load wake/stop models
wake_models: Dict[str, MicroWakeWord] = {}
wake_config_path = wake_word_dir / f"{args.wake_model}.json" wake_config_path = wake_word_dir / f"{args.wake_model}.json"
if preferences.active_wake_words: if preferences.active_wake_words:
wake_word_id = preferences.active_wake_words[0] # Load preferred models
maybe_wake_config_path = wake_word_dir / f"{wake_word_id}.json" for wake_word_id in preferences.active_wake_words:
if maybe_wake_config_path.exists(): wake_config_path = wake_word_dir / f"{wake_word_id}.json"
# Override with last set if not wake_config_path.exists():
wake_config_path = maybe_wake_config_path continue
_LOGGER.debug("Loading wake model: %s", wake_config_path) _LOGGER.debug("Loading wake model: %s", wake_config_path)
wake_model = MicroWakeWord.from_config(wake_config_path, libtensorflowlite_c_path) wake_models[wake_word_id] = MicroWakeWord.from_config(
wake_config_path, libtensorflowlite_c_path
)
if not wake_models:
# Load default model
_LOGGER.debug("Loading wake model: %s", wake_config_path)
wake_word_id = wake_config_path.stem
wake_models[wake_word_id] = MicroWakeWord.from_config(
wake_config_path, libtensorflowlite_c_path
)
stop_config_path = wake_word_dir / f"{args.stop_model}.json" stop_config_path = wake_word_dir / f"{args.stop_model}.json"
_LOGGER.debug("Loading stop model: %s", stop_config_path) _LOGGER.debug("Loading stop model: %s", stop_config_path)
@@ -130,7 +141,7 @@ async def main() -> None:
audio_queue=Queue(), audio_queue=Queue(),
entities=[], entities=[],
available_wake_words=available_wake_words, available_wake_words=available_wake_words,
wake_word=wake_model, wake_words=wake_models,
stop_word=stop_model, stop_word=stop_model,
music_player=MpvMediaPlayer(device=args.audio_output_device), music_player=MpvMediaPlayer(device=args.audio_output_device),
tts_player=MpvMediaPlayer(device=args.audio_output_device), tts_player=MpvMediaPlayer(device=args.audio_output_device),
@@ -138,6 +149,7 @@ async def main() -> None:
timer_finished_sound=args.timer_finished_sound, timer_finished_sound=args.timer_finished_sound,
preferences=preferences, preferences=preferences,
preferences_path=preferences_path, preferences_path=preferences_path,
libtensorflowlite_c_path=libtensorflowlite_c_path,
) )
process_audio_thread = threading.Thread( process_audio_thread = threading.Thread(
@@ -179,6 +191,9 @@ async def main() -> None:
def process_audio(state: ServerState): def process_audio(state: ServerState):
"""Process audio chunks from the microphone."""
wake_words: List[MicroWakeWord] = []
try: try:
while True: while True:
@@ -189,13 +204,20 @@ def process_audio(state: ServerState):
if state.satellite is None: if state.satellite is None:
continue continue
if (not wake_words) or (state.wake_words_changed and state.wake_words):
# Update list of wake word models to process
state.wake_words_changed = False
wake_words = list(state.wake_words.values())
try: try:
state.satellite.handle_audio(audio_chunk) state.satellite.handle_audio(audio_chunk)
if state.wake_word.is_active and state.wake_word.process_streaming( for wake_word in wake_words:
audio_chunk if not wake_word.is_active:
): continue
state.satellite.wakeup()
if wake_word.process_streaming(audio_chunk):
state.satellite.wakeup(wake_word)
if state.stop_word.is_active and state.stop_word.process_streaming( if state.stop_word.is_active and state.stop_word.process_streaming(
audio_chunk audio_chunk
+3 -1
View File
@@ -36,7 +36,7 @@ class ServerState:
audio_queue: "Queue[Optional[bytes]]" audio_queue: "Queue[Optional[bytes]]"
entities: "List[ESPHomeEntity]" entities: "List[ESPHomeEntity]"
available_wake_words: "Dict[str, AvailableWakeWord]" available_wake_words: "Dict[str, AvailableWakeWord]"
wake_word: "MicroWakeWord" wake_words: "Dict[str, MicroWakeWord]"
stop_word: "MicroWakeWord" stop_word: "MicroWakeWord"
music_player: "MpvMediaPlayer" music_player: "MpvMediaPlayer"
tts_player: "MpvMediaPlayer" tts_player: "MpvMediaPlayer"
@@ -44,8 +44,10 @@ class ServerState:
timer_finished_sound: str timer_finished_sound: str
preferences: Preferences preferences: Preferences
preferences_path: Path preferences_path: Path
libtensorflowlite_c_path: Path
media_player_entity: "Optional[MediaPlayerEntity]" = None media_player_entity: "Optional[MediaPlayerEntity]" = None
satellite: "Optional[VoiceSatelliteProtocol]" = None satellite: "Optional[VoiceSatelliteProtocol]" = None
wake_words_changed: bool = False
def save_preferences(self) -> None: def save_preferences(self) -> None:
"""Save preferences as JSON.""" """Save preferences as JSON."""
+16 -10
View File
@@ -175,36 +175,42 @@ class VoiceSatelliteProtocol(APIServer):
) )
for ww in self.state.available_wake_words.values() for ww in self.state.available_wake_words.values()
], ],
active_wake_words=[self.state.wake_word.id], active_wake_words=list(self.state.wake_words.keys()),
max_active_wake_words=1, max_active_wake_words=2,
) )
_LOGGER.info("Connected to Home Assistant") _LOGGER.info("Connected to Home Assistant")
elif isinstance(msg, VoiceAssistantSetConfiguration): elif isinstance(msg, VoiceAssistantSetConfiguration):
# TODO: support multiple wake words # Change active wake words
active_wake_words: Set[str] = set() active_wake_words: Set[str] = set()
for wake_word_id in msg.active_wake_words: for wake_word_id in msg.active_wake_words:
if wake_word_id == self.state.wake_word.id: if wake_word_id in self.state.wake_words:
# Already active # Already active
active_wake_words.add(wake_word_id) active_wake_words.add(wake_word_id)
break continue
model_info = self.state.available_wake_words.get(wake_word_id) model_info = self.state.available_wake_words.get(wake_word_id)
if not model_info: if not model_info:
continue continue
_LOGGER.debug("Loading wake word: %s", model_info.config_path) _LOGGER.debug("Loading wake word: %s", model_info.config_path)
self.state.wake_word = MicroWakeWord.from_config( self.state.wake_words[wake_word_id] = MicroWakeWord.from_config(
model_info.config_path, model_info.config_path,
self.state.wake_word.libtensorflowlite_c_path, self.state.libtensorflowlite_c_path,
) )
_LOGGER.info("Wake word set: %s", self.state.wake_word.wake_word) _LOGGER.info("Wake word set: %s", wake_word_id)
active_wake_words.add(wake_word_id) active_wake_words.add(wake_word_id)
break break
for wake_word in self.state.wake_words.values():
wake_word.is_active = wake_word.id in active_wake_words
_LOGGER.debug("Active wake words: %s", active_wake_words)
self.state.preferences.active_wake_words = list(active_wake_words) self.state.preferences.active_wake_words = list(active_wake_words)
self.state.save_preferences() self.state.save_preferences()
self.state.wake_words_changed = True
def handle_audio(self, audio_chunk: bytes) -> None: def handle_audio(self, audio_chunk: bytes) -> None:
@@ -213,7 +219,7 @@ class VoiceSatelliteProtocol(APIServer):
self.send_messages([VoiceAssistantAudio(data=audio_chunk)]) self.send_messages([VoiceAssistantAudio(data=audio_chunk)])
def wakeup(self) -> None: def wakeup(self, wake_word: MicroWakeWord) -> None:
if self._timer_finished: if self._timer_finished:
# Stop timer instead # Stop timer instead
self._timer_finished = False self._timer_finished = False
@@ -221,7 +227,7 @@ class VoiceSatelliteProtocol(APIServer):
_LOGGER.debug("Stopping timer finished sound") _LOGGER.debug("Stopping timer finished sound")
return return
wake_word_phrase = self.state.wake_word.wake_word wake_word_phrase = wake_word.wake_word
_LOGGER.debug("Detected wake word: %s", wake_word_phrase) _LOGGER.debug("Detected wake word: %s", wake_word_phrase)
self.send_messages( self.send_messages(
[VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)] [VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)]