mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
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:
parent
913aa7709b
commit
30ed3f82bd
4 changed files with 136 additions and 1 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue