mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-02 00:05:39 +08:00
Compare commits
13 Commits
fix/tui-qu
...
bb/wake-wo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71c2e510cb | ||
|
|
c087ca7bc6 | ||
|
|
5cb8b9a489 | ||
|
|
d365772a48 | ||
|
|
a0e1cd1520 | ||
|
|
4814d6bf40 | ||
|
|
8a2c9bcebc | ||
|
|
896c015ea0 | ||
|
|
8b8a327c20 | ||
|
|
6b47051004 | ||
|
|
42171ff6ac | ||
|
|
305b59c869 | ||
|
|
bd87783c2b |
@@ -27,11 +27,13 @@ import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$composerAttachments,
|
||||
$voiceConversationStartRequest,
|
||||
clearComposerAttachments,
|
||||
clearSessionDraft,
|
||||
type ComposerAttachment,
|
||||
stashSessionDraft,
|
||||
takeSessionDraft
|
||||
takeSessionDraft,
|
||||
takeVoiceConversationStart
|
||||
} from '@/store/composer'
|
||||
import {
|
||||
browseBackward,
|
||||
@@ -60,6 +62,7 @@ import {
|
||||
updateQueuedPrompt
|
||||
} from '@/store/composer-queue'
|
||||
import { $statusItemsBySession } from '@/store/composer-status'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { $previewStatusBySession } from '@/store/preview-status'
|
||||
import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects'
|
||||
@@ -2021,6 +2024,48 @@ export function ChatBar({
|
||||
|
||||
useEffect(() => onComposerVoiceToggleRequest(toggleVoiceConversation), [toggleVoiceConversation])
|
||||
|
||||
// "Hey Hermes" wake word: a latched start request (nanostore) the composer
|
||||
// claims once it's mounted and the gateway is open. 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 (#wake-stays-off).
|
||||
const wakePausedRef = useRef(false)
|
||||
const resumeWakeIfPaused = useCallback(() => {
|
||||
if (!wakePausedRef.current) {
|
||||
return
|
||||
}
|
||||
wakePausedRef.current = false
|
||||
void $gateway.get()?.request('wake.resume', {}).catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (voiceConversationActive) {
|
||||
wakePausedRef.current = true
|
||||
void $gateway.get()?.request('wake.pause', {}).catch(() => undefined)
|
||||
} else {
|
||||
resumeWakeIfPaused()
|
||||
}
|
||||
}, [voiceConversationActive, resumeWakeIfPaused])
|
||||
|
||||
useEffect(() => resumeWakeIfPaused, [resumeWakeIfPaused])
|
||||
|
||||
const contextMenu = (
|
||||
<ContextMenu
|
||||
onInsertText={insertText}
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
setPetOverlayScaleHandler,
|
||||
setPetOverlaySubmitHandler
|
||||
} from '../store/pet-overlay'
|
||||
import { requestVoiceConversationStart } from '../store/composer'
|
||||
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
|
||||
import {
|
||||
$activeGatewayProfile,
|
||||
@@ -739,6 +740,24 @@ export function DesktopController() {
|
||||
updateSessionState
|
||||
})
|
||||
|
||||
// "Hey Hermes": handle the wake event on the canonical onEvent pipeline (the
|
||||
// path every gateway socket already feeds), not a side-registered listener —
|
||||
// open a fresh session and begin back-and-forth voice.
|
||||
const handleGatewayEventWithWake = useCallback(
|
||||
(event: Parameters<typeof handleDesktopGatewayEvent>[0]) => {
|
||||
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, startFreshSessionDraft]
|
||||
)
|
||||
|
||||
// Single global listener for every rebindable hotkey (incl. profile switching)
|
||||
// plus the on-screen keybind editor's capture mode.
|
||||
useKeybinds({
|
||||
@@ -987,7 +1006,7 @@ export function DesktopController() {
|
||||
}, [])
|
||||
|
||||
useGatewayBoot({
|
||||
handleGatewayEvent: handleDesktopGatewayEvent,
|
||||
handleGatewayEvent: handleGatewayEventWithWake,
|
||||
onConnectionReady: c => {
|
||||
connectionRef.current = c
|
||||
},
|
||||
@@ -1006,6 +1025,16 @@ export function DesktopController() {
|
||||
}
|
||||
}, [gatewayState, refreshCurrentModel, refreshSessions])
|
||||
|
||||
// "Hey Hermes" wake word: arm the server-side detector for this surface
|
||||
// (gated on config). Detection arrives as a wake.detected event handled in
|
||||
// handleGatewayEventWithWake. Idempotent server-side, so reconnects are safe.
|
||||
useEffect(() => {
|
||||
if (gatewayState !== 'open') {
|
||||
return
|
||||
}
|
||||
void requestGateway('wake.start', { surface: 'gui' }).catch(() => undefined)
|
||||
}, [gatewayState, requestGateway])
|
||||
|
||||
// Keep the cron jobs section live without a user action: the scheduler ticks
|
||||
// in the background (advancing next-run/state and creating runs), so poll the
|
||||
// job list on an interval (and on tab re-focus) while connected.
|
||||
|
||||
@@ -21,6 +21,27 @@ export const $composerDraft = atom('')
|
||||
export const $composerAttachments = atom<ComposerAttachment[]>([])
|
||||
export const $composerTerminalSelections = atom<Record<string, string>>({})
|
||||
|
||||
// Latched "start a voice conversation" request (the "Hey Hermes" wake word).
|
||||
// A nanostore, not a fire-once window event, because the wake handler opens a
|
||||
// fresh session first — the composer may remount before it can observe the
|
||||
// intent. The composer reads the current value on (re)mount and consumes it
|
||||
// once it's ready (gateway open). Module-level _voiceStartHandled tracks the
|
||||
// last consumed id so a remount doesn't re-trigger or miss a just-set request.
|
||||
export const $voiceConversationStartRequest = atom(0)
|
||||
let _voiceStartHandled = 0
|
||||
|
||||
export const requestVoiceConversationStart = (): void =>
|
||||
$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).
|
||||
|
||||
198
cli.py
198
cli.py
@@ -987,6 +987,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:
|
||||
@@ -8469,6 +8474,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
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:
|
||||
@@ -10924,6 +10931,185 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
|
||||
_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:
|
||||
@@ -14624,7 +14810,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
# 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)
|
||||
|
||||
|
||||
@@ -2658,3 +2658,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]")
|
||||
|
||||
@@ -161,6 +161,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")),
|
||||
|
||||
@@ -1980,6 +1980,30 @@ 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 jarvis", # 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": {
|
||||
# Built-in model name ("hey_jarvis", "alexa", "hey_mycroft", ...) or
|
||||
# a path to a custom .onnx/.tflite model. Train a "hey hermes" model
|
||||
# and point this at it — see the wake-word docs.
|
||||
"model": "hey_jarvis",
|
||||
"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",
|
||||
@@ -3549,6 +3573,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",
|
||||
|
||||
@@ -2958,10 +2958,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,
|
||||
@@ -2981,6 +2986,28 @@ def _elevenlabs_voice_label(voice: Dict[str, Any]) -> str:
|
||||
return f"{name} ({category})" if category else name
|
||||
|
||||
|
||||
# Collapses repeated identical ElevenLabs voice-list failures (the desktop
|
||||
# re-polls on every settings open/focus) to a single log line. Re-arms on
|
||||
# success or when the error signature changes, so a real new failure is seen.
|
||||
_voice_list_last_error: Optional[str] = None
|
||||
|
||||
|
||||
def _voice_list_error_logged_once(signature: Optional[str]) -> bool:
|
||||
"""Return True if ``signature`` is new and should be logged now.
|
||||
|
||||
Passing ``None`` clears the latch (call on success). Idempotent per
|
||||
signature: the same error logs once until it changes.
|
||||
"""
|
||||
global _voice_list_last_error
|
||||
if signature is None:
|
||||
_voice_list_last_error = None
|
||||
return False
|
||||
if signature == _voice_list_last_error:
|
||||
return False
|
||||
_voice_list_last_error = signature
|
||||
return True
|
||||
|
||||
|
||||
@app.get("/api/audio/elevenlabs/voices")
|
||||
async def get_elevenlabs_voices():
|
||||
"""Return ElevenLabs voices when an API key is configured.
|
||||
@@ -3008,9 +3035,27 @@ async def get_elevenlabs_voices():
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
|
||||
payload = await loop.run_in_executor(None, _fetch)
|
||||
except Exception as exc:
|
||||
_log.warning("ElevenLabs voice list failed: %s", exc)
|
||||
except urllib.error.HTTPError as exc:
|
||||
# An auth failure (bad/expired/scoped key) is a persistent,
|
||||
# user-fixable state, not a transient blip — the desktop polls this on
|
||||
# every settings open/focus, so a per-poll WARNING floods the log
|
||||
# (#voice-list-401-spam). Treat 401/403 as "integration unavailable":
|
||||
# report it to the UI with a 200 and log at most once until the error
|
||||
# signature changes (see _voice_list_error_logged_once).
|
||||
if exc.code in (401, 403):
|
||||
if _voice_list_error_logged_once(f"http-{exc.code}"):
|
||||
_log.info(
|
||||
"ElevenLabs voices unavailable: %s — check ELEVENLABS_API_KEY", exc
|
||||
)
|
||||
return {"available": False, "voices": [], "error": "unauthorized"}
|
||||
if _voice_list_error_logged_once(f"http-{exc.code}"):
|
||||
_log.warning("ElevenLabs voice list failed: %s", exc)
|
||||
raise HTTPException(status_code=502, detail="Could not load ElevenLabs voices")
|
||||
except Exception as exc:
|
||||
if _voice_list_error_logged_once(str(exc)):
|
||||
_log.warning("ElevenLabs voice list failed: %s", exc)
|
||||
raise HTTPException(status_code=502, detail="Could not load ElevenLabs voices")
|
||||
_voice_list_error_logged_once(None) # success — re-arm logging for next failure
|
||||
|
||||
voices = []
|
||||
for voice in payload.get("voices") or []:
|
||||
|
||||
@@ -174,6 +174,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
|
||||
|
||||
242
tests/tools/test_wake_word.py
Normal file
242
tests/tools/test_wake_word.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""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 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 hermes"}) == "hey hermes"
|
||||
assert ww.wake_phrase({}) == "hey jarvis"
|
||||
|
||||
|
||||
def test_wake_surface_enabled_gate():
|
||||
# Disabled → never, regardless of surface.
|
||||
assert ww.wake_surface_enabled("cli", {"enabled": False, "surface": "cli"}) is False
|
||||
# auto → every surface.
|
||||
for s in ("cli", "tui", "gui"):
|
||||
assert ww.wake_surface_enabled(s, {"enabled": True, "surface": "auto"}) is True
|
||||
# Pinned surface → only that one.
|
||||
cfg = {"enabled": True, "surface": "tui"}
|
||||
assert ww.wake_surface_enabled("tui", cfg) is True
|
||||
assert ww.wake_surface_enabled("cli", cfg) is False
|
||||
assert ww.wake_surface_enabled("gui", cfg) is False
|
||||
# Missing/blank surface defaults to auto.
|
||||
assert ww.wake_surface_enabled("gui", {"enabled": True}) is True
|
||||
|
||||
|
||||
def test_looks_like_path():
|
||||
assert ww._looks_like_path("models/hey_hermes.onnx")
|
||||
assert ww._looks_like_path("custom.ppn")
|
||||
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
|
||||
|
||||
|
||||
# ── 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
|
||||
@@ -131,6 +131,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",),
|
||||
|
||||
|
||||
466
tools/wake_word.py
Normal file
466
tools/wake_word.py
Normal file
@@ -0,0 +1,466 @@
|
||||
"""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 a pretrained or custom
|
||||
ONNX model. Ships with ``hey_jarvis``, ``alexa``, ``hey_mycroft``, … ; point
|
||||
``wake_word.openwakeword.model`` at a custom ``.onnx`` to detect a real
|
||||
"hey hermes" (training guide in the wake-word docs).
|
||||
* **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 jarvis",
|
||||
"sensitivity": 0.5,
|
||||
"start_new_session": True,
|
||||
}
|
||||
|
||||
|
||||
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 jarvis"
|
||||
|
||||
|
||||
def wake_surface_enabled(surface: str, cfg: Optional[Dict[str, Any]] = None) -> bool:
|
||||
"""Should ``surface`` (``cli`` / ``tui`` / ``gui``) host the listener?
|
||||
|
||||
True when the wake word is enabled and the configured ``surface`` is either
|
||||
``auto`` or this exact surface — the single gate every surface consults so
|
||||
only one place owns the wake word and the new session it opens.
|
||||
"""
|
||||
cfg = cfg if cfg is not None else load_wake_word_config()
|
||||
if not cfg.get("enabled"):
|
||||
return False
|
||||
want = str(_get(cfg, "surface")).strip().lower() or "auto"
|
||||
return want == "auto" or want == surface.strip().lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Audio capture (lazy — never import sounddevice at module load)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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 "hey_jarvis").strip()
|
||||
framework = str(sub.get("inference_framework") or "onnx").strip().lower()
|
||||
self._threshold = _sensitivity(cfg)
|
||||
|
||||
if _looks_like_path(model_ref):
|
||||
models = [model_ref]
|
||||
else:
|
||||
# Pretrained name (e.g. "hey_jarvis"). Best-effort one-time fetch
|
||||
# of the bundled models; harmless if already present / offline.
|
||||
try:
|
||||
openwakeword.utils.download_models([model_ref])
|
||||
except Exception as e: # pragma: no cover - network/path dependent
|
||||
logger.debug("openwakeword model download 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
|
||||
@@ -699,6 +699,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:
|
||||
@@ -12422,6 +12427,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.
|
||||
@@ -12566,12 +12733,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,
|
||||
@@ -12587,6 +12770,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(
|
||||
|
||||
@@ -317,6 +317,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
||||
// "too many re-renders" guard in embedded dashboard PTYs.
|
||||
ensureAgentsNudgeConfig()
|
||||
|
||||
// Arm "Hey Hermes" if this surface owns it (server gates on config).
|
||||
// Fire-and-forget + idempotent server-side, so reconnects are harmless.
|
||||
void rpc('wake.start', { surface: 'tui' })
|
||||
|
||||
rpc<CommandsCatalogResponse>('commands.catalog', {})
|
||||
.then(r => {
|
||||
if (!r?.pairs) {
|
||||
@@ -632,6 +636,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() : ''
|
||||
|
||||
@@ -640,6 +640,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' }
|
||||
| {
|
||||
|
||||
268
uv.lock
generated
268
uv.lock
generated
@@ -3,7 +3,8 @@ revision = 3
|
||||
requires-python = ">=3.11, <3.14"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.13'",
|
||||
"python_full_version < '3.13'",
|
||||
"python_full_version == '3.12.*'",
|
||||
"python_full_version < '3.12'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1614,6 +1615,13 @@ voice = [
|
||||
{ name = "numpy" },
|
||||
{ name = "sounddevice" },
|
||||
]
|
||||
wake = [
|
||||
{ name = "numpy" },
|
||||
{ name = "onnxruntime" },
|
||||
{ name = "openwakeword" },
|
||||
{ name = "pvporcupine" },
|
||||
{ name = "sounddevice" },
|
||||
]
|
||||
web = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "python-multipart" },
|
||||
@@ -1699,7 +1707,10 @@ requires-dist = [
|
||||
{ name = "modal", marker = "extra == 'modal'", specifier = "==1.3.4" },
|
||||
{ name = "nemo-relay", marker = "extra == 'nemo-relay'", specifier = "==0.3" },
|
||||
{ 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" },
|
||||
@@ -1707,6 +1718,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" },
|
||||
@@ -1732,6 +1744,7 @@ requires-dist = [
|
||||
{ name = "slack-sdk", marker = "extra == 'messaging'", specifier = "==3.40.1" },
|
||||
{ name = "slack-sdk", marker = "extra == 'slack'", specifier = "==3.40.1" },
|
||||
{ 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" },
|
||||
@@ -1745,7 +1758,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", "vision", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "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", "vision", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "hf-xet"
|
||||
@@ -2035,6 +2048,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"
|
||||
@@ -2325,15 +2347,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"
|
||||
@@ -2476,6 +2489,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.22.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/62/3c/c4ef2164a71c1a63d7f1ae411c4082c5fa872405106db60a4b7114989ad7/narwhals-2.22.1.tar.gz", hash = "sha256:d62920805a0a43b7ff8b54b0c0d3142d796f8a9301836ada37e573d6a33cbcd9", size = 647493, upload-time = "2026-06-05T12:34:34.051Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/48/ca/36339329c4604adbcc99c899b7eb1ce1a555c499b6a6860757dc9bfed36d/narwhals-2.22.1-py3-none-any.whl", hash = "sha256:60567d774edf77db53906f89d9fbd164e66e56d66d388e1e6990f17ac33cfb53", size = 454815, upload-time = "2026-06-05T12:34:32.289Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nemo-relay"
|
||||
version = "0.3.0"
|
||||
@@ -2616,33 +2638,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]]
|
||||
@@ -2786,6 +2807,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"
|
||||
@@ -3043,6 +3082,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"
|
||||
@@ -3688,6 +3739,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"
|
||||
@@ -3805,18 +3979,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/e1/b2df4bc09a1e51ff664c1e17018a4274b42e5e9352e4a478ea540512dc88/starlette-1.0.1-py3-none-any.whl", hash = "sha256:7c0e69b2ee1c848bd54669d908500117a3ee13de603a21427e5c6fc1adf98dcd", size = 72802, upload-time = "2026-05-21T21:58:56.551Z" },
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -3856,6 +4018,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"
|
||||
|
||||
@@ -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. Nine models supported (FLUX 2 Klein/Pro, GPT-Image 1.5/2, Nano Banana Pro, Ideogram V3, Recraft V4 Pro, Qwen, Z-Image Turbo); pick one via `hermes tools`.
|
||||
|
||||
168
website/docs/user-guide/features/wake-word.md
Normal file
168
website/docs/user-guide/features/wake-word.md
Normal file
@@ -0,0 +1,168 @@
|
||||
---
|
||||
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 with `hey_jarvis`, `alexa`, `hey_mycroft`, … |
|
||||
| **Porcupine** | Free tier / paid | `PORCUPINE_ACCESS_KEY` | Picovoice engine; built-in keywords + custom `.ppn` files |
|
||||
|
||||
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 jarvis" # 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_jarvis # built-in name OR 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 real "Hey Hermes"
|
||||
|
||||
The bundled openWakeWord models do **not** include "hey hermes" — `hey_jarvis`
|
||||
is the free, instantly-working default. To detect the literal phrase you supply
|
||||
your own model and point the config at it:
|
||||
|
||||
### Option A — openWakeWord (free)
|
||||
|
||||
Train a custom model (≈75–90 min on a free/Colab GPU), then drop the `.onnx`
|
||||
file somewhere and reference it:
|
||||
|
||||
```yaml
|
||||
wake_word:
|
||||
enabled: true
|
||||
provider: openwakeword
|
||||
phrase: "hey hermes"
|
||||
openwakeword:
|
||||
model: ~/.hermes/wakewords/hey_hermes.onnx
|
||||
```
|
||||
|
||||
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.
|
||||
Reference in New Issue
Block a user