diff --git a/MANIFEST.in b/MANIFEST.in index c09d58c71283..1f7d3bf0941d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -10,5 +10,8 @@ graft locales recursive-include plugins plugin.yaml plugin.yml # Gateway assets include images plus YAML catalogs such as status_phrases.yaml. recursive-include gateway/assets * +# Bundled "hey hermes" wake-word model. Binary assets, so ship them in the sdist +# too (downstream packagers build from it); package-data covers the wheel. +recursive-include tools/wakewords *.onnx *.tflite *.md global-exclude __pycache__ global-exclude *.py[cod] diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts index 5ce92171d3f1..b5abccd38d59 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts @@ -1,9 +1,12 @@ +import { useStore } from '@nanostores/react' import { useCallback, useEffect, useRef, useState } from 'react' import { useI18n } from '@/i18n' import { chatMessageText } from '@/lib/chat-messages' import { triggerHaptic } from '@/lib/haptics' +import { $voiceConversationStartRequest, takeVoiceConversationStart } from '@/store/composer' import { resetBrowseState } from '@/store/composer-input-history' +import { $gateway } from '@/store/gateway' import { notifyError } from '@/store/notifications' import { $messages } from '@/store/session' import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs' @@ -132,6 +135,57 @@ export function useComposerVoice({ [target, toggleVoiceConversation] ) + // "Hey Hermes" wake word: a latched start request (nanostore) the composer + // claims once it's mounted and the gateway is ready. Survives the fresh-session + // remount the wake handler triggers, and waits out a transient `disabled`. + const voiceStartReq = useStore($voiceConversationStartRequest) + useEffect(() => { + if (disabled) { + return // not ready — re-runs when `disabled` flips false + } + + if (!takeVoiceConversationStart(voiceStartReq)) { + return + } + + if (!voiceConversationActive) { + setVoiceConversationActive(true) + } + }, [voiceStartReq, disabled, voiceConversationActive]) + + // Hand the mic between the server-side wake detector and the browser's voice + // loop: pause the detector while a conversation is live, resume it after + // (no-ops server-side when the wake word isn't armed). wakePausedRef tracks + // whether WE paused, so resume always runs once — including on unmount, where + // ending voice can tear the composer down before the `false` render lands and + // would otherwise leave the detector paused forever. + const wakePausedRef = useRef(false) + + const wakeRpc = useCallback( + (method: string) => void $gateway.get()?.request(method, {}).catch(() => undefined), + [] + ) + + const resumeWakeIfPaused = useCallback(() => { + if (!wakePausedRef.current) { + return + } + + wakePausedRef.current = false + wakeRpc('wake.resume') + }, [wakeRpc]) + + useEffect(() => { + if (voiceConversationActive) { + wakePausedRef.current = true + wakeRpc('wake.pause') + } else { + resumeWakeIfPaused() + } + }, [voiceConversationActive, wakeRpc, resumeWakeIfPaused]) + + useEffect(() => resumeWakeIfPaused, [resumeWakeIfPaused]) + // Explicit start/end for the on-screen conversation controls (the hotkey uses // the gated toggle above). const startConversation = useCallback(() => setVoiceConversationActive(true), []) diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index b2d0c03a9881..7cd3e02947ad 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -27,6 +27,7 @@ import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChat import { sessionMessagesSignature } from '@/lib/session-signatures' import { isMessagingSource } from '@/lib/session-source' import { latestSessionTodos } from '@/lib/todos' +import { requestVoiceConversationStart } from '@/store/composer' import { setCronFocusJobId } from '@/store/cron' import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout' import { $filePreviewTarget, $previewTarget } from '@/store/preview' @@ -630,9 +631,25 @@ export function ContribWiring({ children }: { children: ReactNode }) { const handleGatewayEventWithPlugins = useCallback( (event: Parameters[0]) => { emitGatewayEvent(event) + + // "Hey Hermes": handle the wake event on the canonical onEvent pipeline — + // open a fresh session (unless the config keeps the current one) and begin + // back-and-forth voice via the composer's latched start request. + if (event.type === 'wake.detected') { + const payload = event.payload as { start_new_session?: boolean } | undefined + + if (payload?.start_new_session !== false) { + startFreshSessionDraft() + } + + requestVoiceConversationStart() + + return + } + handleDesktopGatewayEvent(event) }, - [handleDesktopGatewayEvent] + [handleDesktopGatewayEvent, startFreshSessionDraft] ) useGatewayBoot({ @@ -653,6 +670,17 @@ export function ContribWiring({ children }: { children: ReactNode }) { refreshSessions }) + // "Hey Hermes" wake word: arm the server-side detector for this surface + // (gated on config server-side). Detection arrives as a wake.detected event + // handled in handleGatewayEventWithPlugins. Idempotent, so reconnects are safe. + useEffect(() => { + if (gatewayState !== 'open') { + return + } + + void requestGateway('wake.start', { surface: 'gui' }).catch(() => undefined) + }, [gatewayState, requestGateway]) + // Only the open messaging transcript needs its own poll — local chats are // live over the websocket already. const activeIsMessaging = diff --git a/apps/desktop/src/store/composer.ts b/apps/desktop/src/store/composer.ts index b984fe8bfc3b..50e199d028fe 100644 --- a/apps/desktop/src/store/composer.ts +++ b/apps/desktop/src/store/composer.ts @@ -93,6 +93,29 @@ export function createComposerAttachmentScope($attachments = atom + $voiceConversationStartRequest.set(Date.now()) + +/** Returns true exactly once per new request id (claims it). */ +export const takeVoiceConversationStart = (current: number): boolean => { + if (current > _voiceStartHandled) { + _voiceStartHandled = current + + return true + } + + return false +} + // Per-thread draft stash for the decoupled composer. Session lifecycle never // touches this — only ChatBar's scope swap reads/writes it. Text mirrors to // localStorage; attachments are memory-only (blobs, upload state). diff --git a/cli.py b/cli.py index cac922c850b0..baa3cf37412f 100644 --- a/cli.py +++ b/cli.py @@ -1176,6 +1176,11 @@ def _run_cleanup(*, notify_session_finalize: bool = True): # can't skip the reset (#36823). No-op unless the TUI actually ran. _reset_terminal_input_modes_on_exit() + try: + from tools.wake_word import stop_listening as _stop_wake_word + _stop_wake_word() + except Exception: + pass try: _cleanup_all_terminals() except Exception: @@ -9330,6 +9335,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): self._handle_skin_command(cmd_original) elif canonical == "voice": self._handle_voice_command(cmd_original) + elif canonical == "wake": + self._handle_wake_command(cmd_original) elif canonical == "busy": self._handle_busy_command(cmd_original) else: @@ -11373,6 +11380,185 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): _cprint(f"\n{_DIM}Voice mode disabled.{_RST}") + # ── Wake word ("Hey Hermes") ───────────────────────────────────────── + # + # An always-on hotword listener (tools/wake_word.py) that, on detecting + # the wake phrase, starts a fresh session and captures one utterance via + # the existing voice pipeline — the "Hey Siri" pattern, fully on-device. + # + # The detector holds the microphone, so it must be paused while a voice + # turn records (two input streams on one device is unreliable). On wake we + # pause it and mark the system suspended; a lightweight watchdog resumes it + # once the turn finishes and the CLI is idle again — covering every exit + # path (transcript submitted, no speech, or transcription error) without + # threading resume logic through the voice machinery. + + def _maybe_start_wake_word(self): + """Start the wake-word listener at CLI startup if this surface owns it.""" + try: + from tools.wake_word import wake_surface_enabled + if not wake_surface_enabled("cli"): + return + except Exception: + return + self._start_wake_word_listener(announce=True) + + def _start_wake_word_listener(self, announce: bool = False) -> bool: + """Build + start the hotword detector. Returns True on success.""" + if getattr(self, "_wake_word_active", False): + if announce: + _cprint(f"{_DIM}Wake word is already listening.{_RST}") + return True + try: + from tools.wake_word import ( + check_wake_word_requirements, + load_wake_word_config, + start_listening, + ) + except Exception as e: + if announce: + _cprint(f"{_DIM}Wake word unavailable: {e}{_RST}") + return False + + cfg = load_wake_word_config() + reqs = check_wake_word_requirements(cfg) + if not reqs["available"]: + if announce: + _cprint(f"\n{_ACCENT}Wake word requirements not met:{_RST}") + if reqs.get("hint"): + _cprint(f" {_DIM}{reqs['hint']}{_RST}") + return False + + self._wake_start_new_session = bool(cfg.get("start_new_session", True)) + try: + start_listening(self._on_wake_word, config=cfg) + except Exception as e: + if announce: + _cprint(f"\n{_DIM}Failed to start wake word: {e}{_RST}") + return False + + self._wake_word_active = True + self._wake_suspended = False + self._start_wake_watchdog() + if announce: + _cprint(f"\n{_ACCENT}Wake word listening{_RST} " + f"{_DIM}(say \"{reqs['phrase']}\" — /wake off to stop){_RST}") + return True + + def _stop_wake_word_listener(self, announce: bool = False): + """Stop and tear down the hotword detector.""" + was_active = getattr(self, "_wake_word_active", False) + self._wake_word_active = False + self._wake_suspended = False + try: + from tools.wake_word import stop_listening + stop_listening() + except Exception: + pass + if announce: + if was_active: + _cprint(f"{_DIM}Wake word stopped.{_RST}") + else: + _cprint(f"{_DIM}Wake word is not running.{_RST}") + + def _on_wake_word(self): + """Fired (on the detector thread) when the wake phrase is heard.""" + if getattr(self, "_should_exit", False): + return + # Ignore wake while a turn is in flight or the mic is already in use. + if self._agent_running or self._voice_recording or getattr(self, "_voice_processing", False): + return + + # Release the mic so STT can capture the command utterance. + try: + from tools.wake_word import pause_listening + pause_listening() + except Exception: + pass + self._wake_suspended = True + + _cprint(f"\n{_ACCENT}✦ Wake word detected — listening...{_RST}") + if getattr(self, "_app", None): + try: + self._app.invalidate() + except Exception: + pass + + if getattr(self, "_wake_start_new_session", True): + try: + self.new_session(silent=True) + except Exception as e: + logger.debug("wake word new_session failed: %s", e) + + # Single-utterance capture (not continuous) via the voice pipeline; + # VAD auto-stop transcribes and queues the transcript for process_loop. + with self._voice_lock: + self._voice_mode = True + self._voice_continuous = False + try: + self._voice_start_recording() + except Exception as e: + _cprint(f"{_DIM}Wake capture failed: {e}{_RST}") + # Leave _wake_suspended set; the watchdog resumes once idle. + + def _start_wake_watchdog(self): + """Resume the paused detector when the CLI returns to a stable idle.""" + if getattr(self, "_wake_watchdog_started", False): + return + self._wake_watchdog_started = True + + def _loop(): + idle_polls = 0 + try: + while getattr(self, "_wake_word_active", False) and not getattr(self, "_should_exit", False): + time.sleep(0.25) + if not getattr(self, "_wake_suspended", False): + idle_polls = 0 + continue + busy = ( + self._agent_running + or self._voice_recording + or getattr(self, "_voice_processing", False) + or not self._pending_input.empty() + ) + if busy: + idle_polls = 0 + continue + # Require a few consecutive idle polls (~0.75s) so we don't + # resume in the gap between VAD stop and the agent starting. + idle_polls += 1 + if idle_polls >= 3: + idle_polls = 0 + try: + from tools.wake_word import resume_listening + resume_listening() + self._wake_suspended = False + except Exception as e: + logger.debug("wake word resume failed: %s", e) + finally: + self._wake_watchdog_started = False + + threading.Thread(target=_loop, daemon=True, name="wake-watchdog").start() + + def _show_wake_word_status(self): + """Show current wake-word listener status.""" + from tools.wake_word import check_wake_word_requirements, load_wake_word_config + + cfg = load_wake_word_config() + reqs = check_wake_word_requirements(cfg) + active = getattr(self, "_wake_word_active", False) + + _cprint(f"\n{_BOLD}Wake Word Status{_RST}") + _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}") + if not active: + _cprint(f" {_DIM}Enable with /wake on{_RST}") + def _toggle_voice_tts(self): """Toggle TTS output for voice mode.""" if not self._voice_mode: @@ -15325,7 +15511,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): # Start processing thread process_thread = threading.Thread(target=process_loop, daemon=True) process_thread.start() - + + # Wake word ("Hey Hermes") — start the always-on hotword listener if + # enabled. Off-thread so a first-run engine install never blocks the + # prompt; best-effort, so deps/mic/key gaps are surfaced, never fatal. + def _wake_startup(): + try: + self._maybe_start_wake_word() + except Exception as e: + logger.debug("wake-word startup skipped: %s", e) + threading.Thread(target=_wake_startup, daemon=True, name="wake-startup").start() + # Register atexit cleanup so resources are freed even on unexpected exit atexit.register(_run_cleanup) diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 4c98e70dbc66..112e2769615f 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -2811,3 +2811,26 @@ class CLICommandsMixin: else: _cprint(f"Unknown voice subcommand: {subcommand}") _cprint("Usage: /voice [on|off|tts|status]") + + def _handle_wake_command(self, command: str): + """Handle /wake [on|off|status] — the 'Hey Hermes' hotword listener.""" + from cli import _cprint + parts = command.strip().split(maxsplit=1) + subcommand = parts[1].lower().strip() if len(parts) > 1 else "" + + if subcommand == "on": + self._start_wake_word_listener(announce=True) + elif subcommand == "off": + self._stop_wake_word_listener(announce=True) + elif subcommand in ("", "status"): + if subcommand == "": + # Bare /wake toggles. + if getattr(self, "_wake_word_active", False): + self._stop_wake_word_listener(announce=True) + else: + self._start_wake_word_listener(announce=True) + else: + self._show_wake_word_status() + else: + _cprint(f"Unknown wake subcommand: {subcommand}") + _cprint("Usage: /wake [on|off|status]") diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index d5973e51eab0..dddbe07e7072 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -168,6 +168,9 @@ COMMAND_REGISTRY: list[CommandDef] = [ subcommands=("kaomoji", "emoji", "unicode", "ascii")), CommandDef("voice", "Toggle voice mode", "Configuration", args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")), + CommandDef("wake", "Toggle the 'Hey Hermes' wake word listener", "Configuration", + cli_only=True, args_hint="[on|off|status]", + subcommands=("on", "off", "status")), CommandDef("busy", "Control what Enter does while Hermes is working", "Configuration", cli_only=True, args_hint="[queue|steer|interrupt|status]", subcommands=("queue", "steer", "interrupt", "status")), diff --git a/hermes_cli/config.py b/hermes_cli/config.py index bbb6a747ab0e..f6e9303d41a2 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2306,6 +2306,31 @@ DEFAULT_CONFIG = { "silence_threshold": 200, # RMS below this = silence (0-32767) "silence_duration": 3.0, # Seconds of silence before auto-stop }, + + # "Hey Hermes" hands-free wake word (CLI). Always-on, on-device hotword + # detection that starts a fresh voice session — the "Hey Siri" pattern. + # 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 hermes", # cosmetic label only; detection is keyed by the engine model/keyword below + "sensitivity": 0.5, # 0.0-1.0 detection threshold (higher = stricter) + "start_new_session": True, # start a fresh session on wake vs. continue the current one + "openwakeword": { + # "hey_hermes" (the bundled, works-out-of-the-box default) OR a + # built-in openWakeWord name ("hey_jarvis", "alexa", "hey_mycroft", + # ...) OR a path to a custom .onnx/.tflite model for another phrase. + # See the wake-word docs for the custom-model training guide. + "model": "hey_hermes", + "inference_framework": "onnx", # "onnx" | "tflite" + }, + "porcupine": { + # Built-in keyword ("jarvis", "computer", "bumblebee", ...) or a path + # to a custom .ppn from the Picovoice Console. + "keyword": "jarvis", + }, + }, "human_delay": { "mode": "off", @@ -4178,6 +4203,13 @@ OPTIONAL_ENV_VARS = { "password": True, "category": "tool", }, + "PORCUPINE_ACCESS_KEY": { + "description": "Picovoice access key for the Porcupine 'Hey Hermes' wake word engine (optional; openWakeWord is the free default)", + "prompt": "Picovoice access key", + "url": "https://console.picovoice.ai/", + "password": True, + "category": "tool", + }, "GITHUB_TOKEN": { "description": "GitHub token for Skills Hub (higher API rate limits, skill publish)", "prompt": "GitHub Token", diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 238b4acf88e7..d73f74fa28e3 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -4251,10 +4251,15 @@ async def transcribe_audio_upload(payload: AudioTranscriptionRequest): pass if not result.get("success"): - raise HTTPException( - status_code=400, - detail=result.get("error") or "Transcription failed", - ) + err = result.get("error") or "Transcription failed" + # An empty transcript means no speech was detected — a normal outcome + # for VAD/continuous voice loops (e.g. a wake-word conversation + # re-listening on silence), not an error. Return an empty transcript so + # the client quietly re-listens instead of surfacing a "transcription + # failed" toast on every silent gap. + if "empty transcript" in err.lower(): + return {"ok": True, "transcript": "", "provider": result.get("provider")} + raise HTTPException(status_code=400, detail=err) return { "ok": True, diff --git a/pyproject.toml b/pyproject.toml index ca3648279575..0d8694ebb3ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -181,6 +181,16 @@ voice = [ "sounddevice==0.5.5", "numpy==2.4.3", ] +# "Hey Hermes" wake word — on-device hotword detection. Both engines are +# optional; openWakeWord (ONNX) is the free default, Porcupine the premium +# alternative. Lazy-installed on first /wake; mirrored in tools/lazy_deps.py. +wake = [ + "openwakeword==0.6.0", + "onnxruntime==1.27.0", + "pvporcupine==4.0.3", + "sounddevice==0.5.5", + "numpy==2.4.3", +] pty = [ # Kept as a no-op back-compat alias — `ptyprocess` and `pywinpty` are now # in the main `dependencies` list (with the same platform markers), so @@ -342,6 +352,11 @@ locales = ["locales/*.yaml"] [tool.setuptools.package-data] hermes_cli = ["web_dist/**/*", "tui_dist/**/*", "scripts/install.sh", "scripts/install.ps1"] gateway = ["assets/**/*"] +# Bundled "hey hermes" wake-word model (tools/wakewords/). It's a binary asset, +# not a Python module, so wheels only carry it when declared here; without this +# the wake word defaults to a missing model on packaged installs. MANIFEST.in +# `recursive-include tools/wakewords` covers the sdist. See tools/wake_word.py. +tools = ["wakewords/*.onnx", "wakewords/*.tflite", "wakewords/*.md"] plugins = [ "*/dashboard/manifest.json", "*/dashboard/dist/*", diff --git a/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py index f1ccee4773b0..724d7b735d13 100644 --- a/tests/test_packaging_metadata.py +++ b/tests/test_packaging_metadata.py @@ -117,6 +117,33 @@ def test_manifest_includes_bundled_skills(): assert "graft optional-skills" in manifest +def test_bundled_wakeword_model_ships_in_both_wheel_and_sdist(): + """The default "hey hermes" wake word only works if the model ships. + + ``tools/wakewords/hey_hermes.onnx`` is a binary asset (not a Python module), + so it reaches installed packages only when declared in both channels: + ``[tool.setuptools.package-data]`` (wheel) and ``MANIFEST.in`` (sdist, used + by downstream packagers). Without both, ``wake_word.openwakeword.model: + hey_hermes`` resolves to a missing file on packaged installs. + """ + # The asset must exist for the globs to match. + on_disk = list((REPO_ROOT / "tools" / "wakewords").glob("*.onnx")) + assert on_disk, "expected a bundled wake-word model under tools/wakewords/" + + # Wheel channel. + data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + tools_pkg_data = data["tool"]["setuptools"]["package-data"].get("tools", []) + assert any(g.endswith(".onnx") for g in tools_pkg_data), ( + "pyproject package-data 'tools' must ship wakewords/*.onnx (wheel)" + ) + + # Sdist channel. + manifest = (REPO_ROOT / "MANIFEST.in").read_text(encoding="utf-8") + assert "recursive-include tools/wakewords" in manifest and ".onnx" in manifest, ( + "MANIFEST.in must recursive-include tools/wakewords *.onnx (sdist)" + ) + + def test_bundled_plugin_manifests_ship_in_both_wheel_and_sdist(): """Regression test for #34034 / #28149. diff --git a/tests/tools/test_wake_word.py b/tests/tools/test_wake_word.py new file mode 100644 index 000000000000..50f88dbfe2ba --- /dev/null +++ b/tests/tools/test_wake_word.py @@ -0,0 +1,326 @@ +"""Tests for tools.wake_word — the "Hey Hermes" hotword detector. + +No live audio or network: the sounddevice import is faked, engines are stubbed, +and lazy-dep availability is monkeypatched. Covers config resolution, engine +dispatch, the requirements probe, the detector fire/cooldown loop, and the +process-wide singleton lifecycle. +""" + +import os +import sys +import time +import types + +import pytest + +import tools.wake_word as ww + + +# ── Config helpers ─────────────────────────────────────────────────────── + + +def test_config_defaults_and_clamping(): + assert ww._provider({}) == "openwakeword" + assert ww._provider({"provider": "Porcupine"}) == "porcupine" + assert ww._sensitivity({"sensitivity": 5}) == 1.0 + assert ww._sensitivity({"sensitivity": -1}) == 0.0 + assert ww._sensitivity({"sensitivity": "nope"}) == 0.5 + assert ww.wake_phrase({"phrase": "hey jarvis"}) == "hey jarvis" + assert ww.wake_phrase({}) == "hey hermes" + + +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") + assert not ww._looks_like_path("hey_jarvis") + + +def test_load_wake_word_config_is_a_dict_with_defaults(): + # Wired into DEFAULT_CONFIG, so a real load returns the section shape. + cfg = ww.load_wake_word_config() + assert isinstance(cfg, dict) + assert cfg.get("enabled") is False + assert cfg.get("provider") == "openwakeword" + + +def test_load_wake_word_config_guards_non_dict(monkeypatch): + monkeypatch.setattr( + "hermes_cli.config.load_config", lambda: {"wake_word": "oops"} + ) + assert ww.load_wake_word_config() == {} + + +# ── Engine dispatch ────────────────────────────────────────────────────── + + +def test_build_engine_dispatch(monkeypatch): + monkeypatch.setattr(ww, "_OpenWakeWordEngine", lambda cfg: "oww") + monkeypatch.setattr(ww, "_PorcupineEngine", lambda cfg: "pv") + assert ww._build_engine({"provider": "openwakeword"}) == "oww" + assert ww._build_engine({"provider": "porcupine"}) == "pv" + with pytest.raises(ValueError): + ww._build_engine({"provider": "bogus"}) + + +# ── Requirements probe ─────────────────────────────────────────────────── + + +def test_requirements_openwakeword_available(monkeypatch): + monkeypatch.setattr(ww, "_audio_available", lambda: True) + monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: True) + r = ww.check_wake_word_requirements( + {"provider": "openwakeword", "phrase": "hey hermes"} + ) + assert r["available"] is True + assert r["provider"] == "openwakeword" + assert r["phrase"] == "hey hermes" + + +def test_requirements_porcupine_needs_access_key(monkeypatch): + monkeypatch.delenv("PORCUPINE_ACCESS_KEY", raising=False) + monkeypatch.setattr(ww, "_audio_available", lambda: True) + monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: True) + r = ww.check_wake_word_requirements({"provider": "porcupine"}) + assert r["available"] is False + assert r["access_key_set"] is False + assert "PORCUPINE_ACCESS_KEY" in r["hint"] + + +def test_requirements_unavailable_without_audio(monkeypatch): + monkeypatch.setattr(ww, "_audio_available", lambda: False) + monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: True) + r = ww.check_wake_word_requirements({"provider": "openwakeword"}) + assert r["available"] is False + assert r["audio_available"] is False + + +# ── openWakeWord engine (base-model fetch) ─────────────────────────────── + + +def _install_fake_openwakeword(monkeypatch): + """Swap in a fake ``openwakeword`` so the engine builds with no network. + + Returns a ``calls`` dict recording every ``download_models`` invocation. + """ + calls = {"download": []} + + class _FakeModel: + def __init__(self, wakeword_models, inference_framework="onnx"): + self.wakeword_models = list(wakeword_models) + self.models = {"hey_hermes": object()} + + def predict(self, frame): + return {"hey_hermes": 0.0} + + def reset(self): + pass + + oww = types.ModuleType("openwakeword") + oww.utils = types.SimpleNamespace( + download_models=lambda names=[]: calls["download"].append(list(names)) + ) + model_mod = types.ModuleType("openwakeword.model") + model_mod.Model = _FakeModel + + monkeypatch.setitem(sys.modules, "openwakeword", oww) + monkeypatch.setitem(sys.modules, "openwakeword.model", model_mod) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *a, **k: None) + return calls + + +def test_openwakeword_ensures_base_models_for_custom_path(monkeypatch): + # Regression: a custom ``.onnx`` path used to skip download_models entirely, + # so a fresh install crashed at load time on a missing melspectrogram.onnx. + # The base feature models must be ensured for a custom path too. + calls = _install_fake_openwakeword(monkeypatch) + eng = ww._OpenWakeWordEngine( + {"provider": "openwakeword", "openwakeword": {"model": "/models/hey_hermes.onnx"}} + ) + assert calls["download"] == [["/models/hey_hermes.onnx"]] + assert eng._labels == ["hey_hermes"] + + +def test_openwakeword_fetches_builtin_by_name(monkeypatch): + calls = _install_fake_openwakeword(monkeypatch) + ww._OpenWakeWordEngine({"provider": "openwakeword", "openwakeword": {"model": "hey_jarvis"}}) + assert calls["download"] == [["hey_jarvis"]] + + +def test_bundled_hey_hermes_model_ships_on_disk(): + # The "hey hermes" wake word works out of the box only if the model is + # actually bundled. Both framework artifacts must exist and be non-trivial. + for framework in ("onnx", "tflite"): + path = ww._bundled_wakeword_path(framework) + assert os.path.exists(path), path + assert os.path.getsize(path) > 1024, path + + +@pytest.mark.parametrize("model_value", [None, "", "hey_hermes", "hey hermes", "HEY_HERMES"]) +def test_openwakeword_default_resolves_to_bundled_model(monkeypatch, model_value): + # The default (and any "hey_hermes" alias) must load the bundled file, not be + # passed through as a bogus built-in name that openWakeWord can't resolve. + calls = _install_fake_openwakeword(monkeypatch) + sub = {} if model_value is None else {"model": model_value} + ww._OpenWakeWordEngine({"provider": "openwakeword", "openwakeword": sub}) + (downloaded,) = calls["download"] + assert downloaded == [ww._bundled_wakeword_path("onnx")] + + +def test_openwakeword_bundled_model_matches_framework(monkeypatch): + calls = _install_fake_openwakeword(monkeypatch) + ww._OpenWakeWordEngine( + {"provider": "openwakeword", "openwakeword": {"inference_framework": "tflite"}} + ) + (downloaded,) = calls["download"] + assert downloaded == [ww._bundled_wakeword_path("tflite")] + assert downloaded[0].endswith(".tflite") + + +# ── Detector loop ──────────────────────────────────────────────────────── + + +class _FakeStream: + """Always-readable input stream that yields trivial frames.""" + + def __init__(self, **_kw): + self.closed = False + + def start(self): + pass + + def read(self, n): + time.sleep(0.01) + return [0] * n, False + + def stop(self): + pass + + def close(self): + self.closed = True + + +class _FakeEngine: + frame_length = 4 + + def __init__(self, fire=True): + self._fire = fire + self.closed = False + self.resets = 0 + + def process(self, frame): + return self._fire + + def reset(self): + self.resets += 1 + + def close(self): + self.closed = True + + +def _fake_audio(monkeypatch): + fake_sd = types.SimpleNamespace(InputStream=lambda **kw: _FakeStream(**kw)) + monkeypatch.setattr(ww, "_import_audio", lambda: (fake_sd, None)) + + +def test_detector_fires_once_under_cooldown(monkeypatch): + _fake_audio(monkeypatch) + calls = [] + eng = _FakeEngine(fire=True) + det = ww.WakeWordDetector(eng, lambda: calls.append(1), cooldown=10.0) + det.start() + time.sleep(0.25) + det.stop() + assert len(calls) == 1 # high cooldown suppresses repeats + assert eng.closed is True + assert det.running is False + + +def test_detector_refires_after_cooldown(monkeypatch): + _fake_audio(monkeypatch) + calls = [] + det = ww.WakeWordDetector(_FakeEngine(fire=True), lambda: calls.append(1), cooldown=0.05) + det.start() + time.sleep(0.3) + det.stop() + assert len(calls) >= 2 + + +def test_detector_no_fire_when_engine_quiet(monkeypatch): + _fake_audio(monkeypatch) + calls = [] + det = ww.WakeWordDetector(_FakeEngine(fire=False), lambda: calls.append(1)) + det.start() + time.sleep(0.15) + det.stop() + assert calls == [] + + +def test_detector_resets_engine_on_each_start(monkeypatch): + # Clearing the engine buffer on (re)start is what stops a resume right after + # a voice turn from re-firing on stale audio (the runaway wake loop). + _fake_audio(monkeypatch) + eng = _FakeEngine(fire=False) + det = ww.WakeWordDetector(eng, lambda: None) + det.start() + time.sleep(0.05) + det.pause() + det.resume() + time.sleep(0.05) + det.stop() + assert eng.resets >= 2 # initial start + resume + + +def test_detector_pause_resume(monkeypatch): + _fake_audio(monkeypatch) + det = ww.WakeWordDetector(_FakeEngine(fire=False), lambda: None) + det.start() + time.sleep(0.05) + assert det.running is True + det.pause() + assert det.running is False + det.resume() + time.sleep(0.05) + assert det.running is True + det.stop() + assert det.running is False + + +# ── Singleton lifecycle ────────────────────────────────────────────────── + + +def test_singleton_lifecycle(monkeypatch): + _fake_audio(monkeypatch) + monkeypatch.setattr(ww, "_build_engine", lambda cfg: _FakeEngine(fire=False)) + + assert ww.is_listening() is False + det = ww.start_listening(lambda: None, config={}) + time.sleep(0.05) + assert ww.is_listening() is True + + # Re-entrant start returns the same detector and re-arms it. + det2 = ww.start_listening(lambda: None, config={}) + assert det2 is det + + ww.pause_listening() + assert ww.is_listening() is False + ww.resume_listening() + time.sleep(0.05) + assert ww.is_listening() is True + + ww.stop_listening() + assert ww.is_listening() is False diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index ec5692ecd550..9a372452ef8a 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -135,6 +135,21 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = { "numpy==2.4.3", ), + # ─── Wake word ("Hey Hermes") engines ────────────────────────────────── + # Keep in sync with the `wake` extra in pyproject.toml. openWakeWord is the + # free, local default (ONNX runtime); Porcupine is the premium engine. + "wake.openwakeword": ( + "openwakeword==0.6.0", + "onnxruntime==1.27.0", + "sounddevice==0.5.5", + "numpy==2.4.3", + ), + "wake.porcupine": ( + "pvporcupine==4.0.3", + "sounddevice==0.5.5", + "numpy==2.4.3", + ), + # ─── Image generation backends ───────────────────────────────────────── "image.fal": ("fal-client==0.13.1",), diff --git a/tools/wake_word.py b/tools/wake_word.py new file mode 100644 index 000000000000..ed7b7e9977ab --- /dev/null +++ b/tools/wake_word.py @@ -0,0 +1,482 @@ +"""Wake-word ("Hey Hermes") detection — hands-free session trigger. + +A lightweight, always-on hotword listener that fires a callback when a wake +phrase is spoken — the "Hey Siri" / "Alexa" pattern. Shared by the CLI, TUI, and +desktop GUI (one of them owns it, gated by ``wake_surface_enabled``): say the +wake word, Hermes opens a fresh session and captures voice via the existing +pipeline, then answers. + +Two engines, both fully on-device (no audio leaves the machine for detection): + +* **openwakeword** (default, free, no API key) — loads an ONNX model. Defaults + to the bundled "hey hermes" model (``tools/wakewords/``) so the wake word + works out of the box; or point ``wake_word.openwakeword.model`` at a built-in + name (``hey_jarvis``, ``alexa``, …) or a custom ``.onnx`` for another phrase. +* **porcupine** (premium) — Picovoice's engine. Needs ``PORCUPINE_ACCESS_KEY``; + supports built-in keywords and custom ``.ppn`` files from the Picovoice + Console. + +Audio capture reuses the same 16 kHz mono int16 ``sounddevice`` path as voice +mode. The detector runs on its own daemon thread; callers ``pause()`` it while a +voice turn holds the microphone and ``resume()`` it once the system is idle +again (two input streams on one device is unreliable cross-platform). + +Nothing here mutates agent context or the prompt cache — on wake we hand a plain +string to the caller, exactly like a voice transcript. +""" + +from __future__ import annotations + +import logging +import os +import threading +import time +from typing import Any, Callable, Dict, Optional + +logger = logging.getLogger(__name__) + +# 16 kHz mono int16 — Whisper-native and what both engines expect. +SAMPLE_RATE = 16000 + +# Minimum gap between two consecutive wake fires, so one "hey hermes" can't +# retrigger across several frames while the caller is still reacting. +_FIRE_COOLDOWN_SECONDS = 2.0 + + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +_DEFAULTS: Dict[str, Any] = { + "enabled": False, + "surface": "auto", + "provider": "openwakeword", + "phrase": "hey hermes", + "sensitivity": 0.5, + "start_new_session": True, +} + +# Bundled "hey hermes" model (tools/wakewords/) — the default, so the wake word +# works out of the box. Config names in _ALIASES resolve to it, not a built-in. +_BUNDLED_MODEL_NAME = "hey_hermes" +_BUNDLED_MODEL_ALIASES = frozenset({"", "hey_hermes", "hey hermes", "hermes"}) + + +def _bundled_wakeword_path(framework: str = "onnx") -> str: + """Path to the shipped hey_hermes model (.onnx/.tflite) for ``framework``.""" + ext = "tflite" if str(framework).strip().lower() == "tflite" else "onnx" + return os.path.join(os.path.dirname(__file__), "wakewords", f"{_BUNDLED_MODEL_NAME}.{ext}") + + +def load_wake_word_config() -> Dict[str, Any]: + """Return the ``wake_word`` config section, shape-guarded to a dict.""" + try: + from hermes_cli.config import load_config + + cfg = load_config().get("wake_word") + except Exception: + cfg = None + return cfg if isinstance(cfg, dict) else {} + + +def _get(cfg: Dict[str, Any], key: str) -> Any: + val = cfg.get(key, _DEFAULTS.get(key)) + return _DEFAULTS.get(key) if val is None else val + + +def _provider(cfg: Dict[str, Any]) -> str: + return str(_get(cfg, "provider")).strip().lower() or "openwakeword" + + +def _sensitivity(cfg: Dict[str, Any]) -> float: + raw = _get(cfg, "sensitivity") + try: + s = float(raw) + except (TypeError, ValueError): + s = 0.5 + return min(max(s, 0.0), 1.0) + + +def wake_phrase(cfg: Optional[Dict[str, Any]] = None) -> str: + """Human-facing wake phrase label (purely cosmetic; engine keys detection).""" + cfg = cfg if cfg is not None else load_wake_word_config() + return str(_get(cfg, "phrase")) or "hey hermes" + + +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) +# --------------------------------------------------------------------------- + +def _import_audio(): + import numpy as np + import sounddevice as sd + + return sd, np + + +def _audio_available() -> bool: + try: + _import_audio() + return True + except (ImportError, OSError): + return False + + +# --------------------------------------------------------------------------- +# Engines +# --------------------------------------------------------------------------- + +class _Engine: + """Minimal hotword-engine contract: feed int16 frames, get a bool.""" + + frame_length: int = 1280 # 80 ms at 16 kHz + + def process(self, frame) -> bool: # frame: 1-D int16 ndarray + raise NotImplementedError + + def reset(self) -> None: + """Clear any internal audio/feature buffer (called on every (re)start).""" + pass + + def close(self) -> None: + pass + + +def _looks_like_path(value: str) -> bool: + return ( + os.sep in value + or value.endswith((".onnx", ".tflite", ".ppn")) + or os.path.exists(value) + ) + + +class _OpenWakeWordEngine(_Engine): + """openWakeWord — free, local ONNX hotword detection.""" + + # openWakeWord recommends 80 ms frames (1280 samples) for efficiency. + frame_length = 1280 + + def __init__(self, cfg: Dict[str, Any]): + from tools import lazy_deps + + lazy_deps.ensure("wake.openwakeword", prompt=False) + + import openwakeword + from openwakeword.model import Model + + sub = cfg.get("openwakeword") if isinstance(cfg.get("openwakeword"), dict) else {} + model_ref = str(sub.get("model") or _BUNDLED_MODEL_NAME).strip() + framework = str(sub.get("inference_framework") or "onnx").strip().lower() + self._threshold = _sensitivity(cfg) + + # Default (or explicit "hey_hermes") → the bundled model; a built-in name + # or custom path is used as-is. + if model_ref.lower() in _BUNDLED_MODEL_ALIASES: + model_ref = _bundled_wakeword_path(framework) + + # openWakeWord needs its shared feature models (melspectrogram + embedding) + # for ANY model — download_models() fetches those first on every call, so a + # custom path must call it too, else a fresh install crashes on a missing + # melspectrogram.onnx. A built-in name additionally pulls that pretrained + # model; a path matches nothing in the catalog and is a no-op beyond base. + try: + openwakeword.utils.download_models([model_ref]) + except Exception as e: # pragma: no cover - network/path dependent + logger.debug("openwakeword base-model fetch skipped: %s", e) + + models = [model_ref] + self._model = Model(wakeword_models=models, inference_framework=framework) + self._labels = list(self._model.models.keys()) + + def process(self, frame) -> bool: + scores = self._model.predict(frame) + return any(score >= self._threshold for score in scores.values()) + + def reset(self) -> None: + # Clears openWakeWord's rolling feature/prediction buffer so stale audio + # captured before a pause can't re-fire the moment we resume. + try: + self._model.reset() + except Exception: + pass + + def close(self) -> None: + self.reset() + + +class _PorcupineEngine(_Engine): + """Picovoice Porcupine — premium, on-device, needs an access key.""" + + def __init__(self, cfg: Dict[str, Any]): + from tools import lazy_deps + + lazy_deps.ensure("wake.porcupine", prompt=False) + + import pvporcupine + + access_key = (os.getenv("PORCUPINE_ACCESS_KEY") or "").strip() + if not access_key: + raise RuntimeError( + "Porcupine wake word requires PORCUPINE_ACCESS_KEY " + "(get a free key at https://console.picovoice.ai)." + ) + + sub = cfg.get("porcupine") if isinstance(cfg.get("porcupine"), dict) else {} + keyword = str(sub.get("keyword") or "jarvis").strip() + sensitivity = _sensitivity(cfg) + + kwargs: Dict[str, Any] = {"access_key": access_key, "sensitivities": [sensitivity]} + if _looks_like_path(keyword): + kwargs["keyword_paths"] = [keyword] + else: + kwargs["keywords"] = [keyword] + + self._porcupine = pvporcupine.create(**kwargs) + self.frame_length = self._porcupine.frame_length + + def process(self, frame) -> bool: + # pvporcupine wants a plain list/sequence of int16 samples. + return self._porcupine.process(frame) >= 0 + + def close(self) -> None: + try: + self._porcupine.delete() + except Exception: + pass + + +def _build_engine(cfg: Dict[str, Any]) -> _Engine: + provider = _provider(cfg) + if provider == "porcupine": + return _PorcupineEngine(cfg) + if provider in ("openwakeword", "oww", "local"): + return _OpenWakeWordEngine(cfg) + raise ValueError(f"Unknown wake_word provider: {provider!r}") + + +# --------------------------------------------------------------------------- +# Requirements probe (for /wake status + enable path) +# --------------------------------------------------------------------------- + +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() + provider = _provider(cfg) + from tools import lazy_deps + + feature = "wake.porcupine" if provider == "porcupine" else "wake.openwakeword" + deps_ok = lazy_deps.is_available(feature) + audio_ok = _audio_available() + key_ok = True + hint = "" + + if provider == "porcupine" and not (os.getenv("PORCUPINE_ACCESS_KEY") or "").strip(): + key_ok = False + hint = "Set PORCUPINE_ACCESS_KEY (free key at https://console.picovoice.ai)." + elif not deps_ok: + hint = lazy_deps.feature_install_command(feature) or "" + elif not audio_ok: + hint = "Microphone capture needs sounddevice + numpy and a working audio device." + + return { + "available": audio_ok and (deps_ok or lazy_deps._allow_lazy_installs()) and key_ok, + "provider": provider, + "deps_available": deps_ok, + "audio_available": audio_ok, + "access_key_set": key_ok, + "phrase": wake_phrase(cfg), + "hint": hint, + } + + +# --------------------------------------------------------------------------- +# Detector +# --------------------------------------------------------------------------- + +class WakeWordDetector: + """Background hotword listener. Fires ``on_wake()`` when the phrase is heard. + + The engine is built once and kept alive across pause/resume; only the audio + stream + reader thread cycle, so toggling the mic for a voice turn is cheap. + """ + + def __init__(self, engine: _Engine, on_wake: Callable[[], None], + cooldown: float = _FIRE_COOLDOWN_SECONDS): + self.engine = engine + self.on_wake = on_wake + self.cooldown = cooldown + self._thread: Optional[threading.Thread] = None + self._stop = threading.Event() + self._last_fire = 0.0 + self._lock = threading.Lock() + + @property + def running(self) -> bool: + t = self._thread + return t is not None and t.is_alive() + + def start(self) -> None: + """Open the mic and begin listening. Idempotent.""" + with self._lock: + if self._thread is not None and self._thread.is_alive(): + return + self._stop.clear() + self._thread = threading.Thread( + target=self._run, daemon=True, name="wake-word" + ) + self._thread.start() + + # pause/resume keep the engine; stop tears it down. + def pause(self) -> None: + self._halt_thread() + + def resume(self) -> None: + self.start() + + def stop(self) -> None: + self._halt_thread() + self.engine.close() + + def _halt_thread(self) -> None: + with self._lock: + t, self._thread = self._thread, None + if t is not None and t is not threading.current_thread(): + self._stop.set() + t.join(timeout=2.0) + + def _run(self) -> None: + try: + sd, _ = _import_audio() + except (ImportError, OSError) as e: + logger.error("wake word: audio libraries unavailable: %s", e) + return + + frame_length = self.engine.frame_length + try: + stream = sd.InputStream( + samplerate=SAMPLE_RATE, + channels=1, + dtype="int16", + blocksize=frame_length, + ) + stream.start() + except Exception as e: + logger.error("wake word: failed to open microphone: %s", e) + return + + # Drop any buffered audio/feature state so a resume right after a voice + # turn can't immediately re-fire on audio captured before the pause (the + # wake → voice → resume → wake runaway loop). + try: + self.engine.reset() + except Exception: + pass + + logger.info("wake word: listening (frame=%d, rate=%d)", frame_length, SAMPLE_RATE) + try: + while not self._stop.is_set(): + try: + data, _overflow = stream.read(frame_length) + except Exception as e: + logger.warning("wake word: stream read error: %s", e) + break + frame = data[:, 0] if getattr(data, "ndim", 1) == 2 else data + try: + fired = self.engine.process(frame) + except Exception as e: + logger.debug("wake word: engine error: %s", e) + continue + if fired: + now = time.monotonic() + if now - self._last_fire >= self.cooldown: + self._last_fire = now + logger.info("wake word: phrase detected — firing callback") + try: + self.on_wake() + except Exception as e: + logger.warning("wake word callback failed: %s", e) + else: + logger.debug("wake word: detection within cooldown — ignored") + finally: + try: + stream.stop() + stream.close() + except Exception: + pass + logger.info("wake word: stream closed") + + +# --------------------------------------------------------------------------- +# Process-wide singleton (mirrors hermes_cli.voice's continuous API) +# --------------------------------------------------------------------------- + +_detector: Optional[WakeWordDetector] = None +_detector_lock = threading.Lock() + + +def start_listening( + on_wake: Callable[[], None], + *, + config: Optional[Dict[str, Any]] = None, +) -> WakeWordDetector: + """Build (once) and start the wake-word detector. Idempotent. + + Raises if engine construction fails (missing deps / access key / model); + callers should probe :func:`check_wake_word_requirements` first. + """ + global _detector + with _detector_lock: + if _detector is not None: + _detector.on_wake = on_wake + _detector.resume() + return _detector + cfg = config if config is not None else load_wake_word_config() + engine = _build_engine(cfg) + _detector = WakeWordDetector(engine, on_wake) + _detector.start() + return _detector + + +def pause_listening() -> None: + """Release the microphone without tearing down the engine.""" + with _detector_lock: + det = _detector + if det is not None: + det.pause() + + +def resume_listening() -> None: + """Re-open the microphone after a pause. No-op if not initialised.""" + with _detector_lock: + det = _detector + if det is not None: + det.resume() + + +def stop_listening() -> None: + """Fully stop and discard the detector (closes the engine).""" + global _detector + with _detector_lock: + det, _detector = _detector, None + if det is not None: + det.stop() + + +def is_listening() -> bool: + with _detector_lock: + det = _detector + return det is not None and det.running diff --git a/tools/wakewords/README.md b/tools/wakewords/README.md new file mode 100644 index 000000000000..439bbae4aae9 --- /dev/null +++ b/tools/wakewords/README.md @@ -0,0 +1,20 @@ +# Bundled wake-word models + +`hey_hermes.onnx` / `hey_hermes.tflite` — the on-device "Hey Hermes" hotword +model. This is the default detector for the wake word feature (see +`website/docs/user-guide/features/wake-word.md`); no training or setup is +required to say "hey hermes". + +- **Engine:** [openWakeWord](https://github.com/dscripka/openWakeWord) (Apache-2.0). +- **Provenance:** trained with the openWakeWord training pipeline (synthetic + TTS-generated speech), which produces both the `.onnx` and `.tflite` artifacts. + Redistribution is permitted under the openWakeWord license. +- **Label:** the model registers as `hey_hermes` (matches the filename). +- **Runtime:** openWakeWord's shared feature-extraction models (melspectrogram + + embedding) are NOT bundled here — they are fetched once on first use by + `tools/wake_word.py` via `openwakeword.utils.download_models()`. + +To use a different phrase, train your own model and point +`wake_word.openwakeword.model` at its path, or set a built-in openWakeWord name +(`hey_jarvis`, `alexa`, `hey_mycroft`, …). See the wake-word docs for the +training guide. diff --git a/tools/wakewords/hey_hermes.onnx b/tools/wakewords/hey_hermes.onnx new file mode 100644 index 000000000000..a730b34747fa Binary files /dev/null and b/tools/wakewords/hey_hermes.onnx differ diff --git a/tools/wakewords/hey_hermes.tflite b/tools/wakewords/hey_hermes.tflite new file mode 100644 index 000000000000..6811e7f07c73 Binary files /dev/null and b/tools/wakewords/hey_hermes.tflite differ diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 03d8492d932c..215290d42005 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -905,6 +905,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: @@ -15375,6 +15380,168 @@ 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 = "" +# Transport captured at wake.start time. The detector callback fires on a +# background thread where the request-scoped transport ContextVar is unset, so +# write_json would fall back to stdio and the event would never cross the +# desktop's websocket (#wake-detected-not-delivered). We pin the arming +# request's transport here and bind it for the emit. +_wake_transport: "Optional[Transport]" = None + + +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 + transport = _wake_transport + phrase, new_session = "", True + try: + from tools.wake_word import load_wake_word_config, wake_phrase + cfg = load_wake_word_config() + phrase = wake_phrase(cfg) + new_session = bool(cfg.get("start_new_session", True)) + except Exception: + pass + logger.info("wake.detected: emitting to sid=%r (transport=%s)", + sid, type(transport).__name__ if transport else None) + # Bind the arming request's transport so write_json reaches the right peer + # (WS for desktop/dashboard) instead of falling back to stdio on this + # background thread. Carry start_new_session so every surface honors it. + token = bind_transport(transport) if transport is not None else None + try: + _emit("wake.detected", sid, {"phrase": phrase, "start_new_session": new_session}) + finally: + if token is not None: + reset_transport(token) + + +@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, _wake_transport + 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): + logger.info("wake.start(%s): disabled for surface (enabled=%s, surface=%s)", + surface, cfg.get("enabled"), cfg.get("surface")) + return _ok(rid, {"started": False, "reason": "disabled_for_surface"}) + 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": reqs.get("hint") or "unavailable"}) + + with _wake_lock: + _wake_event_sid = params.get("session_id") or _wake_event_sid + # Capture the live transport (WS for desktop) so the background detector + # thread can route wake.detected back to this client, not stdio. + _wake_transport = current_transport() or _wake_transport + try: + start_listening(_wake_on_detect, config=cfg) + except Exception as e: + logger.warning("wake.start(%s): failed to start listener: %s", surface, e) + return _err(rid, 5026, str(e)) + with _wake_lock: + _wake_active = True + logger.info("wake.start(%s): listening for %r (%s)", surface, reqs["phrase"], reqs["provider"]) + 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() + logger.info("wake.pause: detector paused") + except Exception as e: + logger.debug("wake.pause failed: %s", e) + 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() + logger.info("wake.resume: detector resumed") + else: + logger.info("wake.resume: ignored (listener not armed)") + 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. @@ -15519,12 +15686,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, @@ -15540,6 +15723,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( diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 7ff42d53d265..b19cec4d474d 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -599,6 +599,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('commands.catalog', {}) .then(r => { if (!r?.pairs) { @@ -914,6 +918,25 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return } + case 'wake.detected': { + // "Hey Hermes": optionally open a fresh session (start_new_session), + // then arm voice capture so the user can speak hands-free. Mirrors CLI. + void (async () => { + if (ev.payload?.start_new_session !== false) { + 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() : '' diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 8ef5ba46fc89..e5f1bc96bc35 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -581,6 +581,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; start_new_session?: boolean }; 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' } | { diff --git a/uv.lock b/uv.lock index 13a9912aa785..5fd48634e465 100644 --- a/uv.lock +++ b/uv.lock @@ -1415,7 +1415,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/58/5404031044f55afad7aad1aff8be3f22b1bed03e237cfeabbc7e5c8cfde0/greenlet-3.5.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:aca9b4ce85b152b5524ef7d88170efdff80dc0032aa8b75f9aaf7f3479ea95b4", size = 287424, upload-time = "2026-06-26T18:20:31.469Z" }, { url = "https://files.pythonhosted.org/packages/b4/bf/1c65e9b94a54d547068fa5b5a8a06f221f3316b48908e08668d29c77cb50/greenlet-3.5.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f71be4920368fe1fabeeaa53d1e3548337e2b223d9565f8ad5e392a75ba23fc", size = 606523, upload-time = "2026-06-26T19:07:08.859Z" }, { url = "https://files.pythonhosted.org/packages/b8/c7/b66baacc95775ad511287acb0137b95574a9ce5491902372b7564799d790/greenlet-3.5.3-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d77e67f65f98449e3fb83f795b5d0a8437aead2f874ca89c96576caf4be3af6", size = 618315, upload-time = "2026-06-26T19:10:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a0/68afd1ebad40db87dac0a28ffa120726b98bf9c7c40c481b0f63c105d298/greenlet-3.5.3-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e18619ba655ac05d78d80fc83cac4ba892bd6927b99e3b8237aee861aaacc8bb", size = 626155, upload-time = "2026-06-26T19:24:14.44Z" }, { url = "https://files.pythonhosted.org/packages/78/2b/28ed29463522fdbe4c15b1f63922041626a7478316b34ab4adda3f0a4aba/greenlet-3.5.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8540f1e6205bd13ca0ce685581037219ca54a1b41a0a15d228c6c9b8ad5903d7", size = 617381, upload-time = "2026-06-26T18:32:16.077Z" }, + { url = "https://files.pythonhosted.org/packages/07/7f/e327d912239ec4b3b49999e3967389bcf1ee8722b9ee9194d2752ecd558a/greenlet-3.5.3-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:d27c0c653a60d9535f690226474a5cc1036a8b0d7b57504d1c4f89c44a07a80c", size = 421083, upload-time = "2026-06-26T19:25:35.804Z" }, { url = "https://files.pythonhosted.org/packages/2a/7b/ad04e9d1337fc04965dc9fc616b6a72cb65a24b800a014c011ec812f5489/greenlet-3.5.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ef56fe650f50575bf843acde967b9c567687f3c22340941a899b7bc56e956a8", size = 1577771, upload-time = "2026-06-26T19:09:01.537Z" }, { url = "https://files.pythonhosted.org/packages/d8/33/6c87ab7ba663f70ca21f3022aad1ffe56d3f3e0521e836c2415e13abcc3c/greenlet-3.5.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5121af01cf911e70056c00d4b46d5e9b5d1415550038573d744138bacb59e6b8", size = 1644048, upload-time = "2026-06-26T18:31:42.996Z" }, { url = "https://files.pythonhosted.org/packages/1c/35/f0d8ee998b422cf8693b270f098e55d8d4ec8006b061b333f54f177d28d9/greenlet-3.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:0f41e4a05a3c0cb31b17023eff28dd111e1d16bf7d7d00406cd7df23f31398a7", size = 239137, upload-time = "2026-06-26T18:23:21.664Z" }, @@ -1423,7 +1425,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" }, { url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" }, { url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" }, + { url = "https://files.pythonhosted.org/packages/fb/fb/d97dc261209c80744b7c8132693a30d70ec6e7315e632cb0a10b3fec94dd/greenlet-3.5.3-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5795cd1101371140551c645f2d408b8d3c01a5a29cf8a9bce6e759c983682d23", size = 622351, upload-time = "2026-06-26T19:24:16.32Z" }, { url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/e5fee13cbbd0e8de312d9a146584b8a51891c68847330ef9dc8b5109d23f/greenlet-3.5.3-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:af4923b3096e26a36d7e9cf24ab88083a20f97d191e3b97f253731ce9b41b28c", size = 425395, upload-time = "2026-06-26T19:25:37.144Z" }, { url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" }, { url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" }, { url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" }, @@ -1431,7 +1435,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" }, { url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" }, { url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/2f5b1adf256d039f5dab8005de8d3d7ad2b0070a3219c0e036b3fbfeb440/greenlet-3.5.3-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ad04dd75458c6300b047c61b8639092433d205a25a14e310d6582a480efcca1", size = 625580, upload-time = "2026-06-26T19:24:18.344Z" }, { url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/ab7fc9e543e44d6879b0a6ef9a4b2188940fd180cc65d6f646883ddf7201/greenlet-3.5.3-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:afaabdd554cd7ae9bbb3ca070b0d7fdfd207dbf1d16865f7233837709d354bda", size = 427933, upload-time = "2026-06-26T19:25:38.219Z" }, { url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" }, { url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" }, { url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" }, @@ -1718,6 +1724,13 @@ voice = [ { name = "numpy" }, { name = "sounddevice" }, ] +wake = [ + { name = "numpy" }, + { name = "onnxruntime" }, + { name = "openwakeword" }, + { name = "pvporcupine" }, + { name = "sounddevice" }, +] web = [ { name = "fastapi" }, { name = "python-multipart" }, @@ -1807,7 +1820,10 @@ requires-dist = [ { name = "modal", marker = "extra == 'modal'", specifier = "==1.3.4" }, { name = "nemo-relay", marker = "extra == 'nemo-relay'", specifier = ">=0.5,<1.0" }, { name = "numpy", marker = "extra == 'voice'", specifier = "==2.4.3" }, + { name = "numpy", marker = "extra == 'wake'", specifier = "==2.4.3" }, + { name = "onnxruntime", marker = "extra == 'wake'", specifier = "==1.27.0" }, { name = "openai", specifier = "==2.24.0" }, + { name = "openwakeword", marker = "extra == 'wake'", specifier = "==0.6.0" }, { name = "packaging", specifier = "==26.0" }, { name = "parallel-web", marker = "extra == 'parallel-web'", specifier = "==0.4.2" }, { name = "pathspec", specifier = "==1.1.1" }, @@ -1815,6 +1831,7 @@ requires-dist = [ { name = "prompt-toolkit", specifier = "==3.0.52" }, { name = "psutil", specifier = "==7.2.2" }, { name = "ptyprocess", marker = "sys_platform != 'win32'", specifier = ">=0.7.0,<1" }, + { name = "pvporcupine", marker = "extra == 'wake'", specifier = "==4.0.3" }, { name = "pydantic", specifier = "==2.13.4" }, { name = "pyjwt", extras = ["crypto"], specifier = "==2.13.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.2" }, @@ -1841,6 +1858,7 @@ requires-dist = [ { name = "slack-sdk", marker = "extra == 'messaging'", specifier = "==3.43.0" }, { name = "slack-sdk", marker = "extra == 'slack'", specifier = "==3.43.0" }, { name = "sounddevice", marker = "extra == 'voice'", specifier = "==0.5.5" }, + { name = "sounddevice", marker = "extra == 'wake'", specifier = "==0.5.5" }, { name = "starlette", marker = "extra == 'computer-use'", specifier = "==1.0.1" }, { name = "starlette", marker = "extra == 'dev'", specifier = "==1.0.1" }, { name = "starlette", marker = "extra == 'mcp'", specifier = "==1.0.1" }, @@ -1855,7 +1873,7 @@ requires-dist = [ { name = "websockets", specifier = "==15.0.1" }, { name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" }, ] -provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "pty", "honcho", "supermemory", "mem0", "vision", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"] +provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "wake", "pty", "honcho", "supermemory", "mem0", "vision", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"] [[package]] name = "hf-xet" @@ -2148,6 +2166,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + [[package]] name = "jsonpath-python" version = "1.1.6" @@ -2457,15 +2484,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/aa/f0ffbe6bf679a597e8be692ca3cde47de6156435c2b72cf752fec719bb1f/modal-1.3.4-py3-none-any.whl", hash = "sha256:d66a851969f447936b3512f1c3708435ce1ca81171eeddc3eb0678f594493380", size = 773837, upload-time = "2026-02-23T15:44:03.635Z" }, ] -[[package]] -name = "mpmath" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, -] - [[package]] name = "msal" version = "1.36.0" @@ -2608,6 +2626,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] +[[package]] +name = "narwhals" +version = "2.24.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/1d/58946e5aab18393e793bd4add6985b95d0e01c3a2d832f38f54468b10dcd/narwhals-2.24.0.tar.gz", hash = "sha256:b5c0f684ccd9d7475b564111e319a4964abcf2baf79d3cf6b1003d06ac9b828d", size = 661143, upload-time = "2026-07-13T10:49:19.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl", hash = "sha256:42fdedf44e5b2ca7505630d45b4ac3058f38d8485cba9fe1652ca23152df7489", size = 461030, upload-time = "2026-07-13T10:49:17.571Z" }, +] + [[package]] name = "nemo-relay" version = "0.5.0" @@ -2748,33 +2775,32 @@ wheels = [ [[package]] name = "onnxruntime" -version = "1.24.4" +version = "1.27.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "flatbuffers" }, { name = "numpy" }, { name = "packaging" }, { name = "protobuf" }, - { name = "sympy" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/60/69/6c40720201012c6af9aa7d4ecdd620e521bd806dc6269d636fdd5c5aeebe/onnxruntime-1.24.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:0bdfce8e9a6497cec584aab407b71bf697dac5e1b7b7974adc50bf7533bdb3a2", size = 17332131, upload-time = "2026-03-17T22:05:49.005Z" }, - { url = "https://files.pythonhosted.org/packages/38/e9/8c901c150ce0c368da38638f44152fb411059c0c7364b497c9e5c957321a/onnxruntime-1.24.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:046ff290045a387676941a02a8ae5c3ebec6b4f551ae228711968c4a69d8f6b7", size = 15152472, upload-time = "2026-03-17T22:03:26.176Z" }, - { url = "https://files.pythonhosted.org/packages/d5/b6/7a4df417cdd01e8f067a509e123ac8b31af450a719fa7ed81787dd6057ec/onnxruntime-1.24.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e54ad52e61d2d4618dcff8fa1480ac66b24ee2eab73331322db1049f11ccf330", size = 17222993, upload-time = "2026-03-17T22:04:34.485Z" }, - { url = "https://files.pythonhosted.org/packages/dd/59/8febe015f391aa1757fa5ba82c759ea4b6c14ef970132efb5e316665ba61/onnxruntime-1.24.4-cp311-cp311-win_amd64.whl", hash = "sha256:b43b63eb24a2bc8fc77a09be67587a570967a412cccb837b6245ccb546691153", size = 12594863, upload-time = "2026-03-17T22:05:38.749Z" }, - { url = "https://files.pythonhosted.org/packages/32/84/4155fcd362e8873eb6ce305acfeeadacd9e0e59415adac474bea3d9281bb/onnxruntime-1.24.4-cp311-cp311-win_arm64.whl", hash = "sha256:e26478356dba25631fb3f20112e345f8e8bf62c499bb497e8a559f7d69cf7e7b", size = 12259895, upload-time = "2026-03-17T22:05:28.812Z" }, - { url = "https://files.pythonhosted.org/packages/d7/38/31db1b232b4ba960065a90c1506ad7a56995cd8482033184e97fadca17cc/onnxruntime-1.24.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cad1c2b3f455c55678ab2a8caa51fb420c25e6e3cf10f4c23653cdabedc8de78", size = 17341875, upload-time = "2026-03-17T22:05:51.669Z" }, - { url = "https://files.pythonhosted.org/packages/aa/60/c4d1c8043eb42f8a9aa9e931c8c293d289c48ff463267130eca97d13357f/onnxruntime-1.24.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a5c5a544b22f90859c88617ecb30e161ee3349fcc73878854f43d77f00558b5", size = 15172485, upload-time = "2026-03-17T22:03:32.182Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ab/5b68110e0460d73fad814d5bd11c7b1ddcce5c37b10177eb264d6a36e331/onnxruntime-1.24.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d640eb9f3782689b55cfa715094474cd5662f2f137be6a6f847a594b6e9705c", size = 17244912, upload-time = "2026-03-17T22:04:37.251Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f4/6b89e297b93704345f0f3f8c62229bee323ef25682a3f9b4f89a39324950/onnxruntime-1.24.4-cp312-cp312-win_amd64.whl", hash = "sha256:535b29475ca42b593c45fbb2152fbf1cdf3f287315bf650e6a724a0a1d065cdb", size = 12596856, upload-time = "2026-03-17T22:05:41.224Z" }, - { url = "https://files.pythonhosted.org/packages/43/06/8b8ec6e9e6a474fcd5d772453f627ad4549dfe3ab8c0bf70af5afcde551b/onnxruntime-1.24.4-cp312-cp312-win_arm64.whl", hash = "sha256:e6214096e14b7b52e3bee1903dc12dc7ca09cb65e26664668a4620cc5e6f9a90", size = 12270275, upload-time = "2026-03-17T22:05:31.132Z" }, - { url = "https://files.pythonhosted.org/packages/e9/f0/8a21ec0a97e40abb7d8da1e8b20fb9e1af509cc6d191f6faa75f73622fb2/onnxruntime-1.24.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e99a48078baaefa2b50fe5836c319499f71f13f76ed32d0211f39109147a49e0", size = 17341922, upload-time = "2026-03-17T22:03:56.364Z" }, - { url = "https://files.pythonhosted.org/packages/8b/25/d7908de8e08cee9abfa15b8aa82349b79733ae5865162a3609c11598805d/onnxruntime-1.24.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4aaed1e5e1aaacf2343c838a30a7c3ade78f13eeb16817411f929d04040a13", size = 15172290, upload-time = "2026-03-17T22:03:37.124Z" }, - { url = "https://files.pythonhosted.org/packages/7f/72/105ec27a78c5aa0154a7c0cd8c41c19a97799c3b12fc30392928997e3be3/onnxruntime-1.24.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e30c972bc02e072911aabb6891453ec73795386c0af2b761b65444b8a4c4745f", size = 17244738, upload-time = "2026-03-17T22:04:40.625Z" }, - { url = "https://files.pythonhosted.org/packages/05/fb/a592736d968c2f58e12de4d52088dda8e0e724b26ad5c0487263adb45875/onnxruntime-1.24.4-cp313-cp313-win_amd64.whl", hash = "sha256:3b6ba8b0181a3aa88edab00eb01424ffc06f42e71095a91186c2249415fcff93", size = 12597435, upload-time = "2026-03-17T22:05:43.826Z" }, - { url = "https://files.pythonhosted.org/packages/ad/04/ae2479e9841b64bd2eb44f8a64756c62593f896514369a11243b1b86ca5c/onnxruntime-1.24.4-cp313-cp313-win_arm64.whl", hash = "sha256:71d6a5c1821d6e8586a024000ece458db8f2fc0ecd050435d45794827ce81e19", size = 12269852, upload-time = "2026-03-17T22:05:33.353Z" }, - { url = "https://files.pythonhosted.org/packages/b4/af/a479a536c4398ffaf49fbbe755f45d5b8726bdb4335ab31b537f3d7149b8/onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1700f559c8086d06b2a4d5de51e62cb4ff5e2631822f71a36db8c72383db71ee", size = 15176861, upload-time = "2026-03-17T22:03:40.143Z" }, - { url = "https://files.pythonhosted.org/packages/be/13/19f5da70c346a76037da2c2851ecbf1266e61d7f0dcdb887c667210d4608/onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c74e268dc808e61e63784d43f9ddcdaf50a776c2819e8bd1d1b11ef64bf7e36", size = 17247454, upload-time = "2026-03-17T22:04:46.643Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e4/5353d7e09ced4a8f473f843223fc75d726b2b5519dcefc12f22a6c92852d/onnxruntime-1.27.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:8ba14a38c570087f3cdb8cfba33f7a38a1e826c1e5b29e17c28ceda0cc910016", size = 18416484, upload-time = "2026-06-15T22:43:43.894Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1f/a2117aa3f144fce88774efa37440d0ca72d0c9144854dfc0961f2b04c6fc/onnxruntime-1.27.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2eb083321af8a236a84c7c140a7f4cecbfa2a987a18c07c78db471c20cd390ef", size = 16419330, upload-time = "2026-06-15T22:42:37.58Z" }, + { url = "https://files.pythonhosted.org/packages/e0/cd/74bb804170ceb622fda9111df31a07b3024f7491472256d3a90b5391a4d2/onnxruntime-1.27.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4f7b0e90d2d212e2c2deaa6c8291616183ab815d3ec558ea12d3ac8b26d36f4", size = 18636930, upload-time = "2026-06-15T22:43:01.584Z" }, + { url = "https://files.pythonhosted.org/packages/fe/8f/5b8e2b85e81735696887175dbaf6409f215683f5ca9d4928fbb038211d32/onnxruntime-1.27.0-cp311-cp311-win_amd64.whl", hash = "sha256:ff050e4f6bf7f12918fa14dcb047c0b02e295f35e86d42532552be4b3d54e977", size = 13356110, upload-time = "2026-06-15T22:43:32.172Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3a/4f568de678126b6a371a93862f015a82138359decd97fcac61fc84b5b774/onnxruntime-1.27.0-cp311-cp311-win_arm64.whl", hash = "sha256:75fbc1e1fb43a39a856c8209c544cca7817b5de7ac16b15b1bdf55d1cc67b9df", size = 13098635, upload-time = "2026-06-15T22:43:19.607Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b7/dd3a524ed93a820dff1af902d0412957ab12499953333e9daa01af5bc480/onnxruntime-1.27.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a14c2ce45312def86b77aea651f46565e45960cf5f0721bfdff449165086ab76", size = 18433506, upload-time = "2026-06-15T22:43:47.026Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/c3b6b17745a1997d784dadc9bd88d713d2e6721139a5a0e885b28cfb79b1/onnxruntime-1.27.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6fddce0539a4898c7bef35b052ffd37935b2190e35488eab99ce91887743ea1", size = 16438140, upload-time = "2026-06-15T22:42:40.666Z" }, + { url = "https://files.pythonhosted.org/packages/26/81/24dd9b31b0fb912ee19ca53ac1c9764bfd79d58a2ccef564eb693be831a5/onnxruntime-1.27.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c65a7438632d55dfbc8a02ee60bd6cf7dd9d1ba05a43d4b851452f32338e194", size = 18658316, upload-time = "2026-06-15T22:43:04.012Z" }, + { url = "https://files.pythonhosted.org/packages/4f/88/8ec9db1a4d126bb8b758992beb40d1249df171917d75f44a327eb5f20dda/onnxruntime-1.27.0-cp312-cp312-win_amd64.whl", hash = "sha256:20c321cf187ba496e648acf6b4cf90b4d398b0d17c2a77fdaeba365b908cc1c1", size = 13358769, upload-time = "2026-06-15T22:43:34.581Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9f/fdad359dfcba7e7cd8815569b304a596531d4efa77a75d77f8b4981891a2/onnxruntime-1.27.0-cp312-cp312-win_arm64.whl", hash = "sha256:d0d1f68868e2ef30ef70998ba9bbbc5c305e9b17041e3936751c1b8aa6aade06", size = 13104440, upload-time = "2026-06-15T22:43:22.893Z" }, + { url = "https://files.pythonhosted.org/packages/fb/2b/54208fd03ad410480bc17edf4869376362da8bbf46fe186ddf4cb5cc20fe/onnxruntime-1.27.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:b3e5b58b8c89c2b20e086e890aa9527377e5c240dc3ecc1640d18e07705eeb1c", size = 18432958, upload-time = "2026-06-15T22:42:53.105Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/24fc51fcbb126da6d032372314e47b55c3faad58f2aa78c0e199ccd20b9c/onnxruntime-1.27.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48b3d87eb560ff6a772240506f3c78d6d27c63cafedd5c775672e1194f968cfd", size = 16438180, upload-time = "2026-06-15T22:42:43.093Z" }, + { url = "https://files.pythonhosted.org/packages/cb/19/14929c3c2fe0b79b41cce24463062bf3afa4cdd3c19dccf00319caa92bff/onnxruntime-1.27.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6872443f236a554921cda6f318c900e2d0c226792cf3534d00e5057c6926e5d2", size = 18658445, upload-time = "2026-06-15T22:43:08.053Z" }, + { url = "https://files.pythonhosted.org/packages/7f/76/59ed932b0244acd7bbbd6449480053a6d958ea66357f022f932872e19287/onnxruntime-1.27.0-cp313-cp313-win_amd64.whl", hash = "sha256:760021bca514d64a811837820d351a08a41741f16f8b4c26450da708fecf14e6", size = 13357856, upload-time = "2026-06-15T22:43:37.315Z" }, + { url = "https://files.pythonhosted.org/packages/79/51/d1ec60ec7b1e2ae2d7340ba52b8a13529140039cd4407ba8dddbbc046582/onnxruntime-1.27.0-cp313-cp313-win_arm64.whl", hash = "sha256:2fdfa9df40a0ded0028ce6f9cd863264237f3970559dea2b81456e9ac4622b94", size = 13104412, upload-time = "2026-06-15T22:43:27.457Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7d/e6bb1c6445c94f708c38cd8fbb7bf0264108c33498b9445c93e60fe6d329/onnxruntime-1.27.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54c0c4e9202c36c4ecdb1f3443f5dfbfd5ee3b54d1362c4b4c6134110e74fb32", size = 16443331, upload-time = "2026-06-15T22:42:45.649Z" }, + { url = "https://files.pythonhosted.org/packages/72/1b/b18b31e806eabc41077810199fbbb36fbc2d5f19912416e5ccfbf73053d1/onnxruntime-1.27.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1b215aa662c8f983f7d6dedafe65a9be72c26e5338e0fe98b3e0422c32c85428", size = 18670967, upload-time = "2026-06-15T22:43:10.621Z" }, ] [[package]] @@ -2918,6 +2944,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/5c/d3f1733665f7cd582ef0842fb1d2ed0bc1fba10875160593342d22bba375/opentelemetry_util_http-0.60b1-py3-none-any.whl", hash = "sha256:66381ba28550c91bee14dcba8979ace443444af1ed609226634596b4b0faf199", size = 8947, upload-time = "2025-12-11T13:36:37.151Z" }, ] +[[package]] +name = "openwakeword" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "onnxruntime" }, + { name = "requests" }, + { name = "scikit-learn" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "tflite-runtime", marker = "sys_platform == 'linux'" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/9b/73b7d98b07f4e1f525ad39703e0c5f30ff61c3fa16c8bfe4d99eadc0567a/openwakeword-0.6.0.tar.gz", hash = "sha256:36858d90f1183e307485597a912a4e3c3384b14ea9923f83feaffae7c1565565", size = 70830, upload-time = "2024-02-11T20:56:17.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/33/dafd6822bebe463a9098951d06a0d88fb4f8c946ce087025bc4fa132e533/openwakeword-0.6.0-py3-none-any.whl", hash = "sha256:6f423a4e3ae9dd0e3cd12b50ff8abf69679f687b4ab349d7c82c021c0e2abc9d", size = 60690, upload-time = "2024-02-11T20:56:16.179Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -3190,6 +3234,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, ] +[[package]] +name = "pvporcupine" +version = "4.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/37/db209e19c4e1d931d1752bdf05c763f119271bb79661d482bdf5f564f662/pvporcupine-4.0.3.tar.gz", hash = "sha256:87d0e4d743a13c3a15b1fb34a9ced66e14bb1125ae079f2e2c09423364a68386", size = 3643620, upload-time = "2026-06-25T21:58:11.366Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/ef/1c4b8e47d8248fe1b615772028265ca65d9ff3ea98022d84cd973d46db87/pvporcupine-4.0.3-py3-none-any.whl", hash = "sha256:92796dbd3cf80a56db1ce20702cbceb151aee34f19ef730591f815eb16f2ebfb", size = 3659883, upload-time = "2026-06-25T21:58:08.805Z" }, +] + [[package]] name = "pyasn1" version = "0.6.3" @@ -3853,6 +3909,129 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, ] +[[package]] +name = "scikit-learn" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "narwhals" }, + { name = "numpy" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/be/e844fd9586e66540a15b71924d17a6cbc1bb749e81ddd0a796bcdba4c055/scikit_learn-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9db6f4d34e68c8899e4cab27fdf8eafe6ed21f2ba52ceb25ea250cd237f8e47b", size = 8789686, upload-time = "2026-06-02T11:53:05.439Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/ff880f62677a17d035817d543cb0fc8727d01eccbee81c5f7fc733a9d856/scikit_learn-1.9.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f401448645a3e7bc115aa3c094097865155b34bff1cba8101857d9104e99074c", size = 8256782, upload-time = "2026-06-02T11:53:08.904Z" }, + { url = "https://files.pythonhosted.org/packages/25/64/eb40435e1a508ab1b4e284ce43ae80f6a162e5be5e38ed5a6fab467a9ea4/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd3a8ef0c758555a3b23c03adaa858af32f7736785ded50ad5991f59c4ed03fa", size = 8992419, upload-time = "2026-06-02T11:53:11.551Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/4810a28e473185429e45a57eebcc91fc991b33d889cc0676063e671db03d/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7e254636164090da847715a27f8e5478feb98c40a9e0ee90cbd277de9e5ceb8", size = 9281411, upload-time = "2026-06-02T11:53:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/be3d369f40d8178ba3bd86635d132e08cb5329b023e4669d9426d84bc007/scikit_learn-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:5dc1818c77575d149e25fce9ef82dd7b7263ae372f03494158668ad632a69759", size = 8272736, upload-time = "2026-06-02T11:53:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/37/79/a733f02dc2118da7e77a134b34f39f40201a353311b011d20859d2db3556/scikit_learn-1.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:366652351f092b219c248f1e72821e841960a63d8f358f1dcfd54dc1cbdbbc28", size = 7919564, upload-time = "2026-06-02T11:53:21.2Z" }, + { url = "https://files.pythonhosted.org/packages/ac/20/75f915ff375d6249e6550ac740fdbbd66159a068fd3af1400ff62036b07a/scikit_learn-1.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2bd41b0d201bc81575531b96b713d3eb5e5f50fb0b82101ff0f92294fdc236ac", size = 8741122, upload-time = "2026-06-02T11:53:24.08Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d5/2b5148f2279196775e1db2aeb85d14b70ac80e7e32b3b28e7ebeafb0901d/scikit_learn-1.9.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5be45aa4a42a68a533913a6ed736cf309de2226411c79ef8d609a5456f1939b1", size = 8261512, upload-time = "2026-06-02T11:53:27.183Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ee/5adbc77656b71f9456a2f5a7a9fdb4bcf9207a6b962889f1c2f9323afa4e/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e50ed4da51974e86e940690e9a3d82e729b62b5a49f7c9bac534d515d39d86f", size = 8837603, upload-time = "2026-06-02T11:53:30.328Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c2/63fdda36c56437eeb44aaf9493c8bcd62ce230ab1598924fc626ffbfa943/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:056c92bb67ad4c28463c2f2653d9701449201e7e7a9e94e321be0f71c4fef2b8", size = 9132097, upload-time = "2026-06-02T11:53:33.456Z" }, + { url = "https://files.pythonhosted.org/packages/83/a4/c8e67227c680e2259c8864ae72ff48b06e16a6f51253a22167aa02a8aa4e/scikit_learn-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:4306775fad04cc4b472a1b15af1ae9cede1540fbfcc17fbce3767cd8dc7ae283", size = 8211173, upload-time = "2026-06-02T11:53:36.602Z" }, + { url = "https://files.pythonhosted.org/packages/cf/fd/3c0863792e98e67e9184aa4029288a175935eb65443afcd30d4f143450cf/scikit_learn-1.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:26e22435f63bcdcf396b574273f29f13dd531f5ea035801f5be10ba1540a4e60", size = 7867451, upload-time = "2026-06-02T11:53:39.075Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/cf3310626b6d48d3e9be69a1223f9180360b5e6edb045f50fade723ce494/scikit_learn-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80746d63bd4b6eaca54d36fe5feaf4d28bb38dc6f9470f81c7cad7c40155f119", size = 8705188, upload-time = "2026-06-02T11:53:41.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713", size = 8228299, upload-time = "2026-06-02T11:53:44.817Z" }, + { url = "https://files.pythonhosted.org/packages/0c/39/ffe829a5b8ecb40a518724a997794657fdc354ada5e8fe8e64d998c0bac9/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:38c3dcb9a1ffb85505ec53d54c7b4aea0cff70050425a7760c2af661ac85df05", size = 8789690, upload-time = "2026-06-02T11:53:47.461Z" }, + { url = "https://files.pythonhosted.org/packages/1f/88/8dab5de10c638c083772a6be83a3d8106ced492f74a928c8693638e5bb50/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da76d09304a4706db7cc1e3ebaa3b6b98a67365cc11d2996c4f1e58ba47df714", size = 9087723, upload-time = "2026-06-02T11:53:50.702Z" }, + { url = "https://files.pythonhosted.org/packages/20/3f/7917ca72464038f6240ec70c29f94862d08a34a74291ae4d4ec5eb8186a0/scikit_learn-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5808d98f15c6bf6d9d96d2348c1997392a5888ce7097e664105f930c4bca1277", size = 8184330, upload-time = "2026-06-02T11:53:53.396Z" }, + { url = "https://files.pythonhosted.org/packages/78/c7/15739eb2f61fda3c54639e9942414e5a19ad8a8d1f5a3266afad7cb7df80/scikit_learn-1.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:d77f54c017633791bc0225a43e2f8d03745fdcfe4880268fcc4df15f505dec2e", size = 7840653, upload-time = "2026-06-02T11:53:56.035Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version == '3.12.*'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" }, + { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, +] + [[package]] name = "setuptools" version = "81.0.0" @@ -4021,18 +4200,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4c/be/caf3b7d4b21851c7d8ddec7661f22089d95bb55bc7b4bdd79dea1001604e/supermemory-3.50.0-py3-none-any.whl", hash = "sha256:f6e2dd142934ec213d561414aeb0164ee408a34d339b84ed93440f03f4ca2290", size = 155533, upload-time = "2026-06-24T09:29:12.012Z" }, ] -[[package]] -name = "sympy" -version = "1.14.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mpmath" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, -] - [[package]] name = "synchronicity" version = "0.11.1" @@ -4072,6 +4239,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, ] +[[package]] +name = "tflite-runtime" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/a6/02d68cb62cd221589a0ff055073251d883936237c9c990e34a1d7cecd06f/tflite_runtime-2.14.0-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:195ab752e7e57329a68e54dd3dd5439fad888b9bff1be0f0dc042a3237a90e4d", size = 2414486, upload-time = "2023-10-03T21:15:44.331Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e9/5fc0435129c23c17551fcfadc82bd0d5482276213dfbc641f07b4420cb6d/tflite_runtime-2.14.0-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:ce9fa5d770a9725c746dcbf6f59f3178233b3759f09982e8b2db8d2234c333b0", size = 2325913, upload-time = "2023-10-03T21:15:46.348Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/e246c39d92929655bac8878d76406d6fb0293c678237e55621e7ece4a269/tflite_runtime-2.14.0-cp311-cp311-manylinux_2_34_armv7l.whl", hash = "sha256:c4e66a74165b18089c86788400af19fa551768ac782d231a9beae2f6434f7949", size = 1820588, upload-time = "2023-10-03T21:15:48.399Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + [[package]] name = "tokenizers" version = "0.22.2" diff --git a/website/docs/user-guide/features/overview.md b/website/docs/user-guide/features/overview.md index 75f365b38924..cb3eef221099 100644 --- a/website/docs/user-guide/features/overview.md +++ b/website/docs/user-guide/features/overview.md @@ -32,6 +32,7 @@ Hermes Agent includes a rich set of capabilities that extend far beyond basic ch ## Media & Web - **[Voice Mode](voice-mode.md)** — Full voice interaction across CLI and messaging platforms. Talk to the agent using your microphone, hear spoken replies, and have live voice conversations in Discord voice channels. +- **[Wake Word](wake-word.md)** — Hands-free "Hey Hermes" trigger for the CLI. An on-device hotword listener starts a fresh voice session when you speak the wake phrase, the "Hey Siri" way. - **[Browser Automation](browser.md)** — Full browser automation with multiple backends: Browserbase cloud, Browser Use cloud, local Chrome/Brave/Chromium/Edge via CDP, or local Chromium. Navigate websites, fill forms, and extract information. - **[Vision & Image Paste](vision.md)** — Multimodal vision support. Paste images from your clipboard into the CLI and ask the agent to analyze, describe, or work with them using any vision-capable model. - **[Image Generation](image-generation.md)** — Generate images from text prompts using FAL.ai. Eleven models supported (FLUX 2 Klein/Pro, GPT-Image 1.5/2, Nano Banana Pro, Ideogram V3, Recraft V4 Pro, Qwen, Z-Image Turbo, Krea V2 Medium/Large); pick one via `hermes tools`. diff --git a/website/docs/user-guide/features/wake-word.md b/website/docs/user-guide/features/wake-word.md new file mode 100644 index 000000000000..f4a48c7bd3ed --- /dev/null +++ b/website/docs/user-guide/features/wake-word.md @@ -0,0 +1,173 @@ +--- +sidebar_position: 11 +title: "Wake Word" +description: "Hands-free 'Hey Hermes' wake word — start a voice session by speaking, the 'Hey Siri' way" +--- + +# Wake Word ("Hey Hermes") + +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 +to the agent. + +## How it works + +1. With `wake_word.enabled: true` (or after `/wake on`), a lightweight hotword + detector listens on your default microphone. +2. When it hears the wake phrase it pauses itself (freeing the mic), starts a new + session, and records one utterance with voice mode's silence detection. +3. Your speech is transcribed and sent to the agent. After it replies, the + listener resumes automatically and waits for the next wake word. + +It is **off by default** — nothing listens until you turn it on. + +## Engines + +| Engine | Cost | API key | Notes | +|--------|------|---------|-------| +| **openWakeWord** (default) | Free | None | Local ONNX models. Ships a bundled **"hey hermes"** model (default); also supports `hey_jarvis`, `alexa`, `hey_mycroft`, … and custom models | +| **Porcupine** | Free tier / paid | `PORCUPINE_ACCESS_KEY` | Picovoice engine; built-in keywords + custom `.ppn` files | + +By default the phrase is **"hey hermes"** — a model for it ships with Hermes, so +it works out of the box with no training. (On first use, openWakeWord downloads +its shared feature-extraction models — a small one-time fetch.) + +Both are lazy-installed the first time you enable the wake word. To install ahead +of time: + +```bash +uv pip install 'hermes-agent[wake]' # or: pip install 'hermes-agent[wake]' +``` + +## Quick start + +```bash +# In an interactive `hermes` session: +/wake on # start listening (installs the engine on first use) +/wake status # show phrase, provider, and state +/wake off # stop listening +``` + +Or enable it permanently in `~/.hermes/config.yaml`: + +```yaml +wake_word: + enabled: true +``` + +## Configuration + +```yaml +wake_word: + enabled: false + surface: auto # which surface owns the listener: "auto" | "cli" | "tui" | "gui" + provider: openwakeword # "openwakeword" (free, local) | "porcupine" + phrase: "hey hermes" # cosmetic label only — detection is keyed by the model/keyword below + sensitivity: 0.5 # 0.0-1.0 — raise to reduce false triggers + start_new_session: true # start a fresh session on wake vs. continue the current one + openwakeword: + model: hey_hermes # bundled default; OR a built-in name OR a path to a custom .onnx/.tflite + inference_framework: onnx # "onnx" | "tflite" + porcupine: + keyword: jarvis # built-in keyword OR path to a custom .ppn +``` + +`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 different phrase + +"Hey Hermes" works out of the box — the bundled openWakeWord model +(`model: hey_hermes`) is the default. To wake on something else, either name a +built-in openWakeWord model or supply your own. + +### Option A — openWakeWord (free) + +Use a built-in name, or train a custom model (≈75–90 min on a free/Colab GPU), +drop the `.onnx` somewhere, and point the config at it: + +```yaml +wake_word: + enabled: true + provider: openwakeword + phrase: "hey jarvis" # cosmetic label + openwakeword: + model: hey_jarvis # built-in name + # model: ~/.hermes/wakewords/my_phrase.onnx # …or a custom model path +``` + +Training references: + +- [openWakeWord](https://github.com/dscripka/openWakeWord) +- [2026 training Colab](https://github.com/alfiedennen/openwakeword-colab-2026) + +:::tip Pick a distinctive phrase +Wake phrases that don't collide with everyday speech generalize best. Two +syllables with an uncommon word ("hermes" qualifies) beat common words like +"hello" or "stop". +::: + +### Option B — Porcupine (custom keyword in seconds) + +Create a "Hey Hermes" keyword in the [Picovoice Console](https://console.picovoice.ai/), +download the `.ppn`, and: + +```yaml +wake_word: + enabled: true + provider: porcupine + phrase: "hey hermes" + porcupine: + keyword: ~/.hermes/wakewords/hey_hermes.ppn +``` + +Set your access key in `~/.hermes/.env`: + +```bash +PORCUPINE_ACCESS_KEY=your-key-here +``` + +## Requirements + +- A working microphone and the `sounddevice` + `numpy` audio stack (shared with + voice mode). +- 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. +- The wake engine deps (auto-installed, or `hermes-agent[wake]`). + +`/wake status` reports exactly what's missing if the listener won't start. + +## Notes & limits + +- **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 + false triggers, lower if it misses you.