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:
Brooklyn Nicholson 2026-06-27 11:31:37 -05:00 committed by Teknium
parent c597b4c47b
commit edb12bf423
No known key found for this signature in database
3 changed files with 46 additions and 5 deletions

View file

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

View file

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

View file

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