From edb12bf4237fa76acdd9067cd40238663cdbfae7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 27 Jun 2026 11:31:37 -0500 Subject: [PATCH] fix(voice): stop wake re-fire loop and empty-transcript error spam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs surfaced by the desktop wake conversation: 1. Runaway loop: wake -> voice -> resume -> wake fired again within ~200ms. openWakeWord keeps its rolling feature buffer across pause/resume, so on resume it immediately re-scored the "hey jarvis" captured before the pause and re-fired, reopening a session and restarting voice in a tight cycle. Reset the engine buffer on every detector (re)start so resume begins from clean audio. 2. Empty-transcript toast: a silent re-listen returns success:false / "… STT returned empty transcript", which the desktop transcribe endpoint turned into a 400 -> thrown error -> "Voice transcription failed" notification on every silent gap. Treat an empty transcript as no-speech: return {ok, transcript: ""} so the voice loop quietly re-listens. Real failures still 4xx/5xx. --- hermes_cli/web_server.py | 13 +++++++++---- tests/tools/test_wake_word.py | 19 +++++++++++++++++++ tools/wake_word.py | 19 ++++++++++++++++++- 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 497d740e1c12..358000458a64 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -4417,10 +4417,15 @@ async def transcribe_audio_upload(payload: AudioTranscriptionRequest): pass if not result.get("success"): - raise HTTPException( - status_code=400, - detail=result.get("error") or "Transcription failed", - ) + err = result.get("error") or "Transcription failed" + # An empty transcript means no speech was detected — a normal outcome + # for VAD/continuous voice loops (e.g. a wake-word conversation + # re-listening on silence), not an error. Return an empty transcript so + # the client quietly re-listens instead of surfacing a "transcription + # failed" toast on every silent gap. + if "empty transcript" in err.lower(): + return {"ok": True, "transcript": "", "provider": result.get("provider")} + raise HTTPException(status_code=400, detail=err) return { "ok": True, diff --git a/tests/tools/test_wake_word.py b/tests/tools/test_wake_word.py index 658b4e1e7c9a..e765e4ac5eee 100644 --- a/tests/tools/test_wake_word.py +++ b/tests/tools/test_wake_word.py @@ -136,10 +136,14 @@ class _FakeEngine: def __init__(self, fire=True): self._fire = fire self.closed = False + self.resets = 0 def process(self, frame): return self._fire + def reset(self): + self.resets += 1 + def close(self): self.closed = True @@ -182,6 +186,21 @@ def test_detector_no_fire_when_engine_quiet(monkeypatch): assert calls == [] +def test_detector_resets_engine_on_each_start(monkeypatch): + # Clearing the engine buffer on (re)start is what stops a resume right after + # a voice turn from re-firing on stale audio (the runaway wake loop). + _fake_audio(monkeypatch) + eng = _FakeEngine(fire=False) + det = ww.WakeWordDetector(eng, lambda: None) + det.start() + time.sleep(0.05) + det.pause() + det.resume() + time.sleep(0.05) + det.stop() + assert eng.resets >= 2 # initial start + resume + + def test_detector_pause_resume(monkeypatch): _fake_audio(monkeypatch) det = ww.WakeWordDetector(_FakeEngine(fire=False), lambda: None) diff --git a/tools/wake_word.py b/tools/wake_word.py index 75d98fc8fca6..b899a56d06fd 100644 --- a/tools/wake_word.py +++ b/tools/wake_word.py @@ -140,6 +140,10 @@ class _Engine: def process(self, frame) -> bool: # frame: 1-D int16 ndarray raise NotImplementedError + def reset(self) -> None: + """Clear any internal audio/feature buffer (called on every (re)start).""" + pass + def close(self) -> None: pass @@ -189,12 +193,17 @@ class _OpenWakeWordEngine(_Engine): scores = self._model.predict(frame) return any(score >= self._threshold for score in scores.values()) - def close(self) -> None: + 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. try: self._model.reset() except Exception: pass + def close(self) -> None: + self.reset() + class _PorcupineEngine(_Engine): """Picovoice Porcupine — premium, on-device, needs an access key.""" @@ -356,6 +365,14 @@ class WakeWordDetector: logger.error("wake word: failed to open microphone: %s", e) return + # Drop any buffered audio/feature state so a resume right after a voice + # turn can't immediately re-fire on audio captured before the pause (the + # wake → voice → resume → wake runaway loop). + try: + self.engine.reset() + except Exception: + pass + logger.info("wake word: listening (frame=%d, rate=%d)", frame_length, SAMPLE_RATE) try: while not self._stop.is_set():