mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
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:
parent
514dd59cad
commit
f03bb2b4ef
4 changed files with 93 additions and 9 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue