mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
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:
parent
5f43452e91
commit
86d5b8b90f
9 changed files with 1678 additions and 14 deletions
1435
apps/desktop/src/app/desktop-controller.tsx
Normal file
1435
apps/desktop/src/app/desktop-controller.tsx
Normal file
File diff suppressed because it is too large
Load diff
7
cli.py
7
cli.py
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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() : ''
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
| {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue