d01b78b225
Plan 3 smoke surfaced 'session_ended reason=error' on every wake with no context. The relay now logs every upstream event type at INFO and forwards any upstream 'error' event to the device as 'error code=upstream_<code>' so it appears in the Pi's journal alongside the wake/session_ended pair. Also extends the client's wakeword.predict with per-frame RMS + score diagnostics and the bridge loop with a 5 s heartbeat for triage.
53 lines
1.9 KiB
Python
53 lines
1.9 KiB
Python
"""openwakeword wrapper for the Pi client.
|
|
|
|
Loads the stock `alexa` model. Each call to `predict()` takes a 1280-sample
|
|
16 kHz mono int16 frame (produced by `client.audio.resample_24k_to_16k`).
|
|
Returns True at most once per cooldown window.
|
|
"""
|
|
import time
|
|
|
|
import numpy as np
|
|
|
|
from client.log import get_logger
|
|
|
|
_log = get_logger("wakeword")
|
|
|
|
WAKEWORD = "alexa"
|
|
DEFAULT_THRESHOLD = 0.5
|
|
DEFAULT_COOLDOWN_S = 1.5
|
|
|
|
|
|
class WakewordDetector:
|
|
def __init__(self, *, threshold: float = DEFAULT_THRESHOLD,
|
|
cooldown_s: float = DEFAULT_COOLDOWN_S) -> None:
|
|
# Lazy-import openwakeword to keep the test suite importable without it.
|
|
import openwakeword.utils
|
|
from openwakeword.model import Model
|
|
|
|
_log.info("downloading openwakeword models (idempotent)")
|
|
# No-arg form dodges the None-handling crash documented in findings.md §4.
|
|
openwakeword.utils.download_models()
|
|
_log.info("loading openwakeword model %r", WAKEWORD)
|
|
t0 = time.monotonic()
|
|
self._model = Model(wakeword_models=[WAKEWORD], inference_framework="onnx")
|
|
_log.info("openwakeword model loaded in %.2fs", time.monotonic() - t0)
|
|
|
|
self._threshold = threshold
|
|
self._cooldown_s = cooldown_s
|
|
self._last_fired = 0.0
|
|
|
|
def predict(self, frame_16k_int16: np.ndarray) -> bool:
|
|
scores = self._model.predict(frame_16k_int16)
|
|
score = float(scores.get(WAKEWORD, 0.0))
|
|
rms = float(np.sqrt(np.mean(frame_16k_int16.astype(np.float32) ** 2)))
|
|
now = time.monotonic()
|
|
# Diagnostic: log any non-trivial score so we can see what's getting
|
|
# close to firing.
|
|
if score >= 0.1:
|
|
_log.info("score=%.3f rms=%.0f", score, rms)
|
|
if score >= self._threshold and (now - self._last_fired) >= self._cooldown_s:
|
|
self._last_fired = now
|
|
_log.info("wakeword fired score=%.3f rms=%.0f", score, rms)
|
|
return True
|
|
return False
|