Switch between MWW/OWW models from the UI (#348)
This PR adds the ability to select microWakeWord/openWakeWord models using the wake word dropdown selection via the HA device page at any time without the need for restarting the app/container to change the wake word path.
This commit is contained in:
1 parent
2c9c1d2b8a
commit
4a0695ab57
7 files changed
+102
-11
No files matched your search
@@ -18,7 +18,7 @@ from getmac import get_mac_address # type: ignore
|
|||||||
from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
|
from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
|
||||||
from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures
|
from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures
|
||||||
|
|
||||||
from .models import Preferences, ServerState
|
from .models import Preferences, ServerState, WakeWordType
|
||||||
from .mpv_player import MpvMediaPlayer
|
from .mpv_player import MpvMediaPlayer
|
||||||
from .peripheral_api import LVAEvent, PeripheralAPIServer
|
from .peripheral_api import LVAEvent, PeripheralAPIServer
|
||||||
from .satellite import VoiceSatelliteProtocol
|
from .satellite import VoiceSatelliteProtocol
|
||||||
@@ -322,6 +322,25 @@ async def main() -> None:
|
|||||||
|
|
||||||
# Load available wake words
|
# Load available wake words
|
||||||
wake_word_dirs = [Path(ww_dir) for ww_dir in args.wake_word_dir]
|
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")
|
wake_word_dirs.append(args.download_dir / "external_wake_words")
|
||||||
available_wake_words = find_available_wake_words(wake_word_dirs, args.stop_model)
|
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
|
preferences.mic_noise_suppression = args.mic_noise_suppression
|
||||||
|
|
||||||
# Load wake/stop models
|
# 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"
|
# TODO: allow openWakeWord for "stop"
|
||||||
stop_model = load_stop_model(wake_word_dirs, args.stop_model)
|
stop_model = load_stop_model(wake_word_dirs, args.stop_model)
|
||||||
|
|||||||
@@ -76,8 +76,43 @@ def find_available_wake_words(wake_word_dirs: List[Path], stop_model_id: str) ->
|
|||||||
return available_wake_words
|
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(
|
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]:
|
) -> tuple[Dict[str, Union[MicroWakeWord, OpenWakeWord]], Set[str], bool]:
|
||||||
"""
|
"""
|
||||||
Loads the specified wake word models.
|
Loads the specified wake word models.
|
||||||
@@ -88,6 +123,12 @@ def load_wake_models(
|
|||||||
available_wake_words: Dictionary with all available wake words
|
available_wake_words: Dictionary with all available wake words
|
||||||
active_wake_word_ids: List of IDs of wake words to load (may be None)
|
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
|
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:
|
Returns:
|
||||||
Tuple with (Dictionary of loaded models, Set of active wake word IDs)
|
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
|
# No models loaded, fall back to default model
|
||||||
_LOGGER.debug("No wake models loaded, falling back to default model")
|
_LOGGER.debug("No wake models loaded, falling back to default model")
|
||||||
wake_word_id = default_wake_word_id
|
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:
|
if wake_word is None:
|
||||||
_LOGGER.error("❌ Default wake word '%s' not found!", wake_word_id)
|
_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"
|
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)
|
wake_word = available_wake_words.get(wake_word_id)
|
||||||
|
|
||||||
if wake_word is None:
|
if wake_word is None:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"type": "openWakeWord",
|
"type": "openWakeWord",
|
||||||
"wake_word": "Alexa",
|
"wake_word": "Alexa (OWW)",
|
||||||
"model": "alexa_v0.1.tflite",
|
"model": "alexa_v0.1.tflite",
|
||||||
"openWakeWord": {
|
"openWakeWord": {
|
||||||
"probability_cutoff": 0.7
|
"probability_cutoff": 0.7
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"type": "openWakeWord",
|
"type": "openWakeWord",
|
||||||
"wake_word": "Hey Jarvis",
|
"wake_word": "Hey Jarvis (OWW)",
|
||||||
"model": "hey_jarvis_v0.1.tflite",
|
"model": "hey_jarvis_v0.1.tflite",
|
||||||
"openWakeWord": {
|
"openWakeWord": {
|
||||||
"probability_cutoff": 0.7
|
"probability_cutoff": 0.7
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"type": "openWakeWord",
|
"type": "openWakeWord",
|
||||||
"wake_word": "Hey Mycroft",
|
"wake_word": "Hey Mycroft (OWW)",
|
||||||
"model": "hey_mycroft_v0.1.tflite",
|
"model": "hey_mycroft_v0.1.tflite",
|
||||||
"openWakeWord": {
|
"openWakeWord": {
|
||||||
"probability_cutoff": 0.7
|
"probability_cutoff": 0.7
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"type": "openWakeWord",
|
"type": "openWakeWord",
|
||||||
"wake_word": "Hey Rhasspy",
|
"wake_word": "Hey Rhasspy (OWW)",
|
||||||
"model": "hey_rhasspy_v0.1.tflite",
|
"model": "hey_rhasspy_v0.1.tflite",
|
||||||
"openWakeWord": {
|
"openWakeWord": {
|
||||||
"probability_cutoff": 0.7
|
"probability_cutoff": 0.7
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"type": "openWakeWord",
|
"type": "openWakeWord",
|
||||||
"wake_word": "Ok Nabu",
|
"wake_word": "Ok Nabu (OWW)",
|
||||||
"model": "ok_nabu_v0.1.tflite",
|
"model": "ok_nabu_v0.1.tflite",
|
||||||
"openWakeWord": {
|
"openWakeWord": {
|
||||||
"probability_cutoff": 0.7
|
"probability_cutoff": 0.7
|
||||||
|
|||||||
Reference in new issue
Block a user