Add files via upload
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user