diff --git a/CHANGELOG.md b/CHANGELOG.md index 588bdb7..26ae6a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Support multiple wake words - Save active wake words to preferences JSON file - Refactor main into separate files diff --git a/linux_voice_assistant/__main__.py b/linux_voice_assistant/__main__.py index 61c3bd6..b9b26db 100644 --- a/linux_voice_assistant/__main__.py +++ b/linux_voice_assistant/__main__.py @@ -6,7 +6,7 @@ import logging import threading from pathlib import Path from queue import Queue -from typing import Dict +from typing import Dict, List import sounddevice as sd @@ -109,16 +109,27 @@ async def main() -> None: _LOGGER.debug("libtensorflowlite_c path: %s", libtensorflowlite_c_path) # Load wake/stop models + wake_models: Dict[str, MicroWakeWord] = {} wake_config_path = wake_word_dir / f"{args.wake_model}.json" if preferences.active_wake_words: - wake_word_id = preferences.active_wake_words[0] - maybe_wake_config_path = wake_word_dir / f"{wake_word_id}.json" - if maybe_wake_config_path.exists(): - # Override with last set - wake_config_path = maybe_wake_config_path + # Load preferred models + for wake_word_id in preferences.active_wake_words: + wake_config_path = wake_word_dir / f"{wake_word_id}.json" + if not wake_config_path.exists(): + continue - _LOGGER.debug("Loading wake model: %s", wake_config_path) - wake_model = MicroWakeWord.from_config(wake_config_path, libtensorflowlite_c_path) + _LOGGER.debug("Loading wake model: %s", wake_config_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" _LOGGER.debug("Loading stop model: %s", stop_config_path) @@ -130,7 +141,7 @@ async def main() -> None: audio_queue=Queue(), entities=[], available_wake_words=available_wake_words, - wake_word=wake_model, + wake_words=wake_models, stop_word=stop_model, music_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, preferences=preferences, preferences_path=preferences_path, + libtensorflowlite_c_path=libtensorflowlite_c_path, ) process_audio_thread = threading.Thread( @@ -179,6 +191,9 @@ async def main() -> None: def process_audio(state: ServerState): + """Process audio chunks from the microphone.""" + + wake_words: List[MicroWakeWord] = [] try: while True: @@ -189,13 +204,20 @@ def process_audio(state: ServerState): if state.satellite is None: 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: state.satellite.handle_audio(audio_chunk) - if state.wake_word.is_active and state.wake_word.process_streaming( - audio_chunk - ): - state.satellite.wakeup() + for wake_word in wake_words: + if not wake_word.is_active: + continue + + if wake_word.process_streaming(audio_chunk): + state.satellite.wakeup(wake_word) if state.stop_word.is_active and state.stop_word.process_streaming( audio_chunk diff --git a/linux_voice_assistant/models.py b/linux_voice_assistant/models.py index 4a47a7c..f6c47ad 100644 --- a/linux_voice_assistant/models.py +++ b/linux_voice_assistant/models.py @@ -36,7 +36,7 @@ class ServerState: audio_queue: "Queue[Optional[bytes]]" entities: "List[ESPHomeEntity]" available_wake_words: "Dict[str, AvailableWakeWord]" - wake_word: "MicroWakeWord" + wake_words: "Dict[str, MicroWakeWord]" stop_word: "MicroWakeWord" music_player: "MpvMediaPlayer" tts_player: "MpvMediaPlayer" @@ -44,8 +44,10 @@ class ServerState: timer_finished_sound: str preferences: Preferences preferences_path: Path + libtensorflowlite_c_path: Path media_player_entity: "Optional[MediaPlayerEntity]" = None satellite: "Optional[VoiceSatelliteProtocol]" = None + wake_words_changed: bool = False def save_preferences(self) -> None: """Save preferences as JSON.""" diff --git a/linux_voice_assistant/satellite.py b/linux_voice_assistant/satellite.py index ebee376..1537d0d 100644 --- a/linux_voice_assistant/satellite.py +++ b/linux_voice_assistant/satellite.py @@ -175,36 +175,42 @@ class VoiceSatelliteProtocol(APIServer): ) for ww in self.state.available_wake_words.values() ], - active_wake_words=[self.state.wake_word.id], - max_active_wake_words=1, + active_wake_words=list(self.state.wake_words.keys()), + max_active_wake_words=2, ) _LOGGER.info("Connected to Home Assistant") elif isinstance(msg, VoiceAssistantSetConfiguration): - # TODO: support multiple wake words + # Change active wake words active_wake_words: Set[str] = set() 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 active_wake_words.add(wake_word_id) - break + continue model_info = self.state.available_wake_words.get(wake_word_id) if not model_info: continue _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, - 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) 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.save_preferences() + self.state.wake_words_changed = True def handle_audio(self, audio_chunk: bytes) -> None: @@ -213,7 +219,7 @@ class VoiceSatelliteProtocol(APIServer): self.send_messages([VoiceAssistantAudio(data=audio_chunk)]) - def wakeup(self) -> None: + def wakeup(self, wake_word: MicroWakeWord) -> None: if self._timer_finished: # Stop timer instead self._timer_finished = False @@ -221,7 +227,7 @@ class VoiceSatelliteProtocol(APIServer): _LOGGER.debug("Stopping timer finished sound") 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) self.send_messages( [VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)]