From 30ed3f82bdbf4ab39b40a5d43f0c2e17119448fc Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:30:23 -0700 Subject: [PATCH] fix(wake): reject ambient-speech false triggers with consecutive-frame confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openWakeWord scores one ~80ms frame at a time and the detector fired the instant a SINGLE frame crossed threshold — so a stray phoneme in background conversation could trigger the wake word unintentionally (reported in testing). A real utterance of the phrase holds a high score across several consecutive frames; an ambient blip spikes just one. _OpenWakeWordEngine now requires N consecutive over-threshold frames (wake_word.confirmation_frames, default 3) before firing. The streak resets on any sub-threshold frame and on engine reset() (pause/resume), so a pre-pause frame can't count toward a post-resume fire. confirmation_frames=1 restores the old single-frame behavior; clamped 1..10. Only openWakeWord is affected — sherpa (streaming transducer) and porcupine decode the whole phrase internally and already reject single-frame spikes. - tools/wake_word.py: _confirmation_frames() accessor, streak logic in process()/reset(), config default - hermes_cli/config.py: wake_word.confirmation_frames documented default - tests: 5 new (spike rejected, sustained fires once, =1 legacy behavior, reset clears streak, config clamp) — 58 wake tests green - docs: 'Reducing false triggers on ambient speech' section --- hermes_cli/config.py | 1 + tests/tools/test_wake_word.py | 80 +++++++++++++++++++ tools/wake_word.py | 38 ++++++++- website/docs/user-guide/features/wake-word.md | 18 +++++ 4 files changed, 136 insertions(+), 1 deletion(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 65520f85032..9c8bff52a39 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2383,6 +2383,7 @@ DEFAULT_CONFIG = { "provider": "openwakeword", # "openwakeword" (free, local) | "sherpa" (free, ANY phrase, no training) | "porcupine" (premium; needs PORCUPINE_ACCESS_KEY) "phrase": "hey hermes", # for "sherpa" this IS the detected phrase (any text works); for other engines it's a cosmetic label — detection is keyed by the model/keyword below "sensitivity": 0.5, # 0.0-1.0 detection threshold (higher = stricter) + "confirmation_frames": 3, # openWakeWord only: consecutive over-threshold frames required to fire (higher = fewer false triggers on ambient speech, slightly more latency; 1 = old single-frame behavior) "start_new_session": True, # start a fresh session on wake vs. continue the current one "profile_routing": True, # sherpa only: also listen for every wake-enabled profile's phrase and route the wake to the matching profile "openwakeword": { diff --git a/tests/tools/test_wake_word.py b/tests/tools/test_wake_word.py index 0e91014286d..83097328de3 100644 --- a/tests/tools/test_wake_word.py +++ b/tests/tools/test_wake_word.py @@ -348,6 +348,86 @@ def test_explicit_framework_overrides_platform_default(monkeypatch): assert downloaded == [ww._bundled_wakeword_path("onnx")] +# ── ambient-speech rejection: consecutive-frame confirmation ────────────────── + +def _openwakeword_engine_with_scores(monkeypatch, cfg_wake, scores): + """Build a real _OpenWakeWordEngine whose predict() replays ``scores``.""" + seq = iter(scores) + + class _ScriptedModel: + def __init__(self, wakeword_models, inference_framework="onnx"): + self.models = {"hey_hermes": object()} + + def predict(self, frame): + return {"hey_hermes": next(seq)} + + def reset(self): + pass + + oww = types.ModuleType("openwakeword") + oww.utils = types.SimpleNamespace(download_models=lambda names=[]: None) + model_mod = types.ModuleType("openwakeword.model") + model_mod.Model = _ScriptedModel + monkeypatch.setitem(sys.modules, "openwakeword", oww) + monkeypatch.setitem(sys.modules, "openwakeword.model", model_mod) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *a, **k: None) + monkeypatch.setattr(ww, "ensure_tflite_runtime", lambda: True) + return ww._OpenWakeWordEngine({"provider": "openwakeword", **cfg_wake}) + + +def test_confirmation_frames_reject_single_frame_spike(monkeypatch): + # A lone over-threshold frame (ambient phoneme) must NOT fire with the + # default 3-frame confirmation; the streak resets on the next quiet frame. + eng = _openwakeword_engine_with_scores( + monkeypatch, + {"sensitivity": 0.5, "confirmation_frames": 3}, + [0.9, 0.0, 0.0, 0.9, 0.0], + ) + assert [eng.process(None) for _ in range(5)] == [False, False, False, False, False] + + +def test_confirmation_frames_fire_on_sustained_phrase(monkeypatch): + # Three consecutive over-threshold frames (a real utterance) fire exactly + # once, on the third frame. + eng = _openwakeword_engine_with_scores( + monkeypatch, + {"sensitivity": 0.5, "confirmation_frames": 3}, + [0.9, 0.9, 0.9, 0.0], + ) + assert [eng.process(None) for _ in range(4)] == [False, False, True, False] + + +def test_confirmation_frames_one_restores_single_frame_behavior(monkeypatch): + # confirmation_frames=1 is the old behavior: fire on the first frame. + eng = _openwakeword_engine_with_scores( + monkeypatch, + {"sensitivity": 0.5, "confirmation_frames": 1}, + [0.9, 0.0], + ) + assert eng.process(None) is True + + +def test_confirmation_streak_resets_on_engine_reset(monkeypatch): + # A pause (reset) between two over-threshold frames must not let a + # pre-pause frame count toward the post-resume streak. + eng = _openwakeword_engine_with_scores( + monkeypatch, + {"sensitivity": 0.5, "confirmation_frames": 2}, + [0.9, 0.9, 0.9], + ) + assert eng.process(None) is False # streak = 1 + eng.reset() # streak -> 0 + assert eng.process(None) is False # streak = 1 again, not 2 + assert eng.process(None) is True # streak = 2 -> fire + + +def test_confirmation_frames_config_clamped(monkeypatch): + assert ww._confirmation_frames({"confirmation_frames": 0}) == 1 + assert ww._confirmation_frames({"confirmation_frames": 99}) == 10 + assert ww._confirmation_frames({"confirmation_frames": "x"}) == ww._DEFAULT_CONFIRMATION_FRAMES + assert ww._confirmation_frames({}) == ww._DEFAULT_CONFIRMATION_FRAMES + + def test_macos_tflite_refuses_silent_onnx_downgrade(monkeypatch): # openWakeWord silently falls back to onnx when no tflite runtime imports. # On macOS ARM64 that lands on the broken backend, so we must raise instead diff --git a/tools/wake_word.py b/tools/wake_word.py index ad35562a6d7..93de79501e3 100644 --- a/tools/wake_word.py +++ b/tools/wake_word.py @@ -49,6 +49,13 @@ SAMPLE_RATE = 16000 _FIRE_COOLDOWN_SECONDS = 2.0 _START_TIMEOUT_SECONDS = 5.0 +# Ambient-speech rejection: openWakeWord scores one ~80ms frame at a time, and a +# stray phoneme in background conversation can spike a single frame over the +# threshold. A real utterance of the phrase holds the score high across several +# consecutive frames, so we require N-in-a-row above threshold before firing. +# This is the primary lever against unintended triggers on ambient talk. +_DEFAULT_CONFIRMATION_FRAMES = 3 + # Dead-mic detection: an int16 stream whose peak stays at/below this for this # many consecutive seconds is flagged as silent. macOS grants the *app* mic # permission per-process — a backend spawned without the entitlement gets a @@ -72,6 +79,7 @@ _DEFAULTS: Dict[str, Any] = { "provider": "openwakeword", "phrase": "hey hermes", "sensitivity": 0.5, + "confirmation_frames": _DEFAULT_CONFIRMATION_FRAMES, "start_new_session": True, } @@ -166,6 +174,21 @@ def _sensitivity(cfg: Dict[str, Any]) -> float: return min(max(s, 0.0), 1.0) +def _confirmation_frames(cfg: Dict[str, Any]) -> int: + """How many consecutive over-threshold frames are required to fire. + + ``1`` restores the old single-frame behaviour; higher values reject + ambient-speech blips at the cost of a few tens of ms of extra latency. + Clamped to a sane 1..10. + """ + raw = _get(cfg, "confirmation_frames") + try: + n = int(raw) + except (TypeError, ValueError): + n = _DEFAULT_CONFIRMATION_FRAMES + return min(max(n, 1), 10) + + def wake_phrase(cfg: Optional[Dict[str, Any]] = None) -> str: """Human-facing wake phrase label (purely cosmetic; engine keys detection).""" cfg = cfg if cfg is not None else load_wake_word_config() @@ -305,6 +328,8 @@ class _OpenWakeWordEngine(_Engine): if not framework: framework = default_inference_framework() self._threshold = _sensitivity(cfg) + self._confirm_needed = _confirmation_frames(cfg) + self._confirm_streak = 0 # openWakeWord silently downgrades tflite -> onnx when no tflite runtime # imports (model.py). On macOS ARM64 that lands on the backend whose @@ -348,11 +373,22 @@ class _OpenWakeWordEngine(_Engine): def process(self, frame) -> bool: scores = self._model.predict(frame) - return any(score >= self._threshold for score in scores.values()) + over = any(score >= self._threshold for score in scores.values()) + # Require N consecutive over-threshold frames: a real phrase holds the + # score high across frames, a stray ambient phoneme spikes just one. + if over: + self._confirm_streak += 1 + if self._confirm_streak >= self._confirm_needed: + self._confirm_streak = 0 + return True + return False + self._confirm_streak = 0 + return False def reset(self) -> None: # Clears openWakeWord's rolling feature/prediction buffer so stale audio # captured before a pause can't re-fire the moment we resume. + self._confirm_streak = 0 try: self._model.reset() except Exception: diff --git a/website/docs/user-guide/features/wake-word.md b/website/docs/user-guide/features/wake-word.md index 99fb65fbfa9..50d602406e7 100644 --- a/website/docs/user-guide/features/wake-word.md +++ b/website/docs/user-guide/features/wake-word.md @@ -77,6 +77,7 @@ wake_word: provider: openwakeword # "openwakeword" (free, local) | "porcupine" phrase: "hey hermes" # cosmetic label only — detection is keyed by the model/keyword below sensitivity: 0.5 # 0.0-1.0 — raise to reduce false triggers + confirmation_frames: 3 # openWakeWord only — consecutive over-threshold frames required to fire start_new_session: true # start a fresh session on wake vs. continue the current one openwakeword: model: hey_hermes # bundled default; OR a built-in name OR a path to a custom .onnx/.tflite @@ -88,6 +89,23 @@ wake_word: `sensitivity`, `phrase`, and `start_new_session` apply to both engines. The `openwakeword` and `porcupine` blocks select the actual detection model. +### Reducing false triggers on ambient speech + +openWakeWord scores one short (~80ms) audio frame at a time, so a stray phoneme +in background conversation can occasionally spike a single frame over the +threshold and fire the wake word unintentionally. Two knobs control this: + +- **`confirmation_frames`** (default `3`, openWakeWord only) — how many + *consecutive* over-threshold frames are required before the wake fires. A real + "hey hermes" holds a high score across several frames; an ambient blip spikes + just one. Raise it (e.g. `4`–`5`) if you still get false triggers in a noisy + room; the cost is a few tens of milliseconds of extra latency. `1` restores + the old fire-on-first-frame behavior. +- **`sensitivity`** — raise toward `1.0` to demand a higher score per frame. + +The `sherpa` and `porcupine` engines decode the whole phrase internally, so they +don't have the single-frame-spike problem and ignore `confirmation_frames`. + `inference_framework` picks the openWakeWord backend. Leave it empty (the default) to let Hermes choose per platform: **tflite on Apple Silicon**, onnx everywhere else. openWakeWord's onnx backend returns near-zero scores on macOS