From d0bd1410820d4271c201e9cf9588c7e85e89cbfd Mon Sep 17 00:00:00 2001 From: Assistant builder Date: Thu, 11 Jun 2026 22:49:40 +0000 Subject: [PATCH] Plan 3: openwakeword wrapper with threshold + cooldown --- client/wakeword.py | 47 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 client/wakeword.py diff --git a/client/wakeword.py b/client/wakeword.py new file mode 100644 index 0000000..049b5b6 --- /dev/null +++ b/client/wakeword.py @@ -0,0 +1,47 @@ +"""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)) + now = time.monotonic() + if score >= self._threshold and (now - self._last_fired) >= self._cooldown_s: + self._last_fired = now + _log.info("wakeword fired score=%.3f", score) + return True + return False