"""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