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
+122 -14
View File
@@ -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
+294 -74
View File
@@ -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