mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
fix(voice): stop wake re-fire loop and empty-transcript error spam
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.
This commit is contained in:
parent
c597b4c47b
commit
edb12bf423
3 changed files with 46 additions and 5 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue