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
9 changed files with 1678 additions and 14 deletions
+18
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)
# ---------------------------------------------------------------------------