Add files via upload
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import os
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -8,10 +9,26 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
|
||||||
from queue import Queue
|
from queue import Queue
|
||||||
from typing import Dict, List, Optional
|
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
|
# pylint: disable=no-name-in-module
|
||||||
import sounddevice as sd
|
import sounddevice as sd
|
||||||
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
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_bus import EventBus
|
||||||
from .event_led import LedEvent
|
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__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
_MODULE_DIR = Path(__file__).parent
|
_MODULE_DIR = Path(__file__).parent
|
||||||
@@ -86,7 +114,8 @@ class ServerState:
|
|||||||
event_bus: EventBus
|
event_bus: EventBus
|
||||||
media_player_entity: Optional[MediaPlayerEntity] = None
|
media_player_entity: Optional[MediaPlayerEntity] = None
|
||||||
satellite: "Optional[VoiceSatelliteProtocol]" = 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
|
use_wyoming_wake: bool = False
|
||||||
|
|
||||||
|
|
||||||
@@ -231,7 +260,7 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
)
|
)
|
||||||
for ww in self.state.available_wake_words.values()
|
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,
|
max_active_wake_words=1,
|
||||||
)
|
)
|
||||||
_LOGGER.info("Connected to Home Assistant")
|
_LOGGER.info("Connected to Home Assistant")
|
||||||
@@ -242,6 +271,15 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
# Already active
|
# Already active
|
||||||
break
|
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)
|
model_info = self.state.available_wake_words.get(wake_word_id)
|
||||||
if not model_info:
|
if not model_info:
|
||||||
continue
|
continue
|
||||||
@@ -270,8 +308,12 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
_LOGGER.debug("Stopping timer finished sound")
|
_LOGGER.debug("Stopping timer finished sound")
|
||||||
return
|
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)
|
_LOGGER.debug("Detected wake word: %s", wake_word_phrase)
|
||||||
|
try:
|
||||||
|
self.state.wyoming_wake.suppress(2500)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self.send_messages(
|
self.send_messages(
|
||||||
[VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)]
|
[VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)]
|
||||||
)
|
)
|
||||||
@@ -348,9 +390,6 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
|
|
||||||
|
|
||||||
def process_audio(state: ServerState):
|
def process_audio(state: ServerState):
|
||||||
# debug counters
|
|
||||||
chunks_sent = 0
|
|
||||||
last_log = 0.0
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
@@ -363,9 +402,6 @@ def process_audio(state: ServerState):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
state.satellite.handle_audio(audio_chunk)
|
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.use_wyoming_wake:
|
||||||
if state.wyoming_wake:
|
if state.wyoming_wake:
|
||||||
@@ -395,6 +431,7 @@ async def main() -> None:
|
|||||||
parser.add_argument("--name", required=True)
|
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-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-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(
|
parser.add_argument(
|
||||||
"--audio-input-device",
|
"--audio-input-device",
|
||||||
default="default",
|
default="default",
|
||||||
@@ -433,11 +470,14 @@ async def main() -> None:
|
|||||||
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
|
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
|
||||||
_LOGGER.debug(args)
|
_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
|
wyoming_client = None
|
||||||
if use_wyoming:
|
if use_wyoming:
|
||||||
_LOGGER.info("Using Wyoming openWakeWord at %s (name=%s)", args.wake_uri, args.wake_word_name)
|
_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
|
# Load available wake words
|
||||||
wake_word_dir = Path(args.wake_word_dir)
|
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())))
|
_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"
|
libtensorflowlite_c_path = _LIB_DIR / "libtensorflowlite_c.so"
|
||||||
_LOGGER.debug("libtensorflowlite_c path: %s", libtensorflowlite_c_path)
|
_LOGGER.debug("libtensorflowlite_c path: %s", libtensorflowlite_c_path)
|
||||||
|
|
||||||
@@ -496,12 +558,58 @@ async def main() -> None:
|
|||||||
# Connect to Wyoming wake server if enabled
|
# Connect to Wyoming wake server if enabled
|
||||||
if state.use_wyoming_wake and state.wyoming_wake:
|
if state.use_wyoming_wake and state.wyoming_wake:
|
||||||
def _on_detect(_name, _ts):
|
def _on_detect(_name, _ts):
|
||||||
_LOGGER.debug("[OWW] detection callback fired name=%s ts=%s", _name, _ts)
|
|
||||||
if state.satellite is not None:
|
if state.satellite is not None:
|
||||||
state.loop.call_soon_threadsafe(lambda: state.satellite.wakeup())
|
state.loop.call_soon_threadsafe(lambda: state.satellite.wakeup())
|
||||||
state.wyoming_wake.connect(_on_detect)
|
state.wyoming_wake.connect(_on_detect)
|
||||||
|
# Disable local wake model to avoid double triggers
|
||||||
state.wake_word.is_active = False
|
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(
|
process_audio_thread = threading.Thread(
|
||||||
target=process_audio, args=(state,), daemon=True
|
target=process_audio, args=(state,), daemon=True
|
||||||
|
|||||||
@@ -2,43 +2,95 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import socket
|
import socket
|
||||||
import threading
|
import threading
|
||||||
import time
|
from typing import Callable, Optional, List, Dict, Any
|
||||||
from typing import Callable, Optional
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
from pathlib import Path
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Models that should NOT appear in UI dropdowns
|
||||||
|
EXCLUDED_OWW_MODELS = {"embedding_model", "melspectrogram"}
|
||||||
|
|
||||||
|
|
||||||
|
# Models that are not actual wake-word detectors and should never be shown
|
||||||
|
_OWW_DETECT_MODEL_DENYLIST = {"embedding_model", "melspectrogram"}
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
def _prettify_model_name(name: str) -> str:
|
||||||
|
"""
|
||||||
|
Convert 'hey_jarvis_v0.1' -> 'Hey Jarvis', 'ok_nabu_v0.1' -> 'OK Nabu',
|
||||||
|
'hal_v2' -> 'Hal', 'marvin_v2' -> 'Marvin'.
|
||||||
|
"""
|
||||||
|
stem = Path(name).stem
|
||||||
|
# Strip common version suffixes
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_model_name(name: str) -> str:
|
||||||
|
# Accept "foo_v2", "foo_v2.tflite", full paths, etc.
|
||||||
|
stem = Path(name).stem
|
||||||
|
return stem
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_detect_models(models: list[str]) -> list[str]:
|
||||||
|
# Remove helper models and de-duplicate
|
||||||
|
normalized = {_normalize_model_name(m) for m in models}
|
||||||
|
return sorted(m for m in normalized if m not in _OWW_DETECT_MODEL_DENYLIST)
|
||||||
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _jsonl(obj: dict) -> bytes:
|
def _jsonl(obj: dict) -> bytes:
|
||||||
return (json.dumps(obj, separators=(",", ":")) + "\n").encode("utf-8")
|
return (json.dumps(obj, separators=(",", ":")) + "\n").encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
class WyomingWakeClient:
|
class WyomingWakeClient:
|
||||||
"""Minimal client for Wyoming wake detection (openWakeWord) with extra debug."""
|
"""Minimal client for Wyoming wake detection (openWakeWord)."""
|
||||||
|
|
||||||
def __init__(self, uri: str, name: str, rate=16000, width=2, channels=1):
|
def __init__(self, uri: str, name: str, rate=16000, width=2, channels=1):
|
||||||
self.uri, self.name = uri, name
|
self.uri, self.name = uri, name
|
||||||
self.rate, self.width, self.channels = rate, width, channels
|
self.rate, self.width, self.channels = rate, width, channels
|
||||||
|
self._paused = False
|
||||||
|
self._suppress_until_ms = 0
|
||||||
|
self._refractory_ms = 0
|
||||||
self._sock: Optional[socket.socket] = None
|
self._sock: Optional[socket.socket] = None
|
||||||
self._reader: Optional[threading.Thread] = None
|
self._reader: Optional[threading.Thread] = None
|
||||||
self._on_detect: Optional[Callable[[str, Optional[int]], None]] = None
|
self._on_detect: Optional[Callable[[str, Optional[int]], None]] = None
|
||||||
self._closed = False
|
self._closed = False
|
||||||
self._chunks = 0
|
# model discovery cache
|
||||||
self._last_log_time = 0.0
|
self._models_cache: List[Dict[str, Any]] = []
|
||||||
|
self._info_event = threading.Event()
|
||||||
|
|
||||||
def connect(self, on_detect: Callable[[str, Optional[int]], None]) -> None:
|
def connect(self, on_detect: Callable[[str, Optional[int]], None]) -> None:
|
||||||
self._on_detect = on_detect
|
self._on_detect = on_detect
|
||||||
parsed = urlparse(self.uri)
|
parsed = urlparse(self.uri)
|
||||||
if parsed.scheme != "tcp":
|
if parsed.scheme != "tcp":
|
||||||
raise ValueError(f"Only tcp:// URIs are supported (got {self.uri})")
|
raise ValueError(f"Only tcp:// URIs are supported (got {self.uri})")
|
||||||
host = parsed.hostname or "127.0.0.1"
|
self._sock = socket.create_connection((parsed.hostname or "127.0.0.1", parsed.port or 10400))
|
||||||
port = parsed.port or 10400
|
|
||||||
_LOGGER.debug("[OWW] Connecting to %s:%s", host, port)
|
|
||||||
self._sock = socket.create_connection((host, port))
|
|
||||||
self._sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
self._sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||||
self._send({ "type": "audio-start", "data": { "rate": self.rate, "width": self.width, "channels": self.channels }})
|
# Start audio stream
|
||||||
self._arm_detect()
|
self._send({"type": "audio-start", "data": {"rate": self.rate, "width": self.width, "channels": self.channels}})
|
||||||
|
# Ask for model info
|
||||||
|
try:
|
||||||
|
self._send({"type": "describe"})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Set initial detect model
|
||||||
|
if self.name:
|
||||||
|
try:
|
||||||
|
self._send({"type": "detect", "data": {"names": [self.name]}})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self._reader = threading.Thread(target=self._read_loop, daemon=True)
|
self._reader = threading.Thread(target=self._read_loop, daemon=True)
|
||||||
self._reader.start()
|
self._reader.start()
|
||||||
_LOGGER.info("[OWW] Connected to Wyoming wake server at %s", self.uri)
|
_LOGGER.debug("Connected to Wyoming wake server at %s", self.uri)
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
self._closed = True
|
self._closed = True
|
||||||
@@ -52,93 +104,261 @@ class WyomingWakeClient:
|
|||||||
finally:
|
finally:
|
||||||
self._sock = None
|
self._sock = None
|
||||||
|
|
||||||
def _arm_detect(self) -> None:
|
|
||||||
_LOGGER.debug("[OWW] Arming detect for name=%s", self.name)
|
|
||||||
self._send({"type": "detect", "data": {"names": [self.name]}})
|
|
||||||
|
|
||||||
def send_audio_chunk(self, pcm: bytes, timestamp_ms: Optional[int] = None) -> None:
|
def send_audio_chunk(self, pcm: bytes, timestamp_ms: Optional[int] = None) -> None:
|
||||||
if self._sock is None or self._closed:
|
if self._sock is None or self._closed:
|
||||||
return
|
return
|
||||||
hdr = {
|
hdr = {"type": "audio-chunk",
|
||||||
"type": "audio-chunk",
|
|
||||||
"data": {"rate": self.rate, "width": self.width, "channels": self.channels},
|
"data": {"rate": self.rate, "width": self.width, "channels": self.channels},
|
||||||
"payload_length": len(pcm),
|
"payload_length": len(pcm)}
|
||||||
}
|
|
||||||
if timestamp_ms is not None:
|
if timestamp_ms is not None:
|
||||||
hdr["data"]["timestamp"] = timestamp_ms
|
hdr["data"]["timestamp"] = timestamp_ms
|
||||||
try:
|
|
||||||
self._sock.sendall(_jsonl(hdr))
|
self._sock.sendall(_jsonl(hdr))
|
||||||
self._sock.sendall(pcm)
|
self._sock.sendall(pcm)
|
||||||
except Exception:
|
|
||||||
_LOGGER.exception("[OWW] Failed to send audio chunk")
|
|
||||||
return
|
|
||||||
|
|
||||||
self._chunks += 1
|
|
||||||
if (self._chunks % 1000) == 0:
|
def pause(self) -> None:
|
||||||
# ~64ms * 1000 = ~64s of audio
|
self._paused = True
|
||||||
_LOGGER.debug("[OWW] Sent %d chunks (~%.1fs)", self._chunks, self._chunks * 0.064)
|
|
||||||
|
def resume(self) -> None:
|
||||||
|
self._paused = False
|
||||||
|
|
||||||
|
def suppress(self, ms: int) -> None:
|
||||||
|
try:
|
||||||
|
now_ms = int(time.time() * 1000)
|
||||||
|
except Exception:
|
||||||
|
now_ms = 0
|
||||||
|
self._suppress_until_ms = max(self._suppress_until_ms, now_ms + int(ms))
|
||||||
|
|
||||||
|
def set_refractory(self, ms: int) -> None:
|
||||||
|
self._refractory_ms = max(0, int(ms))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def list_models(self):
|
||||||
|
"""Query models using a short-lived connection with timeout; accept 'describe' or 'info'."""
|
||||||
|
parsed = urlparse(self.uri)
|
||||||
|
if parsed.scheme != "tcp":
|
||||||
|
raise ValueError(f"Only tcp:// URIs are supported (got {self.uri})")
|
||||||
|
host, port = (parsed.hostname or "127.0.0.1", parsed.port or 10400)
|
||||||
|
s = socket.create_connection((host, port))
|
||||||
|
try:
|
||||||
|
s.settimeout(1.5)
|
||||||
|
s.sendall(_jsonl({"type": "describe"}))
|
||||||
|
f = s.makefile("rb")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
line = f.readline()
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
msg = json.loads(line.decode("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
mtype = msg.get("type")
|
||||||
|
if mtype in ("describe", "info"):
|
||||||
|
data = msg.get("data", {}) or {}
|
||||||
|
wake = data.get("wake", {}) or {}
|
||||||
|
models = wake.get("models") or wake.get("names") or []
|
||||||
|
norm: List[Dict[str, Any]] = []
|
||||||
|
for m in models:
|
||||||
|
if isinstance(m, str):
|
||||||
|
norm.append({"name": m})
|
||||||
|
elif isinstance(m, dict):
|
||||||
|
norm.append(m)
|
||||||
|
out: List[Dict[str, Any]] = []
|
||||||
|
for m in norm:
|
||||||
|
name = (m.get('name') or '').strip()
|
||||||
|
if not name or name in EXCLUDED_OWW_MODELS:
|
||||||
|
continue
|
||||||
|
phrase = (m.get('phrase') or '').strip()
|
||||||
|
desc = (m.get('description') or '').strip()
|
||||||
|
try:
|
||||||
|
label = phrase or desc or _prettify_model_name(name) # type: ignore[name-defined]
|
||||||
|
except NameError:
|
||||||
|
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]
|
||||||
|
label = " ".join(parts)
|
||||||
|
mm = dict(m)
|
||||||
|
mm['label'] = label
|
||||||
|
out.append(mm)
|
||||||
|
return out
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
s.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_models(self, timeout: float = 1.5):
|
||||||
|
"""Return cached models after waiting briefly for an 'info'/'describe' update."""
|
||||||
|
self._info_event.wait(timeout)
|
||||||
|
return list(self._models_cache)
|
||||||
|
|
||||||
|
def set_detect(self, names):
|
||||||
|
if self._sock is None:
|
||||||
|
raise RuntimeError("Not connected")
|
||||||
|
filtered = [n for n in list(names) if n]
|
||||||
|
if not filtered:
|
||||||
|
_LOGGER.warning("Ignoring empty detect() request (no model names)")
|
||||||
|
return
|
||||||
|
self.name = filtered[0]
|
||||||
|
_LOGGER.debug("Requesting Wyoming detect models: %s", filtered)
|
||||||
|
self._send({"type": "detect", "data": {"names": filtered}})
|
||||||
|
|
||||||
|
|
||||||
# internals
|
# internals
|
||||||
def _send(self, obj: dict) -> None:
|
def _send(self, obj: dict) -> None:
|
||||||
assert self._sock is not None
|
assert self._sock is not None
|
||||||
try:
|
|
||||||
self._sock.sendall(_jsonl(obj))
|
self._sock.sendall(_jsonl(obj))
|
||||||
except Exception:
|
|
||||||
_LOGGER.exception("[OWW] send failed (%s)", obj.get("type"))
|
|
||||||
|
|
||||||
def _cycle_stream(self) -> None:
|
|
||||||
"""Some servers require restarting the audio stream after a detection."""
|
|
||||||
_LOGGER.debug("[OWW] Cycling audio stream")
|
|
||||||
try:
|
|
||||||
self._send({"type": "audio-stop"})
|
|
||||||
except Exception:
|
|
||||||
_LOGGER.exception("[OWW] audio-stop failed")
|
|
||||||
try:
|
|
||||||
self._send({"type": "audio-start", "data": {"rate": self.rate, "width": self.width, "channels": self.channels}})
|
|
||||||
except Exception:
|
|
||||||
_LOGGER.exception("[OWW] audio-start failed")
|
|
||||||
|
|
||||||
|
# --- paste this to replace the whole _read_loop method ---
|
||||||
def _read_loop(self) -> None:
|
def _read_loop(self) -> None:
|
||||||
assert self._sock is not None
|
assert self._sock is not None
|
||||||
f = self._sock.makefile("rb")
|
f = self._sock.makefile("rb")
|
||||||
_LOGGER.debug("[OWW] Reader loop started")
|
|
||||||
buf = b""
|
buf = "" # text buffer for concatenated JSON objects
|
||||||
decoder = json.JSONDecoder()
|
|
||||||
|
def _yield_objects_from_text(text: str):
|
||||||
|
"""Yield JSON objects from `text` even if they are concatenated like {}{}..."""
|
||||||
|
start = 0
|
||||||
|
depth = 0
|
||||||
|
in_str = False
|
||||||
|
escape = False
|
||||||
|
for i, ch in enumerate(text):
|
||||||
|
if in_str:
|
||||||
|
if escape:
|
||||||
|
escape = False
|
||||||
|
elif ch == "\\":
|
||||||
|
escape = True
|
||||||
|
elif ch == '"':
|
||||||
|
in_str = False
|
||||||
|
else:
|
||||||
|
if ch == '"':
|
||||||
|
in_str = True
|
||||||
|
elif ch == "{":
|
||||||
|
if depth == 0:
|
||||||
|
start = i
|
||||||
|
depth += 1
|
||||||
|
elif ch == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
# complete object from start..i
|
||||||
|
yield text[start : i + 1], i + 1
|
||||||
|
# return here with any remainder unparsed
|
||||||
|
return
|
||||||
|
|
||||||
while not self._closed:
|
while not self._closed:
|
||||||
chunk = f.readline()
|
chunk = f.readline()
|
||||||
if not chunk:
|
if not chunk:
|
||||||
_LOGGER.debug("[OWW] Reader EOF")
|
|
||||||
break
|
break
|
||||||
buf += chunk
|
|
||||||
try:
|
try:
|
||||||
# There might be multiple JSON objects in one line; parse until exhausted.
|
piece = chunk.decode("utf-8")
|
||||||
while buf:
|
except Exception:
|
||||||
s = buf.decode("utf-8")
|
try:
|
||||||
obj, idx = decoder.raw_decode(s)
|
_LOGGER.debug("Wyoming raw (non-utf8): %r", chunk[:200])
|
||||||
buf = s[idx:].lstrip().encode("utf-8")
|
except Exception:
|
||||||
mtype = obj.get("type")
|
pass
|
||||||
if mtype and mtype != "detection":
|
continue
|
||||||
_LOGGER.debug("[OWW] recv %s", mtype)
|
|
||||||
|
buf += piece
|
||||||
|
|
||||||
|
# Try to peel off as many complete JSON objects as we can
|
||||||
|
pos = 0
|
||||||
|
progressed = True
|
||||||
|
while progressed and pos < len(buf):
|
||||||
|
progressed = False
|
||||||
|
for obj_text, end_pos in _yield_objects_from_text(buf[pos:]):
|
||||||
|
progressed = True
|
||||||
|
pos += end_pos
|
||||||
|
try:
|
||||||
|
msg = json.loads(obj_text)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
_LOGGER.debug("Wyoming raw (bad-json): %r", obj_text[:200])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
|
|
||||||
|
mtype = str(msg.get("type") or "").lower()
|
||||||
|
try:
|
||||||
|
_LOGGER.debug("Received Wyoming message type=%s", mtype or "<?>")
|
||||||
|
if mtype and mtype not in ("info", "describe"):
|
||||||
|
_LOGGER.debug("Wyoming raw: %r", obj_text[:200])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ----- model info path -----
|
||||||
|
if mtype in ("info", "describe"):
|
||||||
|
data = msg.get("data", {}) or {}
|
||||||
|
wake = data.get("wake", {}) if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
models = []
|
||||||
|
if isinstance(wake, dict):
|
||||||
|
if isinstance(wake.get("models"), list):
|
||||||
|
models = wake.get("models")
|
||||||
|
elif isinstance(wake.get("names"), list):
|
||||||
|
models = wake.get("names")
|
||||||
|
|
||||||
|
norm = []
|
||||||
|
for m in models:
|
||||||
|
if isinstance(m, str):
|
||||||
|
norm.append({"name": m})
|
||||||
|
elif isinstance(m, dict):
|
||||||
|
norm.append(m)
|
||||||
|
|
||||||
|
if norm:
|
||||||
|
self._models_cache = norm
|
||||||
|
self._info_event.set()
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Wyoming model list updated: %s",
|
||||||
|
[m.get("name") for m in norm],
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ----- detection path (accept several shapes) -----
|
||||||
|
data = msg.get("data", {}) or {}
|
||||||
|
is_detection = False
|
||||||
if mtype == "detection":
|
if mtype == "detection":
|
||||||
data = obj.get("data", {}) or {}
|
is_detection = True
|
||||||
name = data.get("name") or self.name
|
elif mtype in ("event", "wake", "hotword"):
|
||||||
ts = data.get("timestamp")
|
ev = str(data.get("event") or "").lower()
|
||||||
_LOGGER.info("[OWW] Detection: name=%s ts=%s (chunks=%d)", name, ts, self._chunks)
|
if ev in ("detection", "wake", "hotword"):
|
||||||
|
is_detection = True
|
||||||
|
|
||||||
|
if is_detection:
|
||||||
|
name = (
|
||||||
|
data.get("name")
|
||||||
|
or data.get("model")
|
||||||
|
or data.get("wakeword")
|
||||||
|
or self.name
|
||||||
|
)
|
||||||
|
ts = data.get("timestamp") or data.get("time") or data.get("ts")
|
||||||
|
_LOGGER.debug("Wake detected: %s ts=%s", name, ts)
|
||||||
if self._on_detect:
|
if self._on_detect:
|
||||||
try:
|
try:
|
||||||
|
now_ms = int(time.time() * 1000)
|
||||||
|
if self._paused or (self._suppress_until_ms and now_ms < self._suppress_until_ms):
|
||||||
|
_LOGGER.debug("Detection suppressed (paused=%s until=%s)", self._paused, getattr(self, '_suppress_until_ms', 0))
|
||||||
|
else:
|
||||||
|
now_ms = int(time.time() * 1000)
|
||||||
|
if self._paused or (self._suppress_until_ms and now_ms < self._suppress_until_ms):
|
||||||
|
_LOGGER.debug("Detection suppressed (paused=%s until=%s)", self._paused, getattr(self, '_suppress_until_ms', 0))
|
||||||
|
else:
|
||||||
self._on_detect(name, ts)
|
self._on_detect(name, ts)
|
||||||
|
if getattr(self, '_refractory_ms', 0):
|
||||||
|
self.suppress(self._refractory_ms)
|
||||||
|
if getattr(self, '_refractory_ms', 0):
|
||||||
|
self.suppress(self._refractory_ms)
|
||||||
except Exception:
|
except Exception:
|
||||||
_LOGGER.exception("[OWW] Error in detection callback")
|
_LOGGER.exception("Error in detection callback")
|
||||||
# Some servers need an audio stream restart before re-arming
|
|
||||||
try:
|
# keep any unconsumed remainder in buffer
|
||||||
self._cycle_stream()
|
buf = buf[pos:]
|
||||||
except Exception:
|
|
||||||
_LOGGER.exception("[OWW] Failed to cycle stream")
|
|
||||||
# Re-arm detect so subsequent detections work
|
|
||||||
try:
|
|
||||||
self._arm_detect()
|
|
||||||
except Exception:
|
|
||||||
_LOGGER.exception("[OWW] Failed to re-arm detect")
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
# Need more bytes; continue accumulating
|
|
||||||
pass
|
|
||||||
Reference in New Issue
Block a user