From f03bb2b4ef9e7230333080cc9774ae3e39124ff4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:26:20 -0700 Subject: [PATCH] feat(wake): gate arming on STT + TTS readiness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wake loop is wake → record → STT → agent → TTS. Arming without either end configured gives a mic that hears you and then does nothing perceivable — a useless experience. check_wake_word_requirements now probes both (same probes /voice uses: stt.enabled + provider != none; check_tts_requirements) and refuses with a pointer to `hermes tools` naming exactly which half is missing. The desktop ear hides (available: false already hides the button), /wake on prints the hint on CLI/TUI. wake.start also validates requirements BEFORE persisting wake_word.enabled, so a refused gesture can't leave config claiming on while nothing can ever arm. Tests: per-half and both-missing hint assertions; existing requirements tests pinned via _voice_loop_ready so they don't depend on the test venv's installed voice stack. E2E: stt.enabled=false in a real config -> unavailable with the speech-to-text hint. --- tests/tools/test_wake_word.py | 33 +++++++++++++ tools/wake_word.py | 47 ++++++++++++++++++- tui_gateway/server.py | 19 ++++---- website/docs/user-guide/features/wake-word.md | 3 ++ 4 files changed, 93 insertions(+), 9 deletions(-) diff --git a/tests/tools/test_wake_word.py b/tests/tools/test_wake_word.py index 0bcab0486071..2eb99edbccba 100644 --- a/tests/tools/test_wake_word.py +++ b/tests/tools/test_wake_word.py @@ -83,7 +83,15 @@ def test_build_engine_dispatch(monkeypatch): # ── Requirements probe ─────────────────────────────────────────────────── +def _voice_loop_ready(monkeypatch, stt=True, tts=True): + """Pin the STT/TTS probes so requirements tests don't depend on the + test venv's installed voice stack.""" + monkeypatch.setattr(ww, "_stt_ready", lambda: stt) + monkeypatch.setattr(ww, "_tts_ready", lambda: tts) + + def test_requirements_openwakeword_available(monkeypatch): + _voice_loop_ready(monkeypatch) monkeypatch.setattr(ww, "_audio_available", lambda: True) monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: True) r = ww.check_wake_word_requirements( @@ -94,6 +102,30 @@ def test_requirements_openwakeword_available(monkeypatch): assert r["phrase"] == "hey hermes" +def test_requirements_need_stt_and_tts(monkeypatch): + """No STT/TTS → wake refuses to arm (mic would hear you, then nothing).""" + monkeypatch.setattr(ww, "_audio_available", lambda: True) + monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: True) + + _voice_loop_ready(monkeypatch, stt=False, tts=True) + r = ww.check_wake_word_requirements({"provider": "openwakeword"}) + assert r["available"] is False + assert r["stt_available"] is False + assert "speech-to-text" in r["hint"] + assert "text-to-speech" not in r["hint"] + + _voice_loop_ready(monkeypatch, stt=True, tts=False) + r = ww.check_wake_word_requirements({"provider": "openwakeword"}) + assert r["available"] is False + assert r["tts_available"] is False + assert "text-to-speech" in r["hint"] + + _voice_loop_ready(monkeypatch, stt=False, tts=False) + r = ww.check_wake_word_requirements({"provider": "openwakeword"}) + assert r["available"] is False + assert "speech-to-text and text-to-speech" in r["hint"] + + def test_requirements_porcupine_needs_access_key(monkeypatch): monkeypatch.delenv("PORCUPINE_ACCESS_KEY", raising=False) monkeypatch.setattr(ww, "_audio_available", lambda: True) @@ -124,6 +156,7 @@ def test_requirements_fresh_install_lazy_allowed(monkeypatch): def _boom(): raise AssertionError("audio probe must not run while deps are missing") + _voice_loop_ready(monkeypatch) monkeypatch.setattr(ww, "_audio_available", _boom) monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: False) monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: True) diff --git a/tools/wake_word.py b/tools/wake_word.py index 7f33a8895c96..b7cf50425ae5 100644 --- a/tools/wake_word.py +++ b/tools/wake_word.py @@ -502,6 +502,37 @@ def _build_engine(cfg: Dict[str, Any]) -> _Engine: # Requirements probe (for /wake status + enable path) # --------------------------------------------------------------------------- +def _stt_ready() -> bool: + """Is a speech-to-text provider configured and enabled? + + A wake without STT arms the mic but every captured utterance dies at + transcription — a useless (and confusing) experience. Same standard as + voice mode's ``check_voice_requirements``: enabled + a real provider. + """ + try: + from tools.transcription_tools import _get_provider, _load_stt_config, is_stt_enabled + + stt_config = _load_stt_config() + return is_stt_enabled(stt_config) and _get_provider(stt_config) != "none" + except Exception: + return False + + +def _tts_ready() -> bool: + """Can the configured text-to-speech provider actually run? + + The wake flow is fully hands-free (wake → speak → hear the reply); without + TTS the reply is silent and the loop is pointless. Mirrors /voice's use of + ``check_tts_requirements``. + """ + try: + from tools.tts_tool import check_tts_requirements + + return bool(check_tts_requirements()) + except Exception: + return False + + def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Report whether wake-word detection can run, with a remediation hint.""" cfg = cfg if cfg is not None else load_wake_word_config() @@ -525,6 +556,11 @@ def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[s # path unreachable (the probe always failed before ensure() could run). audio_ok = _audio_available() if deps_ok else False key_ok = True + # The full wake loop is wake → record → STT → agent → TTS. Arming without + # either end configured gives a mic that hears you and then does nothing + # the user can perceive — refuse with a pointer instead. + stt_ok = _stt_ready() + tts_ok = _tts_ready() hint = "" if provider == "porcupine" and not (os.getenv("PORCUPINE_ACCESS_KEY") or "").strip(): @@ -534,13 +570,22 @@ def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[s hint = lazy_deps.feature_install_command(feature) or "" elif deps_ok and not audio_ok: hint = "Microphone capture needs sounddevice + numpy and a working audio device." + elif not stt_ok or not tts_ok: + missing = " and ".join( + name for name, ok in (("speech-to-text", stt_ok), ("text-to-speech", tts_ok)) if not ok + ) + hint = (f"Wake word needs {missing} configured — run `hermes tools` " + f"(Voice section) or see the voice-mode docs.") return { - "available": key_ok and ((deps_ok and audio_ok) or (not deps_ok and lazy_ok)), + "available": key_ok and stt_ok and tts_ok + and ((deps_ok and audio_ok) or (not deps_ok and lazy_ok)), "provider": provider, "deps_available": deps_ok, "audio_available": audio_ok, "access_key_set": key_ok, + "stt_available": stt_ok, + "tts_available": tts_ok, "phrase": wake_phrase(cfg), "hint": hint, } diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 28da1643615d..75c3a20cc0a4 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -17740,6 +17740,17 @@ def _(rid, params: dict) -> dict: return _err(rid, 5026, f"wake module unavailable: {e}") cfg = load_wake_word_config() + # Requirements first: a gesture on an unarmed-able setup (no STT/TTS, no + # mic, missing key) must refuse WITHOUT flipping wake_word.enabled — else + # config says on while nothing can ever arm, and auto-arm paths churn. + reqs = check_wake_word_requirements(cfg) + if not reqs["available"]: + logger.warning("wake.start(%s): not available — %s", surface, reqs.get("hint")) + return _ok(rid, { + "started": False, + "reason": "unavailable", + "hint": reqs.get("hint") or "", + }) enabled_persisted = False if persist and not cfg.get("enabled"): enabled_persisted = _persist_wake_enabled(True) @@ -17755,14 +17766,6 @@ def _(rid, params: dict) -> dict: logger.info("wake.start(%s): %s (enabled=%s, surface=%s)", surface, reason, cfg.get("enabled"), cfg.get("surface")) return _ok(rid, {"started": False, "reason": reason}) - reqs = check_wake_word_requirements(cfg) - if not reqs["available"]: - logger.warning("wake.start(%s): not available — %s", surface, reqs.get("hint")) - return _ok(rid, { - "started": False, - "reason": "unavailable", - "hint": reqs.get("hint") or "", - }) existing_owner, existing_surface = _wake_owner_snapshot() if existing_owner is not None and ( diff --git a/website/docs/user-guide/features/wake-word.md b/website/docs/user-guide/features/wake-word.md index 25673fbc451c..577a68b5b6b5 100644 --- a/website/docs/user-guide/features/wake-word.md +++ b/website/docs/user-guide/features/wake-word.md @@ -204,6 +204,9 @@ PORCUPINE_ACCESS_KEY=your-key-here - An STT provider for transcribing the spoken command — local `faster-whisper` works out of the box; see [Voice Mode](/user-guide/features/voice-mode) for the full provider list. +- A TTS provider for speaking the reply (the default `edge-tts` works with no + key). The wake flow is fully hands-free, so the toggle refuses to arm until + both STT and TTS are ready — `hermes tools` (Voice section) sets them up. - The wake engine deps (auto-installed, or `hermes-agent[wake]`). `/wake status` reports exactly what's missing if the listener won't start.