fix(wake): reject ambient-speech false triggers with consecutive-frame confirmation

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
This commit is contained in:
Teknium 2026-07-27 21:30:23 -07:00
parent 913aa7709b
commit 30ed3f82bd
No known key found for this signature in database
4 changed files with 136 additions and 1 deletions

View file

@ -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": {

View file

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

View file

@ -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:

View file

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