Add files via upload

This commit is contained in:
imonlinux
2025-09-11 20:45:18 -07:00
committed by GitHub
parent e72635978f
commit 07eb4ed3bf
2 changed files with 422 additions and 94 deletions
+123 -15
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
import argparse
import os
import asyncio
import json
import logging
@@ -8,10 +9,26 @@ import threading
import time
from collections.abc import Iterable
from dataclasses import dataclass
from pathlib import Path
from queue import Queue
from typing import Dict, List, Optional
# Models that should NOT appear in UI dropdowns
EXCLUDED_OWW_MODELS = {"embedding_model", "melspectrogram"}
import re
def _prettify_model_name(name: str) -> str:
stem = Path(name).stem
stem = re.sub(r"(?:_v\d+(?:\.\d+)?)$", "", stem)
parts = stem.split("_")
if parts and parts[0].lower() == "ok":
parts[0] = "OK"
else:
parts = [p.capitalize() for p in parts]
return " ".join(parts)
# pylint: disable=no-name-in-module
import sounddevice as sd
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
@@ -48,6 +65,17 @@ from .util import call_all, get_mac, is_arm
from .event_bus import EventBus
from .event_led import LedEvent
from pathlib import Path
# Hide helper models that are not detectors
_OWW_DETECT_MODEL_DENYLIST = {"embedding_model", "melspectrogram"}
def _oww_filter_models(models: list[str]) -> list[str]:
"""Normalize names (strip .tflite/paths), drop helper models, sort & de-dupe."""
stems = {Path(m).stem for m in models}
return sorted(m for m in stems if m not in _OWW_DETECT_MODEL_DENYLIST)
_LOGGER = logging.getLogger(__name__)
_MODULE_DIR = Path(__file__).parent
@@ -86,7 +114,8 @@ class ServerState:
event_bus: EventBus
media_player_entity: Optional[MediaPlayerEntity] = None
satellite: "Optional[VoiceSatelliteProtocol]" = None
wyoming_wake: Optional[WyomingWakeClient] = None
current_remote_wake: Optional[str] = None
wyoming_wake: "Optional[WyomingWakeClient]" = None
use_wyoming_wake: bool = False
@@ -231,7 +260,7 @@ class VoiceSatelliteProtocol(APIServer):
)
for ww in self.state.available_wake_words.values()
],
active_wake_words=[self.state.wake_word.id],
active_wake_words=[self.state.current_remote_wake] if self.state.use_wyoming_wake else [self.state.wake_word.id],
max_active_wake_words=1,
)
_LOGGER.info("Connected to Home Assistant")
@@ -242,6 +271,15 @@ class VoiceSatelliteProtocol(APIServer):
# Already active
break
if self.state.use_wyoming_wake and self.state.wyoming_wake:
try:
self.state.wyoming_wake.set_detect([wake_word_id])
_LOGGER.info("Switched Wyoming wake model to: %s", wake_word_id)
self.state.current_remote_wake = wake_word_id
self.state.wake_word.is_active = False
except Exception:
_LOGGER.exception("Failed to switch Wyoming wake model")
break
model_info = self.state.available_wake_words.get(wake_word_id)
if not model_info:
continue
@@ -270,8 +308,12 @@ class VoiceSatelliteProtocol(APIServer):
_LOGGER.debug("Stopping timer finished sound")
return
wake_word_phrase = self.state.wake_word.wake_word
wake_word_phrase = (self.state.current_remote_wake or self.state.wake_word.wake_word) if self.state.use_wyoming_wake else self.state.wake_word.wake_word
_LOGGER.debug("Detected wake word: %s", wake_word_phrase)
try:
self.state.wyoming_wake.suppress(2500)
except Exception:
pass
self.send_messages(
[VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)]
)
@@ -348,9 +390,6 @@ class VoiceSatelliteProtocol(APIServer):
def process_audio(state: ServerState):
# debug counters
chunks_sent = 0
last_log = 0.0
try:
while True:
@@ -363,9 +402,6 @@ def process_audio(state: ServerState):
try:
state.satellite.handle_audio(audio_chunk)
chunks_sent += 1
if state.use_wyoming_wake and chunks_sent % 100 == 0:
_LOGGER.debug("[OWW] chunks_sent=%d (approx %.1fs of audio)", chunks_sent, chunks_sent*0.064)
if state.use_wyoming_wake:
if state.wyoming_wake:
@@ -395,6 +431,7 @@ async def main() -> None:
parser.add_argument("--name", required=True)
parser.add_argument("--wake-uri", help="Wyoming wake server URI (e.g., tcp://127.0.0.1:10400)")
parser.add_argument("--wake-word-name", help="Wake word name on the Wyoming server (e.g., hal)")
parser.add_argument("--wake-models-dir", help="Fallback: directory containing openWakeWord *.tflite models to list if the server does not advertise them")
parser.add_argument(
"--audio-input-device",
default="default",
@@ -433,11 +470,14 @@ async def main() -> None:
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
_LOGGER.debug(args)
use_wyoming = bool(args.wake_uri and args.wake_word_name)
# Optional fallback for discovering remote models from filesystem
models_dir = args.wake_models_dir or os.environ.get("WYOMING_MODELS_DIR")
use_wyoming = bool(args.wake_uri)
wyoming_client = None
if use_wyoming:
_LOGGER.info("Using Wyoming openWakeWord at %s (name=%s)", args.wake_uri, args.wake_word_name)
wyoming_client = WyomingWakeClient(args.wake_uri, args.wake_word_name)
wyoming_client = WyomingWakeClient(args.wake_uri, args.wake_word_name or "")
# Load available wake words
wake_word_dir = Path(args.wake_word_dir)
@@ -459,6 +499,28 @@ async def main() -> None:
_LOGGER.debug("Available wake words: %s", list(sorted(available_wake_words.keys())))
if use_wyoming and wyoming_client:
try:
remote_models = wyoming_client.list_models() or []
_LOGGER.debug("Queried Wyoming models: %s", [m.get("name") if isinstance(m, dict) else m for m in remote_models])
except Exception:
remote_models = []
_LOGGER.exception("Failed to list models from Wyoming server")
if remote_models:
available_wake_words = {}
for m in remote_models:
name = (m.get("name") or "").strip()
langs = m.get("languages") or m.get("trained_languages") or []
if not name:
continue
available_wake_words[name] = AvailableWakeWord(
id=name,
wake_word=(m.get('label') or _prettify_model_name(name)),
trained_languages=langs,
config_path=Path(f"wyoming:{name}"),
)
_LOGGER.debug("Remote OpenWakeWord models: %s", list(sorted(available_wake_words.keys())))
libtensorflowlite_c_path = _LIB_DIR / "libtensorflowlite_c.so"
_LOGGER.debug("libtensorflowlite_c path: %s", libtensorflowlite_c_path)
@@ -496,12 +558,58 @@ async def main() -> None:
# Connect to Wyoming wake server if enabled
if state.use_wyoming_wake and state.wyoming_wake:
def _on_detect(_name, _ts):
_LOGGER.debug("[OWW] detection callback fired name=%s ts=%s", _name, _ts)
if state.satellite is not None:
state.loop.call_soon_threadsafe(lambda: state.satellite.wakeup())
state.wyoming_wake.connect(_on_detect)
# Disable local wake model to avoid double triggers
state.wake_word.is_active = False
_LOGGER.debug("[OWW] Local MicroWakeWord disabled; streaming audio to Wyoming")
# Refresh remote models after connect
try:
cached = state.wyoming_wake.get_models(timeout=1.5) if state.wyoming_wake else []
if not cached and models_dir:
try:
from pathlib import Path as _P
cached = [{"name": p.stem} for p in _P(models_dir).glob("*.tflite")]
cached = [
m for m in cached
if (m.get('name') or '').strip() not in EXCLUDED_OWW_MODELS
]
_LOGGER.info("Using filesystem fallback for OWW models (post-connect): %s", [m["name"] for m in cached])
except Exception:
_LOGGER.exception("Failed to scan wake-models-dir (post-connect)")
if cached:
available = {}
for m in cached:
name = (m.get('name') or '').strip()
if not name:
continue
available[name] = AvailableWakeWord(
id=name,
wake_word=(m.get('label') or _prettify_model_name(name)),
trained_languages=[],
config_path=Path(f"wyoming:{name}")
)
state.available_wake_words = available
_LOGGER.debug("Using remote models from cached info: %s", list(sorted(state.available_wake_words.keys())))
except Exception:
_LOGGER.exception("Failed to refresh remote models from connection info")
# Initialize remote detection target if provided
try:
if args.wake_word_name:
state.wyoming_wake.set_detect([args.wake_word_name])
state.current_remote_wake = args.wake_word_name
else:
try:
first = next(iter(state.available_wake_words.keys()))
except StopIteration:
first = None
if first:
state.wyoming_wake.set_detect([first])
state.current_remote_wake = first
else:
_LOGGER.warning("Wyoming enabled but no remote models available; not sending detect yet")
except Exception:
_LOGGER.exception("Failed to initialize remote detect")
process_audio_thread = threading.Thread(
target=process_audio, args=(state,), daemon=True
@@ -538,4 +646,4 @@ async def main() -> None:
if __name__ == "__main__":
asyncio.run(main())
asyncio.run(main())