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
+299 -79
View File
@@ -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:]