diff --git a/linux_voice_assistant/__main__.py b/linux_voice_assistant/__main__.py index c6a8e22..6dcadd3 100644 --- a/linux_voice_assistant/__main__.py +++ b/linux_voice_assistant/__main__.py @@ -18,7 +18,7 @@ from getmac import get_mac_address # type: ignore from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures -from .models import Preferences, ServerState +from .models import Preferences, ServerState, WakeWordType from .mpv_player import MpvMediaPlayer from .peripheral_api import LVAEvent, PeripheralAPIServer from .satellite import VoiceSatelliteProtocol @@ -322,6 +322,25 @@ async def main() -> None: # Load available wake words wake_word_dirs = [Path(ww_dir) for ww_dir in args.wake_word_dir] + + # If the operator explicitly pointed --wake-word-dir (or the WAKE_WORD_DIR + # env var) at the openWakeWord subdirectory, prefer resolving --wake-model + # to an openWakeWord model of the same name instead of a same-named + # microWakeWord one. Checked before the automatic dirs below are appended, + # since those always include the openWakeWord path and would otherwise + # make every configuration look like an openWakeWord preference. + preferred_wake_word_type = WakeWordType.OPEN_WAKE_WORD if any("openwakeword" in str(ww_dir).lower() for ww_dir in wake_word_dirs) else None + + # openWakeWord models ship in their own subdirectory under the default + # wakewords dir. find_available_wake_words() only globs the top level of + # each directory it's given, so this must be added explicitly or the OWW + # models never get discovered (and never show up in the HA dropdown). + # Appended after the user-specified dirs so OWW entries are inserted + # (and therefore displayed) after the microWakeWord ones. + oww_dir = _WAKEWORDS_DIR / "openWakeWord" + if oww_dir not in wake_word_dirs: + wake_word_dirs.append(oww_dir) + wake_word_dirs.append(args.download_dir / "external_wake_words") available_wake_words = find_available_wake_words(wake_word_dirs, args.stop_model) @@ -359,7 +378,12 @@ async def main() -> None: preferences.mic_noise_suppression = args.mic_noise_suppression # Load wake/stop models - wake_models, active_wake_words, fallback_used = load_wake_models(available_wake_words, [word for word in preferences.active_wake_words if word is not None], args.wake_model) + wake_models, active_wake_words, fallback_used = load_wake_models( + available_wake_words, + [word for word in preferences.active_wake_words if word is not None], + args.wake_model, + preferred_type=preferred_wake_word_type, + ) # TODO: allow openWakeWord for "stop" stop_model = load_stop_model(wake_word_dirs, args.stop_model) diff --git a/linux_voice_assistant/wake_word.py b/linux_voice_assistant/wake_word.py index d5a3180..5734510 100644 --- a/linux_voice_assistant/wake_word.py +++ b/linux_voice_assistant/wake_word.py @@ -76,8 +76,43 @@ def find_available_wake_words(wake_word_dirs: List[Path], stop_model_id: str) -> return available_wake_words +def _find_matching_wake_word_id( + available_wake_words: Dict[str, AvailableWakeWord], + requested_id: str, + wake_word_type: Optional[WakeWordType] = None, +) -> Optional[str]: + """ + Finds a wake word id matching requested_id, optionally restricted to a single type. + + Tries, in order: + 1. An exact id match. + 2. An id that starts with "{requested_id}_", which covers openWakeWord's + versioned filenames (e.g. "hey_jarvis" -> "hey_jarvis_v0.1"). + + Args: + available_wake_words: Dictionary with all available wake words + requested_id: ID (or base name) to look for + wake_word_type: If given, only consider wake words of this type + + Returns: + The matching id, or None if no match was found + """ + prefix = f"{requested_id}_" + for candidate_id, wake_word in available_wake_words.items(): + if wake_word_type is not None and wake_word.type != wake_word_type: + continue + + if candidate_id == requested_id or candidate_id.startswith(prefix): + return candidate_id + + return None + + def load_wake_models( - available_wake_words: Dict[str, AvailableWakeWord], active_wake_word_ids: Optional[List[str]], default_wake_word_id: str + available_wake_words: Dict[str, AvailableWakeWord], + active_wake_word_ids: Optional[List[str]], + default_wake_word_id: str, + preferred_type: Optional[WakeWordType] = None, ) -> tuple[Dict[str, Union[MicroWakeWord, OpenWakeWord]], Set[str], bool]: """ Loads the specified wake word models. @@ -88,6 +123,12 @@ def load_wake_models( available_wake_words: Dictionary with all available wake words active_wake_word_ids: List of IDs of wake words to load (may be None) default_wake_word_id: ID of the default model which is loaded if no others are specified + preferred_type: If given, prefer resolving default_wake_word_id (and the + "okay_nabu" fallback) to a wake word of this type before falling back + to a type-agnostic match. Lets the operator's --wake-word-dir choice + (e.g. pointing at the openWakeWord subdirectory) decide which model + variant gets activated when the requested id exists for more than + one wake word engine. Returns: Tuple with (Dictionary of loaded models, Set of active wake word IDs) @@ -120,14 +161,40 @@ def load_wake_models( # No models loaded, fall back to default model _LOGGER.debug("No wake models loaded, falling back to default model") wake_word_id = default_wake_word_id + wake_word = None + + # If the operator's --wake-word-dir choice implies a preferred engine + # (e.g. pointing at the openWakeWord subdirectory), try to resolve the + # requested id to a model of that type first, even if a same-named + # model of a different type also exists. + if preferred_type is not None: + matched_id = _find_matching_wake_word_id(available_wake_words, default_wake_word_id, preferred_type) + if matched_id is not None: + wake_word_id = matched_id + wake_word = available_wake_words[wake_word_id] + _LOGGER.debug("Resolved default wake word '%s' to '%s' (preferred type %s)", default_wake_word_id, wake_word_id, preferred_type) + + if wake_word is None: + # Fall back to a type-agnostic match (covers exact ids and + # versioned openWakeWord filenames like "hey_jarvis_v0.1") + matched_id = _find_matching_wake_word_id(available_wake_words, default_wake_word_id) + if matched_id is not None: + wake_word_id = matched_id + wake_word = available_wake_words[wake_word_id] - # Check if default wake word exists - wake_word = available_wake_words.get(wake_word_id) if wake_word is None: _LOGGER.error("❌ Default wake word '%s' not found!", wake_word_id) - # Try fallback to 'okay_nabu' + # Try fallback to 'okay_nabu', respecting the same type preference wake_word_id = "okay_nabu" + matched_id = None + if preferred_type is not None: + matched_id = _find_matching_wake_word_id(available_wake_words, wake_word_id, preferred_type) + if matched_id is None: + matched_id = _find_matching_wake_word_id(available_wake_words, wake_word_id) + if matched_id is not None: + wake_word_id = matched_id + wake_word = available_wake_words.get(wake_word_id) if wake_word is None: diff --git a/wakewords/openWakeWord/alexa_v0.1.json b/wakewords/openWakeWord/alexa_v0.1.json index 64d5981..f9b9013 100644 --- a/wakewords/openWakeWord/alexa_v0.1.json +++ b/wakewords/openWakeWord/alexa_v0.1.json @@ -1,6 +1,6 @@ { "type": "openWakeWord", - "wake_word": "Alexa", + "wake_word": "Alexa (OWW)", "model": "alexa_v0.1.tflite", "openWakeWord": { "probability_cutoff": 0.7 diff --git a/wakewords/openWakeWord/hey_jarvis_v0.1.json b/wakewords/openWakeWord/hey_jarvis_v0.1.json index 85c68f8..a25d9de 100644 --- a/wakewords/openWakeWord/hey_jarvis_v0.1.json +++ b/wakewords/openWakeWord/hey_jarvis_v0.1.json @@ -1,6 +1,6 @@ { "type": "openWakeWord", - "wake_word": "Hey Jarvis", + "wake_word": "Hey Jarvis (OWW)", "model": "hey_jarvis_v0.1.tflite", "openWakeWord": { "probability_cutoff": 0.7 diff --git a/wakewords/openWakeWord/hey_mycroft_v0.1.json b/wakewords/openWakeWord/hey_mycroft_v0.1.json index 311e447..86f9a04 100644 --- a/wakewords/openWakeWord/hey_mycroft_v0.1.json +++ b/wakewords/openWakeWord/hey_mycroft_v0.1.json @@ -1,6 +1,6 @@ { "type": "openWakeWord", - "wake_word": "Hey Mycroft", + "wake_word": "Hey Mycroft (OWW)", "model": "hey_mycroft_v0.1.tflite", "openWakeWord": { "probability_cutoff": 0.7 diff --git a/wakewords/openWakeWord/hey_rhasspy_v0.1.json b/wakewords/openWakeWord/hey_rhasspy_v0.1.json index 98ee38a..43ec652 100644 --- a/wakewords/openWakeWord/hey_rhasspy_v0.1.json +++ b/wakewords/openWakeWord/hey_rhasspy_v0.1.json @@ -1,6 +1,6 @@ { "type": "openWakeWord", - "wake_word": "Hey Rhasspy", + "wake_word": "Hey Rhasspy (OWW)", "model": "hey_rhasspy_v0.1.tflite", "openWakeWord": { "probability_cutoff": 0.7 diff --git a/wakewords/openWakeWord/ok_nabu_v0.1.json b/wakewords/openWakeWord/ok_nabu_v0.1.json index dad14da..ea731a5 100644 --- a/wakewords/openWakeWord/ok_nabu_v0.1.json +++ b/wakewords/openWakeWord/ok_nabu_v0.1.json @@ -1,6 +1,6 @@ { "type": "openWakeWord", - "wake_word": "Ok Nabu", + "wake_word": "Ok Nabu (OWW)", "model": "ok_nabu_v0.1.tflite", "openWakeWord": { "probability_cutoff": 0.7