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():