feat(wake): gate arming on STT + TTS readiness

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.
This commit is contained in:
Teknium 2026-07-27 18:26:20 -07:00
parent 514dd59cad
commit f03bb2b4ef
No known key found for this signature in database
4 changed files with 93 additions and 9 deletions

View file

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

View file

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

View file

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

View file

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