feat(voice): extend "Hey Hermes" wake word to TUI + desktop GUI

Makes the wake word a tri-surface feature with one configurable owner.

- wake_word.surface ("auto" | "cli" | "tui" | "gui") + shared
  wake_surface_enabled() gate consulted by every surface, so exactly one
  place owns the listener and the new session it opens.
- tui_gateway: wake.start/stop/pause/resume/status RPCs + a wake.detected
  event, sharing one server-side detector for both TUI and desktop. The
  detector yields the mic to voice.record (pause on capture start, resume
  on terminal) and to the desktop's browser mic (wake.pause/resume).
- TUI (Ink): arm wake.start on gateway.ready; on wake.detected open a
  fresh session and start voice capture.
- Desktop (Electron): arm wake.start on connect; on wake.detected open a
  fresh session.
- CLI now gates on wake_surface_enabled("cli"); /wake status shows surface.
- Tests for the surface gate; docs cover the surface knob + cross-surface.
This commit is contained in:
Brooklyn Nicholson 2026-06-26 22:07:34 -05:00 committed by Teknium
parent 5f43452e91
commit 86d5b8b90f
No known key found for this signature in database
9 changed files with 1678 additions and 14 deletions

File diff suppressed because it is too large Load diff

7
cli.py
View file

@ -12182,10 +12182,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
# threading resume logic through the voice machinery.
def _maybe_start_wake_word(self):
"""Start the wake-word listener at CLI startup if enabled in config."""
"""Start the wake-word listener at CLI startup if this surface owns it."""
try:
from tools.wake_word import load_wake_word_config
if not load_wake_word_config().get("enabled"):
from tools.wake_word import wake_surface_enabled
if not wake_surface_enabled("cli"):
return
except Exception:
return
@ -12340,6 +12340,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
_cprint(f" State: {'LISTENING' if active else 'OFF'}")
_cprint(f" Phrase: \"{reqs['phrase']}\"")
_cprint(f" Provider: {reqs['provider']}")
_cprint(f" Surface: {cfg.get('surface', 'auto')}")
_cprint(f" New session: {'yes' if cfg.get('start_new_session', True) else 'no'}")
if not reqs["available"] and reqs.get("hint"):
_cprint(f" {_DIM}{reqs['hint']}{_RST}")

View file

@ -2379,6 +2379,7 @@ DEFAULT_CONFIG = {
# Off by default; toggle with /wake or `wake_word.enabled: true`.
"wake_word": {
"enabled": False,
"surface": "auto", # which surface owns the listener / opens the new session: "auto" (the running one) | "cli" | "tui" | "gui"
"provider": "openwakeword", # "openwakeword" (free, local) | "porcupine" (premium; needs PORCUPINE_ACCESS_KEY)
"phrase": "hey jarvis", # cosmetic label only; detection is keyed by the engine model/keyword below
"sensitivity": 0.5, # 0.0-1.0 detection threshold (higher = stricter)

View file

@ -27,6 +27,21 @@ def test_config_defaults_and_clamping():
assert ww.wake_phrase({}) == "hey jarvis"
def test_wake_surface_enabled_gate():
# Disabled → never, regardless of surface.
assert ww.wake_surface_enabled("cli", {"enabled": False, "surface": "cli"}) is False
# auto → every surface.
for s in ("cli", "tui", "gui"):
assert ww.wake_surface_enabled(s, {"enabled": True, "surface": "auto"}) is True
# Pinned surface → only that one.
cfg = {"enabled": True, "surface": "tui"}
assert ww.wake_surface_enabled("tui", cfg) is True
assert ww.wake_surface_enabled("cli", cfg) is False
assert ww.wake_surface_enabled("gui", cfg) is False
# Missing/blank surface defaults to auto.
assert ww.wake_surface_enabled("gui", {"enabled": True}) is True
def test_looks_like_path():
assert ww._looks_like_path("models/hey_hermes.onnx")
assert ww._looks_like_path("custom.ppn")

View file

@ -49,12 +49,16 @@ _FIRE_COOLDOWN_SECONDS = 2.0
_DEFAULTS: Dict[str, Any] = {
"enabled": False,
"surface": "auto",
"provider": "openwakeword",
"phrase": "hey jarvis",
"sensitivity": 0.5,
"start_new_session": True,
}
# Surfaces that can host the listener. "auto" means whichever one is running.
SURFACES = ("cli", "tui", "gui")
def load_wake_word_config() -> Dict[str, Any]:
"""Return the ``wake_word`` config section, shape-guarded to a dict."""
@ -91,6 +95,20 @@ def wake_phrase(cfg: Optional[Dict[str, Any]] = None) -> str:
return str(_get(cfg, "phrase")) or "hey jarvis"
def wake_surface_enabled(surface: str, cfg: Optional[Dict[str, Any]] = None) -> bool:
"""Should ``surface`` (``cli`` / ``tui`` / ``gui``) host the listener?
True when the wake word is enabled and the configured ``surface`` is either
``auto`` or this exact surface the single gate every surface consults so
only one place owns the wake word and the new session it opens.
"""
cfg = cfg if cfg is not None else load_wake_word_config()
if not cfg.get("enabled"):
return False
want = str(_get(cfg, "surface")).strip().lower() or "auto"
return want == "auto" or want == surface.strip().lower()
# ---------------------------------------------------------------------------
# Audio capture (lazy — never import sounddevice at module load)
# ---------------------------------------------------------------------------

View file

@ -925,6 +925,11 @@ def _close_sessions_for_transport(
def _shutdown_sessions() -> None:
try:
from tools.wake_word import stop_listening as _stop_wake
_stop_wake()
except Exception:
pass
with _sessions_lock:
sids = list(_sessions)
for sid in sids:
@ -17587,6 +17592,136 @@ def _voice_record_key() -> str:
return str(record_key) if isinstance(record_key, str) and record_key else "ctrl+b"
# ── Wake word ("Hey Hermes") ──────────────────────────────────────────────
# The detector is process-global (one mic), like voice. It runs server-side so
# both the TUI and desktop GUI share it; clients pass their surface identity to
# wake.start and the shared gate (wake_surface_enabled) decides whether to arm.
# On detection we emit wake.detected; the client opens a new session and starts
# its own voice capture. The detector yields the mic to gateway voice.record
# (pause/resume below) and to the desktop's browser mic (wake.pause/resume RPCs).
_wake_lock = threading.Lock()
_wake_active = False
_wake_event_sid = ""
def _wake_is_active() -> bool:
with _wake_lock:
return _wake_active
def _wake_resume_if_active() -> None:
if not _wake_is_active():
return
try:
from tools.wake_word import resume_listening
resume_listening()
except Exception as e:
logger.debug("wake resume failed: %s", e)
def _wake_on_detect() -> None:
"""Detector-thread callback: tell the client to open a fresh voice session."""
with _wake_lock:
sid = _wake_event_sid
try:
from tools.wake_word import wake_phrase
phrase = wake_phrase()
except Exception:
phrase = ""
_emit("wake.detected", sid, {"phrase": phrase})
@method("wake.start")
def _(rid, params: dict) -> dict:
"""Arm the wake-word listener for the calling surface ("tui" | "gui").
Idempotent and gated: returns ``{started: False, reason}`` when the wake
word is disabled, scoped to another surface, or its deps/mic aren't ready.
"""
global _wake_active, _wake_event_sid
surface = str(params.get("surface") or "auto").strip().lower()
try:
from tools.wake_word import (
check_wake_word_requirements,
load_wake_word_config,
start_listening,
wake_surface_enabled,
)
except Exception as e:
return _err(rid, 5026, f"wake module unavailable: {e}")
cfg = load_wake_word_config()
if not wake_surface_enabled(surface, cfg):
return _ok(rid, {"started": False, "reason": "disabled_for_surface"})
reqs = check_wake_word_requirements(cfg)
if not reqs["available"]:
return _ok(rid, {"started": False, "reason": reqs.get("hint") or "unavailable"})
with _wake_lock:
_wake_event_sid = params.get("session_id") or _wake_event_sid
try:
start_listening(_wake_on_detect, config=cfg)
except Exception as e:
return _err(rid, 5026, str(e))
with _wake_lock:
_wake_active = True
return _ok(rid, {"started": True, "phrase": reqs["phrase"], "provider": reqs["provider"]})
@method("wake.stop")
def _(rid, params: dict) -> dict:
global _wake_active
with _wake_lock:
_wake_active = False
try:
from tools.wake_word import stop_listening
stop_listening()
except Exception:
pass
return _ok(rid, {"stopped": True})
@method("wake.pause")
def _(rid, params: dict) -> dict:
"""Release the mic (e.g. while the desktop's browser captures audio)."""
try:
from tools.wake_word import pause_listening
pause_listening()
except Exception:
pass
return _ok(rid, {"paused": True})
@method("wake.resume")
def _(rid, params: dict) -> dict:
"""Reclaim the mic after a pause; no-op if the listener isn't armed."""
active = _wake_is_active()
if active:
_wake_resume_if_active()
return _ok(rid, {"resumed": active})
@method("wake.status")
def _(rid, params: dict) -> dict:
try:
from tools.wake_word import (
check_wake_word_requirements,
is_listening,
load_wake_word_config,
)
cfg = load_wake_word_config()
reqs = check_wake_word_requirements(cfg)
return _ok(rid, {
"listening": _wake_is_active() and is_listening(),
"phrase": reqs["phrase"],
"provider": reqs["provider"],
"available": reqs["available"],
"hint": reqs.get("hint", ""),
})
except Exception as e:
return _err(rid, 5026, str(e))
@method("voice.toggle")
def _(rid, params: dict) -> dict:
"""CLI parity for the ``/voice`` slash command.
@ -17735,12 +17870,28 @@ def _(rid, params: dict) -> dict:
if isinstance(duration, (int, float)) and not isinstance(duration, bool)
else 3.0
)
# Hand the mic to STT if the wake-word detector holds it; resume
# once a terminal capture event fires (one-shot transcript / silence
# limit), so wake-triggered and manual captures both coexist.
if _wake_is_active():
try:
from tools.wake_word import pause_listening
pause_listening()
except Exception:
pass
def _on_transcript(t):
_voice_emit("voice.transcript", {"text": t})
_wake_resume_if_active()
def _on_silent():
_voice_emit("voice.transcript", {"no_speech_limit": True})
_wake_resume_if_active()
started = start_continuous(
on_transcript=lambda t: _voice_emit("voice.transcript", {"text": t}),
on_transcript=_on_transcript,
on_status=lambda s: _voice_emit("voice.status", {"state": s}),
on_silent_limit=lambda: _voice_emit(
"voice.transcript", {"no_speech_limit": True}
),
on_silent_limit=_on_silent,
silence_threshold=safe_threshold,
silence_duration=safe_duration,
auto_restart=False,
@ -17756,6 +17907,7 @@ def _(rid, params: dict) -> dict:
from hermes_cli.voice import stop_continuous
stop_continuous(force_transcribe=True)
_wake_resume_if_active()
return _ok(rid, {"status": "stopped"})
except ImportError:
return _err(

View file

@ -621,6 +621,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
// "too many re-renders" guard in embedded dashboard PTYs.
ensureAgentsNudgeConfig()
// Arm "Hey Hermes" if this surface owns it (server gates on config).
// Fire-and-forget + idempotent server-side, so reconnects are harmless.
void rpc('wake.start', { surface: 'tui' })
rpc<CommandsCatalogResponse>('commands.catalog', {})
.then(r => {
if (!r?.pairs) {
@ -936,6 +940,23 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
return
}
case 'wake.detected': {
// "Hey Hermes": open a fresh session, then arm voice capture so the
// user can speak their request hands-free. Mirrors the CLI flow.
void (async () => {
await newSession()
const sid = getUiState().sid
if (!sid) {
return
}
setVoiceEnabled(true)
await rpc('voice.toggle', { action: 'on' })
await rpc('voice.record', { action: 'start', session_id: sid })
})()
return
}
case 'gateway.start_timeout': {
const { cwd, python, stderr_tail: stderrTail } = ev.payload ?? {}
const trace = python || cwd ? ` · ${String(python || '')} ${String(cwd || '')}`.trim() : ''

View file

@ -589,6 +589,7 @@ export type GatewayEvent =
}
| { payload?: { state?: 'idle' | 'listening' | 'transcribing' }; session_id?: string; type: 'voice.status' }
| { payload?: { no_speech_limit?: boolean; text?: string }; session_id?: string; type: 'voice.transcript' }
| { payload?: { phrase?: string }; session_id?: string; type: 'wake.detected' }
| { payload?: { reason?: string }; session_id?: string; type: 'dashboard.new_session_requested' }
| { payload: { line: string }; session_id?: string; type: 'gateway.stderr' }
| {

View file

@ -6,11 +6,12 @@ description: "Hands-free 'Hey Hermes' wake word — start a voice session by spe
# Wake Word ("Hey Hermes")
The wake word turns Hermes into a hands-free assistant in the CLI: with one
setting on, Hermes listens in the background for a spoken trigger phrase. Say it,
and Hermes starts a fresh session, opens the microphone, captures your command
via the normal [voice pipeline](/user-guide/features/voice-mode), and answers —
exactly like "Hey Siri" or "Alexa".
The wake word turns Hermes into a hands-free assistant across the CLI, TUI, and
desktop app: with one setting on, Hermes listens in the background for a spoken
trigger phrase. Say it, and Hermes starts a fresh session, opens the microphone,
captures your command via the normal [voice pipeline](/user-guide/features/voice-mode),
and answers — exactly like "Hey Siri" or "Alexa". Use `surface` to pick which
one listens.
Detection runs **entirely on-device**. The always-on listener only watches for
the wake phrase; no audio leaves your machine until you actually speak a command
@ -62,6 +63,7 @@ wake_word:
```yaml
wake_word:
enabled: false
surface: auto # which surface owns the listener: "auto" | "cli" | "tui" | "gui"
provider: openwakeword # "openwakeword" (free, local) | "porcupine"
phrase: "hey jarvis" # cosmetic label only — detection is keyed by the model/keyword below
sensitivity: 0.5 # 0.0-1.0 — raise to reduce false triggers
@ -76,6 +78,23 @@ wake_word:
`sensitivity`, `phrase`, and `start_new_session` apply to both engines. The
`openwakeword` and `porcupine` blocks select the actual detection model.
### Surfaces (CLI, TUI, GUI)
The wake word works in all three Hermes surfaces, and `surface` picks which one
owns the listener and opens the new session when it fires:
| `surface` | Behavior |
|-----------|----------|
| `auto` (default) | Whichever surface you launch arms the listener. |
| `cli` | Only the classic `hermes` CLI. |
| `tui` | Only `hermes --tui`. |
| `gui` | Only the desktop app. |
The detector is on-device and single-mic, so only one surface listens at a time
`surface` is how you pin it. The TUI and desktop GUI share the same Python
backend (`tui_gateway`), which runs the detector server-side and yields the mic
to voice capture while a command records.
## Using a real "Hey Hermes"
The bundled openWakeWord models do **not** include "hey hermes" — `hey_jarvis`
@ -140,8 +159,9 @@ PORCUPINE_ACCESS_KEY=your-key-here
## Notes & limits
- **CLI only.** The wake word lives in the interactive `hermes` CLI, where a
local microphone is available. It does not run in the messaging gateway.
- **Local surfaces only.** The wake word runs in the CLI, TUI, and desktop GUI —
wherever a local microphone is available. It does not run in the messaging
gateway (Telegram, Discord, …), which has no mic.
- **One mic at a time.** The detector releases the microphone while a command is
recording and reclaims it once the turn ends, so it won't fight voice capture.
- **Privacy.** Hotword detection is local. Set `sensitivity` higher if you get