Add files via upload
This commit is contained in:
@@ -2,43 +2,95 @@ import json
|
||||
import logging
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, Optional
|
||||
from typing import Callable, Optional, List, Dict, Any
|
||||
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__)
|
||||
|
||||
|
||||
def _jsonl(obj: dict) -> bytes:
|
||||
return (json.dumps(obj, separators=(",", ":")) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
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):
|
||||
self.uri, self.name = uri, name
|
||||
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._reader: Optional[threading.Thread] = None
|
||||
self._on_detect: Optional[Callable[[str, Optional[int]], None]] = None
|
||||
self._closed = False
|
||||
self._chunks = 0
|
||||
self._last_log_time = 0.0
|
||||
# model discovery cache
|
||||
self._models_cache: List[Dict[str, Any]] = []
|
||||
self._info_event = threading.Event()
|
||||
|
||||
def connect(self, on_detect: Callable[[str, Optional[int]], None]) -> None:
|
||||
self._on_detect = on_detect
|
||||
parsed = urlparse(self.uri)
|
||||
if parsed.scheme != "tcp":
|
||||
raise ValueError(f"Only tcp:// URIs are supported (got {self.uri})")
|
||||
host = parsed.hostname or "127.0.0.1"
|
||||
port = parsed.port or 10400
|
||||
_LOGGER.debug("[OWW] Connecting to %s:%s", host, port)
|
||||
self._sock = socket.create_connection((host, port))
|
||||
self._sock = socket.create_connection((parsed.hostname or "127.0.0.1", parsed.port or 10400))
|
||||
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 }})
|
||||
self._arm_detect()
|
||||
# Start audio stream
|
||||
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.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:
|
||||
self._closed = True
|
||||
@@ -52,93 +104,261 @@ class WyomingWakeClient:
|
||||
finally:
|
||||
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:
|
||||
if self._sock is None or self._closed:
|
||||
return
|
||||
hdr = {
|
||||
"type": "audio-chunk",
|
||||
"data": {"rate": self.rate, "width": self.width, "channels": self.channels},
|
||||
"payload_length": len(pcm),
|
||||
}
|
||||
hdr = {"type": "audio-chunk",
|
||||
"data": {"rate": self.rate, "width": self.width, "channels": self.channels},
|
||||
"payload_length": len(pcm)}
|
||||
if timestamp_ms is not None:
|
||||
hdr["data"]["timestamp"] = timestamp_ms
|
||||
try:
|
||||
self._sock.sendall(_jsonl(hdr))
|
||||
self._sock.sendall(pcm)
|
||||
except Exception:
|
||||
_LOGGER.exception("[OWW] Failed to send audio chunk")
|
||||
return
|
||||
self._sock.sendall(_jsonl(hdr))
|
||||
self._sock.sendall(pcm)
|
||||
|
||||
|
||||
def pause(self) -> None:
|
||||
self._paused = True
|
||||
|
||||
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}})
|
||||
|
||||
self._chunks += 1
|
||||
if (self._chunks % 1000) == 0:
|
||||
# ~64ms * 1000 = ~64s of audio
|
||||
_LOGGER.debug("[OWW] Sent %d chunks (~%.1fs)", self._chunks, self._chunks * 0.064)
|
||||
|
||||
# internals
|
||||
def _send(self, obj: dict) -> None:
|
||||
assert self._sock is not None
|
||||
try:
|
||||
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")
|
||||
self._sock.sendall(_jsonl(obj))
|
||||
|
||||
# --- paste this to replace the whole _read_loop method ---
|
||||
def _read_loop(self) -> None:
|
||||
assert self._sock is not None
|
||||
f = self._sock.makefile("rb")
|
||||
_LOGGER.debug("[OWW] Reader loop started")
|
||||
buf = b""
|
||||
decoder = json.JSONDecoder()
|
||||
|
||||
buf = "" # text buffer for concatenated JSON objects
|
||||
|
||||
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:
|
||||
chunk = f.readline()
|
||||
if not chunk:
|
||||
_LOGGER.debug("[OWW] Reader EOF")
|
||||
break
|
||||
buf += chunk
|
||||
|
||||
try:
|
||||
# There might be multiple JSON objects in one line; parse until exhausted.
|
||||
while buf:
|
||||
s = buf.decode("utf-8")
|
||||
obj, idx = decoder.raw_decode(s)
|
||||
buf = s[idx:].lstrip().encode("utf-8")
|
||||
mtype = obj.get("type")
|
||||
if mtype and mtype != "detection":
|
||||
_LOGGER.debug("[OWW] recv %s", mtype)
|
||||
piece = chunk.decode("utf-8")
|
||||
except Exception:
|
||||
try:
|
||||
_LOGGER.debug("Wyoming raw (non-utf8): %r", chunk[:200])
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
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":
|
||||
data = obj.get("data", {}) or {}
|
||||
name = data.get("name") or self.name
|
||||
ts = data.get("timestamp")
|
||||
_LOGGER.info("[OWW] Detection: name=%s ts=%s (chunks=%d)", name, ts, self._chunks)
|
||||
is_detection = True
|
||||
elif mtype in ("event", "wake", "hotword"):
|
||||
ev = str(data.get("event") or "").lower()
|
||||
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:
|
||||
try:
|
||||
self._on_detect(name, ts)
|
||||
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)
|
||||
if getattr(self, '_refractory_ms', 0):
|
||||
self.suppress(self._refractory_ms)
|
||||
if getattr(self, '_refractory_ms', 0):
|
||||
self.suppress(self._refractory_ms)
|
||||
except Exception:
|
||||
_LOGGER.exception("[OWW] Error in detection callback")
|
||||
# Some servers need an audio stream restart before re-arming
|
||||
try:
|
||||
self._cycle_stream()
|
||||
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
|
||||
_LOGGER.exception("Error in detection callback")
|
||||
|
||||
# keep any unconsumed remainder in buffer
|
||||
buf = buf[pos:]
|
||||
Reference in New Issue
Block a user