Compare commits

...
16 Commits
Author SHA1 Message Date
Brooklyn Nicholson 576c090d2f feat(skills): wake-word-training — teach Hermes to train custom wake words
Adds an optional skill so Hermes can build a custom openWakeWord model for a
phrase other than the bundled "hey hermes" (defaults to "hey <profile>"). The
skill drives openWakeWord's own trainer via the terminal tool — no heavy
training stack ships in core, and the existing resolver already loads the
resulting .onnx by path, so there are zero changes to the wake runtime.

- SKILL.md: end-to-end procedure (synthetic positives, augmentation/negatives,
  train, export into the profile's ~/.hermes/wakewords/, wire config, restart).
- scripts/generate_positives.py: OpenAI-TTS positives (default when
  OPENAI_API_KEY is set) with import-lazy synthesis; pure planning helpers.
- scripts/make_training_config.py: emits the openWakeWord training YAML.
- references/platforms.md: Colab fast path + macOS caveats + dataset sources.
- tests: frontmatter contract + pure helper logic, stdlib-only, no network.
- docs: point the wake-word "different phrase" section at the skill.
2026-07-24 10:11:38 -05:00
Hermes Agent 80e78eaf86 chore: map contributor omid3098@gmail.com -> omid3098 2026-07-23 21:34:42 -07:00
Brooklyn Nicholson 95963aaf72 feat(voice): bundle the trained "hey hermes" model as the out-of-the-box default
From #53378: ships hey_hermes.onnx/.tflite (openWakeWord pipeline,
Apache-2.0) under tools/wakewords/, resolves the default (and hey_hermes
aliases) to the bundled file, ensures openWakeWord base feature models
are fetched for custom paths too, and updates config defaults + docs
from hey_jarvis to hey hermes.
2026-07-23 21:34:42 -07:00
Omid Saadat 524963bc61 fix(wake-word): enforce single-owner lifecycle 2026-07-23 21:34:42 -07:00
Brooklyn Nicholson b0af2be01e fix(ci): sync uv.lock and repair wake-word docs MDX
Regenerate uv.lock for the [wake] extra (openwakeword, pvporcupine,
onnxruntime) so uv lock --check passes. Replace angle-bracket URLs in
wake-word.md with markdown links — MDX treats <https://...> as JSX.
2026-07-23 21:34:42 -07:00
Brooklyn Nicholson 00175fe093 fix(voice): honor wake_word.start_new_session on every surface
start_new_session was respected only by the CLI; the TUI and desktop GUI
always opened a fresh session on wake, ignoring the config. The gateway
now carries the flag in the wake.detected payload and both clients honor
it (open a fresh session vs. continue the current one), matching the CLI.
2026-07-23 21:34:42 -07:00
Brooklyn Nicholson 63831cf616 chore(voice): tidy wake_word — drop dead SURFACES, unused np, stale docstring 2026-07-23 21:34:42 -07:00
Brooklyn Nicholson 81b8261ea3 fix(voice): stop wake re-fire loop and empty-transcript error spam
Two bugs surfaced by the desktop wake conversation:

1. Runaway loop: wake -> voice -> resume -> wake fired again within
   ~200ms. openWakeWord keeps its rolling feature buffer across
   pause/resume, so on resume it immediately re-scored the "hey jarvis"
   captured before the pause and re-fired, reopening a session and
   restarting voice in a tight cycle. Reset the engine buffer on every
   detector (re)start so resume begins from clean audio.

2. Empty-transcript toast: a silent re-listen returns
   success:false / "… STT returned empty transcript", which the desktop
   transcribe endpoint turned into a 400 -> thrown error -> "Voice
   transcription failed" notification on every silent gap. Treat an empty
   transcript as no-speech: return {ok, transcript: ""} so the voice loop
   quietly re-listens. Real failures still 4xx/5xx.
2026-07-23 21:34:42 -07:00
Brooklyn Nicholson 04e362fb62 fix(desktop): start voice on wake via a latched store, not a window event
Wake opened a fresh session but voice didn't start: the start intent was
a fire-once window CustomEvent, and the fresh-session remount tore down /
recreated the composer's subscription, so the deferred dispatch landed in
the gap and was lost.

Replace it with a latched nanostore ($voiceConversationStartRequest +
takeVoiceConversationStart): the controller sets it on wake.detected, and
the composer claims it once on (re)mount when the gateway is open, waiting
out any transient `disabled`. Drop the now-unused composer voice-start
window event.
2026-07-23 21:34:42 -07:00
Brooklyn Nicholson 698705e721 fix(desktop): re-arm wake detector after a manual voice end
Ending a voice conversation manually left the wake detector paused for
good, so the wake word couldn't be used again. The composer paused the
detector on voice start but only resumed on the voiceConversationActive
-> false render; if ending voice tore the composer down first, that
render never landed and the resume was skipped.

Resume on unmount as well (latched on wakePausedRef so it fires exactly
once), and stop early-returning when the $gateway atom is momentarily
null. Add wake.pause/resume INFO logs for visibility.
2026-07-23 21:34:42 -07:00
Brooklyn Nicholson 79d2a10129 fix(desktop): deliver wake.detected over the websocket, not stdio
write_json routes via the request-scoped transport ContextVar, but the
wake detector's callback runs on a background thread where that var is
unset — so wake.detected fell back to _stdio_transport and was dumped to
the backend's stdout (visible as raw [hermes] {...} frames in desktop
logs) instead of crossing the desktop/dashboard websocket. The TUI was
unaffected because it IS stdio.

Capture the arming request's transport at wake.start and bind it around
the emit in _wake_on_detect so the background thread routes to the right
peer. Re-armed on each wake.start, so reconnects pick up the new socket.
2026-07-23 21:34:42 -07:00
Brooklyn Nicholson b8bc3661f3 fix(desktop): handle wake.detected on the canonical event pipeline
The GUI armed the detector (wake.start) and the gateway fired
wake.detected, but the desktop never reacted: detection was wired through
a side-registered gatewayRef.current.on('wake.detected', …) listener that
was instance/timing-fragile (and silently dead across reconnects/HMR),
even though the raw events were arriving on the socket.

Route wake.detected through handleGatewayEventWithWake — the same onEvent
pipeline every gateway socket already feeds via useGatewayBoot — and open
a fresh session + start back-and-forth voice there. Drop the separate
.on() listener; the open-effect now only arms wake.start.
2026-07-23 21:34:41 -07:00
Brooklyn Nicholson d020784162 chore(voice): log wake-word lifecycle at INFO for diagnosability
The detector logged listen/detect/close at debug, invisible at the
default level. Promote listen-start, phrase-detected, stream-closed, and
the wake.start outcome (disabled / unavailable / listening) to INFO, and
log wake.detected emission, so a non-triggering setup is diagnosable from
gateway/gui.log without flipping global log levels.
2026-07-23 21:34:41 -07:00
Brooklyn Nicholson 7c2c80ecab feat(desktop): full back-and-forth voice on "Hey Hermes" wake
On wake, the desktop GUI now opens a fresh session AND starts the
browser voice conversation (continuous, with TTS), matching the CLI/TUI
hands-free flow instead of just opening a session.

- Add an explicit requestVoiceStart() intent to the composer bus
  (idempotent start; toggle could stop an active loop).
- Composer owns mic hand-off: pause the server-side wake detector while
  the browser voice loop is live, resume after (server no-ops when the
  wake word isn't armed) — via the $gateway store accessor.
- Controller fires startFreshSessionDraft() + requestVoiceStart() on
  wake.detected.
2026-07-23 21:34:41 -07:00
Brooklyn Nicholson 5d8c96558b feat(voice): extend "Hey Hermes" wake word to TUI + desktop GUI
Makes the wake word a tri-surface feature with one configurable owner.

- wake_word.surface ("auto" | "cli" | "tui" | "gui") + shared
  wake_surface_enabled() gate consulted by every surface, so exactly one
  place owns the listener and the new session it opens.
- tui_gateway: wake.start/stop/pause/resume/status RPCs + a wake.detected
  event, sharing one server-side detector for both TUI and desktop. The
  detector yields the mic to voice.record (pause on capture start, resume
  on terminal) and to the desktop's browser mic (wake.pause/resume).
- TUI (Ink): arm wake.start on gateway.ready; on wake.detected open a
  fresh session and start voice capture.
- Desktop (Electron): arm wake.start on connect; on wake.detected open a
  fresh session.
- CLI now gates on wake_surface_enabled("cli"); /wake status shows surface.
- Tests for the surface gate; docs cover the surface knob + cross-surface.
2026-07-23 21:34:41 -07:00
Brooklyn Nicholson 653c5a80e9 feat(voice): add "Hey Hermes" wake word to start a hands-free session
Adds an opt-in, on-device hotword listener for the CLI. With
wake_word.enabled (or /wake on), Hermes listens in the background for a
wake phrase; on detection it starts a fresh session, captures one
utterance through the existing voice pipeline, and answers — the
"Hey Siri" pattern.

- tools/wake_word.py: provider-pluggable detector (openWakeWord, free
  local default; Porcupine, premium) over the shared 16 kHz sounddevice
  capture path. Background daemon thread with pause/resume so it yields
  the mic during a voice turn.
- CLI wiring: startup listener (off-thread), on-wake flow, an idle
  watchdog that resumes the detector after each turn, cleanup hook, and
  a /wake [on|off|status] command.
- config.yaml wake_word section; PORCUPINE_ACCESS_KEY as an optional
  secret. Engines lazy-install via the [wake] extra.
- Hands a transcript to the input queue exactly like voice mode, so no
  system-prompt/cache mutation. No new core model tool.
- Tests (mocked, no live audio/network) + feature docs.
2026-07-23 21:34:41 -07:00
32 changed files with 3022 additions and 53 deletions
@@ -1,9 +1,12 @@
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useI18n } from '@/i18n'
import { chatMessageText, collectUnspokenTurnSpeech } from '@/lib/chat-messages'
import { triggerHaptic } from '@/lib/haptics'
import { $voiceConversationStartRequest, takeVoiceConversationStart } from '@/store/composer'
import { resetBrowseState } from '@/store/composer-input-history'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
import { $messages } from '@/store/session'
import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs'
@@ -52,6 +55,7 @@ export function useComposerVoice({
const { t } = useI18n()
const [voiceConversationActive, setVoiceConversationActive] = useState(false)
const lastSpokenIdRef = useRef<string | null>(null)
const voiceStartRequest = useStore($voiceConversationStartRequest)
const { dictate, voiceActivityState, voiceStatus } = useVoiceRecorder({
focusInput,
@@ -140,6 +144,40 @@ export function useComposerVoice({
[target, toggleVoiceConversation]
)
useEffect(() => {
if (target === 'main' && !disabled && takeVoiceConversationStart(voiceStartRequest) && !voiceConversationActive) {
setVoiceConversationActive(true)
}
}, [disabled, target, voiceConversationActive, voiceStartRequest])
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()
}
}, [resumeWakeIfPaused, voiceConversationActive])
useEffect(() => resumeWakeIfPaused, [resumeWakeIfPaused])
// Explicit start/end for the on-screen conversation controls (the hotkey uses
// the gated toggle above).
const startConversation = useCallback(() => setVoiceConversationActive(true), [])
+21 -1
View File
@@ -29,6 +29,7 @@ import { sessionMessagesSignature } from '@/lib/session-signatures'
import { isMessagingSource } from '@/lib/session-source'
import { latestSessionTodos } from '@/lib/todos'
import { $billingSettingsRequest } from '@/store/billing-block'
import { requestVoiceConversationStart } from '@/store/composer'
import { setCronFocusJobId } from '@/store/cron'
import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout'
import { $filePreviewTarget, $previewTarget } from '@/store/preview'
@@ -645,9 +646,22 @@ export function ContribWiring({ children }: { children: ReactNode }) {
const handleGatewayEventWithPlugins = useCallback(
(event: Parameters<typeof handleDesktopGatewayEvent>[0]) => {
emitGatewayEvent(event)
if (event.type === 'wake.detected') {
const payload = event.payload as { start_new_session?: boolean } | undefined
if (payload?.start_new_session !== false) {
startFreshSessionDraft()
}
requestVoiceConversationStart()
return
}
handleDesktopGatewayEvent(event)
},
[handleDesktopGatewayEvent]
[handleDesktopGatewayEvent, startFreshSessionDraft]
)
useGatewayBoot({
@@ -668,6 +682,12 @@ export function ContribWiring({ children }: { children: ReactNode }) {
refreshSessions
})
useEffect(() => {
if (gatewayState === 'open') {
void requestGateway('wake.start', { surface: 'gui' }).catch(() => undefined)
}
}, [gatewayState, requestGateway])
// Only the open messaging transcript needs its own poll — local chats are
// live over the websocket already.
const activeIsMessaging =
+16
View File
@@ -2,17 +2,33 @@ import { afterEach, describe, expect, it } from 'vitest'
import {
$composerAttachments,
$voiceConversationStartRequest,
addComposerAttachment,
clearSessionDraft,
type ComposerAttachment,
migrateSessionDraft,
removeComposerAttachment,
requestVoiceConversationStart,
SESSION_DRAFTS_STORAGE_KEY,
stashSessionDraft,
takeSessionDraft,
takeVoiceConversationStart,
updateComposerAttachment
} from './composer'
describe('voice conversation start requests', () => {
it('latches each request until the main composer consumes it once', () => {
requestVoiceConversationStart()
const first = $voiceConversationStartRequest.get()
expect(takeVoiceConversationStart(first)).toBe(true)
expect(takeVoiceConversationStart(first)).toBe(false)
requestVoiceConversationStart()
expect(takeVoiceConversationStart($voiceConversationStartRequest.get())).toBe(true)
})
})
function attachment(overrides: Partial<ComposerAttachment> & Pick<ComposerAttachment, 'id'>): ComposerAttachment {
return { kind: 'file', label: 'doc.pdf', ...overrides }
}
+18
View File
@@ -21,6 +21,24 @@ export const $composerDraft = atom('')
export const $composerAttachments = atom<ComposerAttachment[]>([])
export const $composerTerminalSelections = atom<Record<string, string>>({})
// Latched because opening a fresh session may remount the main composer before
// it can start voice. Session-tile composers deliberately never consume this.
export const $voiceConversationStartRequest = atom(0)
let nextVoiceStartRequest = 0
let handledVoiceStartRequest = 0
export const requestVoiceConversationStart = (): void => $voiceConversationStartRequest.set(++nextVoiceStartRequest)
export const takeVoiceConversationStart = (current: number): boolean => {
if (current <= handledVoiceStartRequest) {
return false
}
handledVoiceStartRequest = current
return true
}
// ---------------------------------------------------------------------------
// Composer scopes — one live attachment set PER MOUNTED COMPOSER. The main
// chat's scope wraps the module-level atom above (all existing readers keep
+218 -1
View File
@@ -984,6 +984,7 @@ def _cleanup_all_browsers(*args, **kwargs):
# Guard to prevent cleanup from running multiple times on exit
_cleanup_done = False
_cli_wake_owner = None
# One-shot CLI finalization runs before process cleanup so plugins can observe
# the session boundary while the agent is still attached. If a signal lands in
# that narrow window, atexit cleanup must not emit that session finalize again.
@@ -1177,6 +1178,12 @@ 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
if _cli_wake_owner is not None:
_stop_wake_word(owner=_cli_wake_owner)
except Exception:
pass
try:
_cleanup_all_terminals()
except Exception:
@@ -9489,6 +9496,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self._handle_skin_command(cmd_original)
elif canonical == "voice":
self._handle_voice_command(cmd_original)
elif canonical == "wake":
self._handle_wake_command(cmd_original)
elif canonical == "busy":
self._handle_busy_command(cmd_original)
else:
@@ -11640,6 +11649,204 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
_cprint(f"\n{_DIM}Voice mode disabled.{_RST}")
# ── Wake word ("Hey Hermes") ─────────────────────────────────────────
#
# An always-on hotword listener (tools/wake_word.py) that, on detecting
# the wake phrase, starts a fresh session and captures one utterance via
# the existing voice pipeline — the "Hey Siri" pattern, fully on-device.
#
# The detector holds the microphone, so it must be paused while a voice
# turn records (two input streams on one device is unreliable). On wake we
# pause it and mark the system suspended; a lightweight watchdog resumes it
# once the turn finishes and the CLI is idle again — covering every exit
# path (transcript submitted, no speech, or transcription error) without
# threading resume logic through the voice machinery.
def _maybe_start_wake_word(self):
"""Start the wake-word listener at CLI startup if this surface is eligible."""
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."""
try:
from tools.wake_word import (
check_wake_word_requirements,
load_wake_word_config,
owns_listener,
start_listening,
)
except Exception as e:
if announce:
_cprint(f"{_DIM}Wake word unavailable: {e}{_RST}")
return False
if getattr(self, "_wake_word_active", False) and owns_listener(self):
if announce:
_cprint(f"{_DIM}Wake word is already listening.{_RST}")
return True
self._wake_word_active = 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, owner=self, 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
global _cli_wake_owner
_cli_wake_owner = self
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."""
global _cli_wake_owner
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(owner=self)
except Exception:
pass
if _cli_wake_owner is self:
_cli_wake_owner = None
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 after the detector hears the wake phrase."""
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
if not pause_listening(owner=self):
self._wake_word_active = False
return
except Exception as e:
logger.debug("wake word pause failed: %s", e)
return
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
if resume_listening(owner=self):
self._wake_suspended = False
else:
self._wake_word_active = 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,
is_listening,
load_wake_word_config,
owns_listener,
)
cfg = load_wake_word_config()
reqs = check_wake_word_requirements(cfg)
owned = owns_listener(self)
state = "LISTENING" if owned and is_listening() else "PAUSED" if owned else "OFF"
_cprint(f"\n{_BOLD}Wake Word Status{_RST}")
_cprint(f" State: {state}")
_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 owned:
_cprint(f" {_DIM}Enable with /wake on{_RST}")
def _toggle_voice_tts(self):
"""Toggle TTS output for voice mode."""
if not self._voice_mode:
@@ -15677,7 +15884,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
# Start processing thread
process_thread = threading.Thread(target=process_loop, daemon=True)
process_thread.start()
# Wake word ("Hey Hermes") — start the always-on hotword listener if
# enabled. Off-thread so a first-run engine install never blocks the
# prompt; best-effort, so deps/mic/key gaps are surfaced, never fatal.
def _wake_startup():
try:
self._maybe_start_wake_word()
except Exception as e:
logger.debug("wake-word startup skipped: %s", e)
threading.Thread(target=_wake_startup, daemon=True, name="wake-startup").start()
# Register atexit cleanup so resources are freed even on unexpected exit
atexit.register(_run_cleanup)
+1
View File
@@ -0,0 +1 @@
omid3098
+23
View File
@@ -2820,3 +2820,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]")
+3
View File
@@ -168,6 +168,9 @@ COMMAND_REGISTRY: list[CommandDef] = [
subcommands=("kaomoji", "emoji", "unicode", "ascii")),
CommandDef("voice", "Toggle voice mode", "Configuration",
args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")),
CommandDef("wake", "Toggle the 'Hey Hermes' wake word listener", "Configuration",
cli_only=True, args_hint="[on|off|status]",
subcommands=("on", "off", "status")),
CommandDef("busy", "Control what Enter does while Hermes is working", "Configuration",
cli_only=True, args_hint="[queue|steer|interrupt|status]",
subcommands=("queue", "steer", "interrupt", "status")),
+32
View File
@@ -2297,6 +2297,31 @@ DEFAULT_CONFIG = {
"silence_duration": 3.0, # Seconds of silence before auto-stop
"barge_in": True, # Stop TTS playback when the user starts talking
},
# "Hey Hermes" hands-free wake word. 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", # eligible surface: "auto" (first claimant) | "cli" | "tui" | "gui"
"provider": "openwakeword", # "openwakeword" (free, local) | "porcupine" (premium; needs PORCUPINE_ACCESS_KEY)
"phrase": "hey hermes", # cosmetic label only; detection is keyed by the engine model/keyword below
"sensitivity": 0.5, # 0.0-1.0 detection threshold (higher = stricter)
"start_new_session": True, # start a fresh session on wake vs. continue the current one
"openwakeword": {
# "hey_hermes" (the bundled, works-out-of-the-box default) OR a
# built-in openWakeWord name ("hey_jarvis", "alexa", "hey_mycroft",
# ...) OR a path to a custom .onnx/.tflite model for another phrase.
# See the wake-word docs for the custom-model training guide.
"model": "hey_hermes",
"inference_framework": "onnx", # "onnx" | "tflite"
},
"porcupine": {
# Built-in keyword ("jarvis", "computer", "bumblebee", ...) or a path
# to a custom .ppn from the Picovoice Console.
"keyword": "jarvis",
},
},
"human_delay": {
"mode": "off",
@@ -4203,6 +4228,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",
+9 -4
View File
@@ -4332,10 +4332,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,
@@ -0,0 +1,142 @@
---
name: wake-word-training
description: "Train a custom on-device wake word from synthetic speech."
version: 1.0.0
author: Brooklyn <brooklyn> & Hermes Agent
license: MIT
platforms: [linux, macos]
metadata:
hermes:
tags: [Voice, Wake Word, openWakeWord, On-Device, Training]
category: productivity
related_skills: []
---
# Wake Word Training Skill
Trains a custom openWakeWord model so Hermes answers to a phrase other than the
bundled "hey hermes" — e.g. "hey morgane" or "hey <profile>". Detection stays
100% on-device; only the optional voice synthesis (OpenAI TTS) touches the
network, and only during training. This skill drives openWakeWord's own
training pipeline through the `terminal` tool — it does not reimplement it.
Custom models are trained on **synthetic speech**: you never record yourself.
A TTS engine speaks the phrase a few thousand times across many voices, the
clips are augmented with noise/room impulses, and a small classifier is trained
on top of openWakeWord's frozen feature extractor. The output is a sub-100 KB
`.onnx` the existing wake runtime loads with zero code changes.
## When to Use
- The user wants Hermes to wake on a different phrase ("hey morgane", a
nickname, "hey <profile>").
- The user asks to "make/train my own wake word".
Do **not** use this for the default "hey hermes" — that model already ships.
For a phrase you already have an `.onnx` for, skip training and jump to
**Wire It Up**.
## Prerequisites
- **Time & hardware.** Fast on Linux/GPU (~1 hr) or a free Colab GPU. On macOS
it is single-threaded and slow (~3–5 hr) — see `references/platforms.md`.
Prefer Colab if the user just wants the file quickly.
- **Disk.** Several GB for augmentation/negative datasets (room impulses,
AudioSet/FMA noise, LibriSpeech negatives). Use a scratch dir, not the repo.
- **Voices.** `OPENAI_API_KEY` for high-quality OpenAI TTS positives (~$0.04 a
run); otherwise the offline Piper generator openWakeWord ships with.
- **A scratch training venv**, created ad hoc via `terminal` — never install the
heavy training stack into Hermes's own environment.
## How to Run
Confirm the phrase and where the model belongs, then train, then wire it up.
Resolve the profile-aware model directory once and reuse it:
```bash
python -c "from hermes_constants import get_hermes_home; print(get_hermes_home() / 'wakewords')"
```
Default the phrase to `hey <profile>` (the active profile's name; `hey hermes`
is the default profile) unless the user gives one. The model file is
`<phrase-slug>.onnx` (lowercase, spaces → underscores), e.g. `hey_morgane.onnx`.
## Quick Reference
| Step | Command / action |
| --- | --- |
| Model dir | `get_hermes_home() / 'wakewords'` (profile-aware) |
| Positives (OpenAI) | `scripts/generate_positives.py --phrase "hey morgane" --out-dir <dir>` |
| Training config | `scripts/make_training_config.py --phrase "hey morgane" --out <cfg.yml>` |
| Train | openWakeWord's `train.py --training_config <cfg.yml>` (in the scratch venv) |
| Wire up | set `wake_word.openwakeword.model` + `phrase`, then restart the listener |
## Procedure
1. **Agree on the phrase and paths.** Slugify the phrase for the filename.
Resolve the model dir with the one-liner above and `mkdir -p` it.
2. **Make a scratch training dir + venv** somewhere with room (not the repo):
```bash
mkdir -p /tmp/oww-train && cd /tmp/oww-train
python -m venv .venv && . .venv/bin/activate
pip install openwakeword
```
3. **Generate positives.**
- *OpenAI TTS (default when `OPENAI_API_KEY` is set):* run
`generate_positives.py` — it speaks the phrase across many voices with
small text/pacing variations and writes 16 kHz mono WAVs.
- *Offline:* let openWakeWord's own Piper generator produce them (the
training config's `target_phrase` drives it). See `references/platforms.md`.
4. **Fetch augmentation + negative data.** Follow openWakeWord's automatic
training notebook (room impulses, AudioSet/FMA noise, LibriSpeech/precomputed
negatives). `references/platforms.md` has the exact dataset sources and the
Colab shortcut that downloads them for you.
5. **Write the training config** with `make_training_config.py` (point it at the
positives/negatives/output dirs), then run openWakeWord's `train.py` against
it. Cross-check the emitted YAML against openWakeWord's `custom_model.yml` for
your installed version.
6. **Place the model.** Copy the exported `<slug>.onnx` into the resolved
`wakewords` dir.
7. **Wire It Up.** In the active profile's `~/.hermes/config.yaml`, set:
- `wake_word.openwakeword.model` → the absolute `.onnx` path
- `wake_word.phrase` → the human label ("hey morgane")
- `wake_word.enabled` → `true`
Then restart the listener so it reloads: `/wake` off then on, or restart the
app/gateway. (Wake config is not part of the prompt, so no cache concern.)
## Pitfalls
- **Don't install the training stack into Hermes's venv.** It's large and
macOS-flaky; keep it in the throwaway scratch venv.
- **openWakeWord needs its base feature models** (melspectrogram + embedding)
for any model — the trainer downloads them; don't delete them.
- **macOS bus errors** from openWakeWord's threadpool + mmap: train
single-threaded, or use Colab. See `references/platforms.md`.
- **Config `model` must be an absolute path** (or a bundled/built-in name). A
bare filename won't resolve.
- **Sensitivity.** If the new phrase over/under-triggers, tune
`wake_word.sensitivity` (0–1, higher = stricter) before retraining.
## Verification
- The `.onnx` exists in the profile's `wakewords` dir and is > 50 KB.
- Smoke-test detection before enabling it live:
```bash
python - <<'PY'
import wave, numpy as np
from openwakeword.model import Model
m = Model(wakeword_models=["/abs/path/hey_morgane.onnx"], inference_framework="onnx")
a = np.frombuffer(wave.open("a_clip_of_the_phrase.wav").readframes(1<<20), np.int16)
print("best:", max(max(m.predict(a[i:i+1280]).values()) for i in range(0, len(a)-1280, 1280)))
PY
```
A clear utterance should score well above `wake_word.sensitivity`.
- After wiring up, `/wake` on and say the phrase — Hermes opens a fresh session.
@@ -0,0 +1,59 @@
# Platforms, Datasets & the Fast Path
openWakeWord's automatic trainer is heavy (torch + a synthetic-speech +
augmentation stack) and its speed depends entirely on where it runs. Pick the
environment first, then follow the data steps.
## Where to train
| Environment | Time | Notes |
| --- | --- | --- |
| **Colab (free GPU)** | ~1 hr | Easiest. Recommended when the user just wants the `.onnx` quickly. |
| Linux + CUDA GPU | ~1 hr | Full local control. |
| Linux CPU | ~2 hr | Fine, just slower. |
| **macOS** | ~3–5 hr | Single-threaded only — see the bus-error note below. |
### Colab shortcut (recommended)
openWakeWord ships an automatic-training notebook that installs the stack,
downloads every dataset below, generates positives, trains, and exports the
model — in one run on a free GPU:
<https://colab.research.google.com/drive/1q1oe2zOyZp7UsB3jJiQ1IFn8z5YfjwEb>
Set the target phrase (and, if using the OpenAI-TTS positives from this skill,
upload the generated WAVs and point the config's positives dir at them), run it,
download the `.onnx`, then follow **Wire It Up** in `SKILL.md`.
### macOS caveat
openWakeWord's data pipeline uses a threadpool over memory-mapped files that
triggers bus errors on macOS. Train **single-threaded** (set the trainer's
worker/`n_jobs` to 1) or, better, use Colab. Expect 3–5 hours locally.
## Datasets the trainer needs
Four kinds of data (openWakeWord's notebook pulls them from HuggingFace):
1. **Room impulse responses** — `davidscripka/MIT_environmental_impulse_responses`
(reverb augmentation). → `./mit_rirs`
2. **Background noise** — a slice of `agkphysics/AudioSet` (16 kHz). → `./audioset_16k`
3. **False-positive / music negatives** — `rudraml/fma` (start with ~1 hr).
→ `./fma`
4. **Precomputed openWakeWord negative features** — from the openWakeWord
releases; used as generic negatives + validation for early stopping.
Base feature models (`melspectrogram`, `embedding`) are fetched automatically by
openWakeWord — don't delete them. Budget several GB of scratch space; keep a
local copy to reuse across runs.
## Positives: OpenAI TTS vs Piper
- **OpenAI TTS** (`generate_positives.py`, default when `OPENAI_API_KEY` is set):
10 voices, small text/pacing variations, ~$0.04 per run, higher quality. Point
the training config's positives dir at the generated WAVs.
- **Piper** (offline): openWakeWord's built-in generator synthesizes positives
from the config's `target_phrase`. Fully local, no key. Note: Piper's sample
generator is best-supported on Linux.
More positives → better accuracy, with smooth diminishing returns. A few
thousand is the practical floor; the notebook defaults to 5,000.
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Generate synthetic positive samples of a wake phrase for openWakeWord.
OpenAI TTS (``gpt-4o-mini-tts``) speaks the phrase across many voices with small
text/pacing variations, resampled to the 16 kHz mono WAV openWakeWord trains on.
This is the higher-quality alternative to openWakeWord's built-in Piper
generator; use it when ``OPENAI_API_KEY`` is set.
Heavy deps (openai, soundfile, numpy, scipy) are imported lazily inside the
synthesis path so the pure planning helpers stay import-light and unit-testable.
"""
from __future__ import annotations
import argparse
import re
# OpenAI's standard TTS voice set — cycling these gives speaker diversity, which
# is what drives custom wake-word robustness (openWakeWord's own models lean on
# exactly this kind of multi-voice synthetic data).
OPENAI_VOICES = (
"alloy", "ash", "ballad", "coral", "echo",
"fable", "onyx", "nova", "sage", "shimmer",
)
TARGET_SAMPLE_RATE = 16_000
def slugify(phrase: str) -> str:
"""`"Hey Morgane!"` → `"hey_morgane"` — the model filename stem."""
s = re.sub(r"[^a-z0-9]+", "_", phrase.strip().lower())
return s.strip("_") or "wake_word"
def build_variations(phrase: str) -> list[str]:
"""Small textual variations so the TTS doesn't render one frozen prosody."""
core = phrase.strip().rstrip(".!?,")
# Punctuation nudges cadence/intonation; duplicates are dropped by dict order.
seen = {core: None, f"{core}.": None, f"{core}!": None, f"{core}...": None}
return list(seen)
def voice_list(engine: str = "openai") -> list[str]:
if engine == "openai":
return list(OPENAI_VOICES)
raise ValueError(f"unknown engine: {engine!r}")
def plan_clips(phrase: str, count: int, voices: list[str]) -> list[tuple[str, str, str]]:
"""Deterministic (voice, text, filename) plan for *count* clips.
Voices and text variations are cycled independently so the set stays evenly
balanced across speakers regardless of *count*. Filenames are zero-padded and
unique, ready to drop into openWakeWord's positive-samples directory.
"""
if count < 1:
return []
if not voices:
raise ValueError("need at least one voice")
variations = build_variations(phrase)
stem = slugify(phrase)
width = max(4, len(str(count - 1)))
plan = []
for i in range(count):
voice = voices[i % len(voices)]
text = variations[i % len(variations)]
plan.append((voice, text, f"{stem}_{i:0{width}d}.wav"))
return plan
def _resample_to_16k_mono(pcm, src_rate: int):
"""int16 mono PCM at *src_rate* → int16 mono PCM at 16 kHz (lazy scipy)."""
import numpy as np
from scipy.signal import resample
if src_rate == TARGET_SAMPLE_RATE:
return pcm
n = round(len(pcm) * TARGET_SAMPLE_RATE / src_rate)
out = resample(pcm.astype("float32"), n)
return np.clip(out, -32768, 32767).astype("int16")
def _synthesize(plan, out_dir, api_key): # pragma: no cover - network/heavy deps
"""Call OpenAI TTS for each planned clip and write a 16 kHz mono WAV."""
import io
import wave
from pathlib import Path
import numpy as np
import soundfile as sf
from openai import OpenAI
client = OpenAI(api_key=api_key)
out = Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
for voice, text, name in plan:
resp = client.audio.speech.create(
model="gpt-4o-mini-tts", voice=voice, input=text, response_format="wav"
)
data, rate = sf.read(io.BytesIO(resp.read()), dtype="int16")
if getattr(data, "ndim", 1) == 2: # stereo → mono
data = data.mean(axis=1).astype("int16")
data = _resample_to_16k_mono(np.asarray(data), rate)
with wave.open(str(out / name), "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(TARGET_SAMPLE_RATE)
w.writeframes(np.asarray(data).tobytes())
return len(plan)
def main(argv=None) -> int:
import os
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--phrase", required=True, help='e.g. "hey morgane"')
ap.add_argument("--out-dir", required=True, help="positive-samples directory")
ap.add_argument("--count", type=int, default=500, help="clips to generate")
ap.add_argument("--engine", default="openai", choices=["openai"])
args = ap.parse_args(argv)
api_key = (os.getenv("OPENAI_API_KEY") or "").strip()
if not api_key:
ap.error(
"OPENAI_API_KEY not set. Set it, or use openWakeWord's offline Piper "
"generator instead (see the skill's references/platforms.md)."
)
plan = plan_clips(args.phrase, args.count, voice_list(args.engine))
written = _synthesize(plan, args.out_dir, api_key)
print(f"wrote {written} positive samples to {args.out_dir}")
return 0
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Emit an openWakeWord automatic-training YAML for a custom phrase.
Writes the config openWakeWord's ``train.py`` consumes, pre-filled with the
target phrase, model name, and the positives/negatives/output paths. It covers
the common fields; always cross-check against openWakeWord's ``custom_model.yml``
for your installed version, since the schema evolves.
"""
from __future__ import annotations
import argparse
import re
def slugify(phrase: str) -> str:
s = re.sub(r"[^a-z0-9]+", "_", phrase.strip().lower())
return s.strip("_") or "wake_word"
def build_config(
phrase: str,
output_dir: str,
*,
positives_dir: str | None = None,
background_dir: str = "./audioset_16k",
rir_dir: str = "./mit_rirs",
false_positive_dir: str = "./fma",
n_samples: int = 5_000,
n_samples_val: int = 1_000,
steps: int = 10_000,
) -> dict:
"""Build the openWakeWord training-config dict.
When *positives_dir* is given (OpenAI-TTS clips already generated), the
trainer uses those instead of synthesizing its own with Piper.
"""
model_name = slugify(phrase)
cfg: dict = {
"target_phrase": [phrase.strip()],
"model_name": model_name,
"output_dir": output_dir,
"n_samples": n_samples,
"n_samples_val": n_samples_val,
"steps": steps,
"target_accuracy": 0.7,
"target_recall": 0.5,
# Augmentation + negative sources (see references/platforms.md).
"rir_paths": [rir_dir],
"background_paths": [background_dir],
"false_positive_validation_data_path": false_positive_dir,
"augmentation_rounds": 1,
"layer_size": 32,
}
if positives_dir:
# Reuse pre-generated positive clips rather than Piper-synthesizing them.
cfg["custom_positive_samples_dir"] = positives_dir
return cfg
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--phrase", required=True, help='e.g. "hey morgane"')
ap.add_argument("--out", required=True, help="path to write the YAML config")
ap.add_argument("--output-dir", default="./oww_out", help="trainer output dir")
ap.add_argument("--positives-dir", default=None, help="pre-generated positives")
ap.add_argument("--steps", type=int, default=10_000)
args = ap.parse_args(argv)
import yaml
cfg = build_config(
args.phrase,
args.output_dir,
positives_dir=args.positives_dir,
steps=args.steps,
)
with open(args.out, "w", encoding="utf-8") as f:
yaml.safe_dump(cfg, f, sort_keys=False)
print(f"wrote training config for {cfg['model_name']!r} to {args.out}")
return 0
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())
+10
View File
@@ -175,6 +175,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",
]
honcho = ["honcho-ai==2.2.0"]
# Cloud memory providers — opt-in, lazy-installed via tools/lazy_deps.py
# (memory.supermemory / memory.mem0) at first use. Exact pins MUST match the
@@ -0,0 +1,129 @@
"""Tests for the wake-word-training optional skill.
Pure-logic + frontmatter contract only — no network, no heavy deps. The scripts'
synthesis paths (openai/soundfile/scipy) are import-lazy, so importing the
helpers here needs nothing beyond stdlib.
"""
import importlib.util
import re
from pathlib import Path
import pytest
import yaml
REPO_ROOT = Path(__file__).resolve().parents[2]
SKILL_DIR = REPO_ROOT / "optional-skills" / "productivity" / "wake-word-training"
def _load(module_name: str):
path = SKILL_DIR / "scripts" / f"{module_name}.py"
spec = importlib.util.spec_from_file_location(f"_wwt_{module_name}", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@pytest.fixture(scope="module")
def positives():
return _load("generate_positives")
@pytest.fixture(scope="module")
def cfg_mod():
return _load("make_training_config")
# ── Frontmatter contract ─────────────────────────────────────────────────
def _frontmatter() -> dict:
text = (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")
m = re.match(r"^---\n(.*?)\n---\n", text, re.DOTALL)
assert m, "SKILL.md must open with a YAML frontmatter block"
return yaml.safe_load(m.group(1))
def test_description_is_short_one_sentence():
desc = _frontmatter()["description"]
assert len(desc) <= 60, f"description is {len(desc)} chars (max 60)"
assert desc.endswith("."), "description must end with a period"
def test_platforms_declared():
# POSIX-ish training tooling — must declare supported platforms.
assert _frontmatter().get("platforms"), "platforms gating is required"
def test_author_credits_a_human_first():
author = _frontmatter()["author"]
assert not author.lower().startswith("hermes"), "credit the human first"
# ── generate_positives pure helpers ──────────────────────────────────────
@pytest.mark.parametrize(
"phrase,expected",
[("Hey Morgane!", "hey_morgane"), (" Hey Hermes ", "hey_hermes"), ("!!!", "wake_word")],
)
def test_slugify(positives, phrase, expected):
assert positives.slugify(phrase) == expected
def test_variations_are_unique_and_nonempty(positives):
v = positives.build_variations("hey morgane")
assert v and len(v) == len(set(v))
assert all(x.lower().startswith("hey morgane") for x in v)
def test_plan_balances_voices_and_is_unique(positives):
voices = positives.voice_list("openai")
plan = positives.plan_clips("hey morgane", 25, voices)
assert len(plan) == 25
filenames = [name for _, _, name in plan]
assert len(set(filenames)) == 25, "filenames must be unique"
# Voices cycle evenly: with 25 clips over 10 voices, counts differ by <= 1.
from collections import Counter
counts = Counter(v for v, _, _ in plan)
assert max(counts.values()) - min(counts.values()) <= 1
def test_plan_empty_for_nonpositive_count(positives):
assert positives.plan_clips("hey morgane", 0, ["alloy"]) == []
def test_plan_requires_a_voice(positives):
with pytest.raises(ValueError):
positives.plan_clips("hey morgane", 5, [])
def test_unknown_engine_rejected(positives):
with pytest.raises(ValueError):
positives.voice_list("piper")
# ── make_training_config pure helper ─────────────────────────────────────
def test_config_sets_phrase_and_derived_name(cfg_mod):
cfg = cfg_mod.build_config("Hey Morgane", "/tmp/out")
assert cfg["target_phrase"] == ["Hey Morgane"]
assert cfg["model_name"] == "hey_morgane"
assert cfg["output_dir"] == "/tmp/out"
assert isinstance(cfg["n_samples"], int) and cfg["n_samples"] > 0
def test_config_uses_custom_positives_only_when_given(cfg_mod):
without = cfg_mod.build_config("hey morgane", "/tmp/out")
assert "custom_positive_samples_dir" not in without
with_pos = cfg_mod.build_config("hey morgane", "/tmp/out", positives_dir="/tmp/pos")
assert with_pos["custom_positive_samples_dir"] == "/tmp/pos"
def test_config_is_yaml_serializable(cfg_mod):
cfg = cfg_mod.build_config("hey morgane", "/tmp/out", positives_dir="/tmp/pos")
round_tripped = yaml.safe_load(yaml.safe_dump(cfg))
assert round_tripped == cfg
+168
View File
@@ -1124,6 +1124,7 @@ def test_voice_record_start_handles_non_dict_voice_cfg(monkeypatch):
assert captured["silence_duration"] == 3.0
assert captured["auto_restart"] is False
# Round-12 Copilot review regression on #19835: ``bool`` is a subclass
# of ``int``, so the naive ``isinstance(threshold, (int, float))``
# guard would forward ``silence_threshold: true`` as ``1`` instead
@@ -1154,6 +1155,173 @@ def test_voice_record_start_handles_non_dict_voice_cfg(monkeypatch):
assert captured["auto_restart"] is False
def test_wake_owner_is_sticky_and_routes_detection_to_first_transport(monkeypatch):
from tools import wake_word
state = {"owner": None, "callback": None, "paused": False}
voice_callbacks = {}
def start_listening(callback, *, owner, config):
if state["owner"] is not None and state["owner"] is not owner:
raise wake_word.WakeWordInUse
state.update(owner=owner, callback=callback, paused=False)
def pause_listening(*, owner):
if state["owner"] is not owner:
return False
state["paused"] = True
return True
def stop_listening(*, owner):
if state["owner"] is not owner:
return False
state.update(owner=None, callback=None, paused=False)
return True
def resume_listening(*, owner):
if state["owner"] is not owner:
return False
state["paused"] = False
return True
def start_continuous(**callbacks):
voice_callbacks.update(callbacks)
return True
monkeypatch.setattr(wake_word, "load_wake_word_config", lambda: {
"enabled": True,
"phrase": "hey hermes",
"surface": "auto",
"start_new_session": True,
})
monkeypatch.setattr(wake_word, "check_wake_word_requirements", lambda _cfg: {
"available": True,
"phrase": "hey hermes",
"provider": "test",
"hint": "",
})
monkeypatch.setattr(wake_word, "start_listening", start_listening)
monkeypatch.setattr(wake_word, "pause_listening", pause_listening)
monkeypatch.setattr(wake_word, "stop_listening", stop_listening)
monkeypatch.setattr(wake_word, "owns_listener", lambda owner: state["owner"] is owner)
monkeypatch.setattr(
wake_word,
"is_listening",
lambda: state["owner"] is not None and not state["paused"],
)
monkeypatch.setattr(
wake_word,
"resume_listening",
resume_listening,
)
monkeypatch.setitem(
sys.modules,
"hermes_cli.voice",
types.SimpleNamespace(
start_continuous=start_continuous,
stop_continuous=lambda **_kwargs: None,
),
)
monkeypatch.setenv("HERMES_VOICE", "1")
first = types.SimpleNamespace(_closed=False)
second = types.SimpleNamespace(_closed=False)
emitted = []
monkeypatch.setattr(
server,
"_emit",
lambda event, sid, payload: emitted.append(
(event, sid, payload, server.current_transport())
),
)
server._wake_owner_transport = None
server._wake_owner_surface = ""
try:
started = server.dispatch({
"id": "wake-1",
"method": "wake.start",
"params": {"surface": "gui", "session_id": "first-session"},
}, transport=first)
denied = server.dispatch({
"id": "wake-2",
"method": "wake.start",
"params": {"surface": "tui", "session_id": "second-session"},
}, transport=second)
denied_stop = server.dispatch({
"id": "wake-stop-2",
"method": "wake.stop",
"params": {},
}, transport=second)
denied_voice_stop = server.dispatch({
"id": "voice-stop-2",
"method": "voice.record",
"params": {"action": "stop"},
}, transport=second)
assert started["result"]["started"] is True
assert denied["result"] == {
"started": False,
"reason": "owned",
"owner_surface": "gui",
}
assert denied_stop["result"] == {"stopped": False, "reason": "not_owner"}
assert denied_voice_stop["result"] == {
"status": "busy",
"reason": "wake_owned",
}
state["callback"]()
assert emitted == [(
"wake.detected",
"first-session",
{"phrase": "hey hermes", "start_new_session": True},
first,
)]
assert state["paused"] is True
voice_started = server.dispatch({
"id": "voice-start-1",
"method": "voice.record",
"params": {"action": "start", "session_id": "first-session"},
}, transport=first)
assert voice_started["result"]["status"] == "recording"
voice_callbacks["on_status"]("idle")
assert state["paused"] is False
stopped = server.dispatch({
"id": "wake-stop-1",
"method": "wake.stop",
"params": {},
}, transport=first)
assert stopped["result"] == {"stopped": True, "reason": None}
reclaimed = server.dispatch({
"id": "wake-reclaim-2",
"method": "wake.start",
"params": {"surface": "tui", "session_id": "second-session"},
}, transport=second)
assert reclaimed["result"]["started"] is True
assert state["owner"] is second
state["callback"]()
assert emitted[-1] == (
"wake.detected",
"second-session",
{"phrase": "hey hermes", "start_new_session": True},
second,
)
stopped_again = server.dispatch({
"id": "wake-stop-2-after-reclaim",
"method": "wake.stop",
"params": {},
}, transport=second)
assert stopped_again["result"] == {"stopped": True, "reason": None}
finally:
server._wake_owner_transport = None
server._wake_owner_surface = ""
def test_voice_record_stop_forces_transcription(monkeypatch):
captured: dict = {}
+14
View File
@@ -151,6 +151,20 @@ def test_ws_connection_registers_then_disconnect_unregisters_live_transport(monk
server._live_transports.clear()
def test_ws_disconnect_releases_wake_word_owner(monkeypatch):
released = []
created = []
monkeypatch.setattr(
server,
"_release_wake_for_transport",
lambda transport: released.append(transport) or True,
)
_run_disconnect(monkeypatch, lambda transport: created.append(transport))
assert released == created
def test_ws_write_loop_stall_does_not_latch_transport(monkeypatch):
"""A write that times out because the event loop is stalled (GIL-heavy
agent turn) must NOT latch the transport closed — the frame is already
+456
View File
@@ -0,0 +1,456 @@
"""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 multiprocessing
import os
import sys
import threading
import time
import types
from pathlib import Path
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 hermes"
def test_wake_surface_enabled_gate():
# Disabled → never, regardless of surface.
assert ww.wake_surface_enabled("cli", {"enabled": False, "surface": "cli"}) is False
# auto → every surface is eligible; ownership still admits only one.
for s in ("cli", "tui", "gui"):
assert ww.wake_surface_enabled(s, {"enabled": True, "surface": "auto"}) is True
# Pinned surface → only that one.
cfg = {"enabled": True, "surface": "tui"}
assert ww.wake_surface_enabled("tui", cfg) is True
assert ww.wake_surface_enabled("cli", cfg) is False
assert ww.wake_surface_enabled("gui", cfg) is False
# Missing/blank surface defaults to auto.
assert ww.wake_surface_enabled("gui", {"enabled": True}) is True
def test_looks_like_path():
assert ww._looks_like_path("models/hey_hermes.onnx")
assert ww._looks_like_path("custom.ppn")
assert not ww._looks_like_path("hey_jarvis")
def test_load_wake_word_config_is_a_dict_with_defaults():
# Wired into DEFAULT_CONFIG, so a real load returns the section shape.
cfg = ww.load_wake_word_config()
assert isinstance(cfg, dict)
assert cfg.get("enabled") is False
assert cfg.get("provider") == "openwakeword"
def test_load_wake_word_config_guards_non_dict(monkeypatch):
monkeypatch.setattr(
"hermes_cli.config.load_config", lambda: {"wake_word": "oops"}
)
assert ww.load_wake_word_config() == {}
# ── Engine dispatch ──────────────────────────────────────────────────────
def test_build_engine_dispatch(monkeypatch):
monkeypatch.setattr(ww, "_OpenWakeWordEngine", lambda cfg: "oww")
monkeypatch.setattr(ww, "_PorcupineEngine", lambda cfg: "pv")
assert ww._build_engine({"provider": "openwakeword"}) == "oww"
assert ww._build_engine({"provider": "porcupine"}) == "pv"
with pytest.raises(ValueError):
ww._build_engine({"provider": "bogus"})
# ── Requirements probe ───────────────────────────────────────────────────
def test_requirements_openwakeword_available(monkeypatch):
monkeypatch.setattr(ww, "_audio_available", lambda: True)
monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: True)
r = ww.check_wake_word_requirements(
{"provider": "openwakeword", "phrase": "hey hermes"}
)
assert r["available"] is True
assert r["provider"] == "openwakeword"
assert r["phrase"] == "hey hermes"
def test_requirements_porcupine_needs_access_key(monkeypatch):
monkeypatch.delenv("PORCUPINE_ACCESS_KEY", raising=False)
monkeypatch.setattr(ww, "_audio_available", lambda: True)
monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: True)
r = ww.check_wake_word_requirements({"provider": "porcupine"})
assert r["available"] is False
assert r["access_key_set"] is False
assert "PORCUPINE_ACCESS_KEY" in r["hint"]
def test_requirements_unavailable_without_audio(monkeypatch):
monkeypatch.setattr(ww, "_audio_available", lambda: False)
monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: True)
r = ww.check_wake_word_requirements({"provider": "openwakeword"})
assert r["available"] is False
assert r["audio_available"] is False
# ── openWakeWord engine (bundled model + base-model fetch) ───────────────
def _install_fake_openwakeword(monkeypatch):
"""Swap in a fake ``openwakeword`` so the engine builds with no network.
Returns a ``calls`` dict recording every ``download_models`` invocation.
"""
calls = {"download": []}
class _FakeModel:
def __init__(self, wakeword_models, inference_framework="onnx"):
self.wakeword_models = list(wakeword_models)
self.models = {"hey_hermes": object()}
def predict(self, frame):
return {"hey_hermes": 0.0}
def reset(self):
pass
oww = types.ModuleType("openwakeword")
oww.utils = types.SimpleNamespace(
download_models=lambda names=[]: calls["download"].append(list(names))
)
model_mod = types.ModuleType("openwakeword.model")
model_mod.Model = _FakeModel
monkeypatch.setitem(sys.modules, "openwakeword", oww)
monkeypatch.setitem(sys.modules, "openwakeword.model", model_mod)
monkeypatch.setattr("tools.lazy_deps.ensure", lambda *a, **k: None)
return calls
def test_openwakeword_ensures_base_models_for_custom_path(monkeypatch):
# Regression: a custom ``.onnx`` path used to skip download_models entirely,
# so a fresh install crashed at load time on a missing melspectrogram.onnx.
# The base feature models must be ensured for a custom path too.
calls = _install_fake_openwakeword(monkeypatch)
eng = ww._OpenWakeWordEngine(
{"provider": "openwakeword", "openwakeword": {"model": "/models/hey_hermes.onnx"}}
)
assert calls["download"] == [["/models/hey_hermes.onnx"]]
assert eng._labels == ["hey_hermes"]
def test_openwakeword_fetches_builtin_by_name(monkeypatch):
calls = _install_fake_openwakeword(monkeypatch)
ww._OpenWakeWordEngine({"provider": "openwakeword", "openwakeword": {"model": "hey_jarvis"}})
assert calls["download"] == [["hey_jarvis"]]
def test_bundled_hey_hermes_model_ships_on_disk():
# The "hey hermes" wake word works out of the box only if the model is
# actually bundled. Both framework artifacts must exist and be non-trivial.
for framework in ("onnx", "tflite"):
path = ww._bundled_wakeword_path(framework)
assert os.path.exists(path), path
assert os.path.getsize(path) > 1024, path
@pytest.mark.parametrize("model_value", [None, "", "hey_hermes", "hey hermes", "HEY_HERMES"])
def test_openwakeword_default_resolves_to_bundled_model(monkeypatch, model_value):
# The default (and any "hey_hermes" alias) must load the bundled file, not be
# passed through as a bogus built-in name that openWakeWord can't resolve.
calls = _install_fake_openwakeword(monkeypatch)
sub = {} if model_value is None else {"model": model_value}
ww._OpenWakeWordEngine({"provider": "openwakeword", "openwakeword": sub})
(downloaded,) = calls["download"]
assert downloaded == [ww._bundled_wakeword_path("onnx")]
def test_openwakeword_bundled_model_matches_framework(monkeypatch):
calls = _install_fake_openwakeword(monkeypatch)
ww._OpenWakeWordEngine(
{"provider": "openwakeword", "openwakeword": {"inference_framework": "tflite"}}
)
(downloaded,) = calls["download"]
assert downloaded == [ww._bundled_wakeword_path("tflite")]
assert downloaded[0].endswith(".tflite")
# ── Detector loop ────────────────────────────────────────────────────────
class _FakeStream:
"""Always-readable input stream that yields trivial frames."""
def __init__(self, **_kw):
self.closed = False
def start(self):
pass
def read(self, n):
time.sleep(0.01)
return [0] * n, False
def stop(self):
pass
def close(self):
self.closed = True
class _FakeEngine:
frame_length = 4
def __init__(self, fire=True):
self._fire = fire
self.closed = False
self.resets = 0
def process(self, frame):
return self._fire
def reset(self):
self.resets += 1
def close(self):
self.closed = True
def _fake_audio(monkeypatch):
fake_sd = types.SimpleNamespace(InputStream=lambda **kw: _FakeStream(**kw))
monkeypatch.setattr(ww, "_import_audio", lambda: (fake_sd, None))
def test_detector_fires_once_under_cooldown(monkeypatch):
_fake_audio(monkeypatch)
calls = []
eng = _FakeEngine(fire=True)
det = ww.WakeWordDetector(eng, lambda: calls.append(1), cooldown=10.0)
det.start()
time.sleep(0.25)
det.stop()
assert len(calls) == 1 # high cooldown suppresses repeats
assert eng.closed is True
assert det.running is False
def test_detector_refires_after_cooldown(monkeypatch):
_fake_audio(monkeypatch)
calls = []
det = ww.WakeWordDetector(_FakeEngine(fire=True), lambda: calls.append(1), cooldown=0.05)
det.start()
time.sleep(0.3)
det.stop()
assert len(calls) >= 2
def test_detector_no_fire_when_engine_quiet(monkeypatch):
_fake_audio(monkeypatch)
calls = []
det = ww.WakeWordDetector(_FakeEngine(fire=False), lambda: calls.append(1))
det.start()
time.sleep(0.15)
det.stop()
assert calls == []
def test_detector_resets_engine_on_each_start(monkeypatch):
# Clearing the engine buffer on (re)start is what stops a resume right after
# a voice turn from re-firing on stale audio (the runaway wake loop).
_fake_audio(monkeypatch)
eng = _FakeEngine(fire=False)
det = ww.WakeWordDetector(eng, lambda: None)
det.start()
time.sleep(0.05)
det.pause()
det.resume()
time.sleep(0.05)
det.stop()
assert eng.resets >= 2 # initial start + resume
def test_detector_pause_resume(monkeypatch):
_fake_audio(monkeypatch)
det = ww.WakeWordDetector(_FakeEngine(fire=False), lambda: None)
det.start()
time.sleep(0.05)
assert det.running is True
det.pause()
assert det.running is False
det.resume()
time.sleep(0.05)
assert det.running is True
det.stop()
assert det.running is False
# ── Singleton lifecycle ──────────────────────────────────────────────────
def test_singleton_lifecycle(monkeypatch, tmp_path):
_fake_audio(monkeypatch)
monkeypatch.setattr(ww, "_build_engine", lambda cfg: _FakeEngine(fire=False))
monkeypatch.setattr(ww, "_lock_path", lambda: tmp_path / "wake.lock")
owner = object()
assert ww.is_listening() is False
det = ww.start_listening(lambda: None, owner=owner, config={})
time.sleep(0.05)
assert ww.is_listening() is True
assert ww.owns_listener(owner) is True
# Re-entrant start returns the same detector and re-arms it.
det2 = ww.start_listening(lambda: None, owner=owner, config={})
assert det2 is det
assert ww.pause_listening(owner=owner) is True
assert ww.is_listening() is False
assert ww.resume_listening(owner=owner) is True
time.sleep(0.05)
assert ww.is_listening() is True
assert ww.stop_listening(owner=owner) is True
assert ww.is_listening() is False
def test_second_owner_cannot_mutate_listener(monkeypatch, tmp_path):
_fake_audio(monkeypatch)
monkeypatch.setattr(ww, "_build_engine", lambda cfg: _FakeEngine(fire=False))
monkeypatch.setattr(ww, "_lock_path", lambda: tmp_path / "wake.lock")
owner, intruder = object(), object()
first_callback = lambda: None
detector = ww.start_listening(first_callback, owner=owner, config={})
with pytest.raises(ww.WakeWordInUse):
ww.start_listening(lambda: None, owner=intruder, config={})
assert detector.on_wake is first_callback
assert ww.pause_listening(owner=intruder) is False
assert ww.resume_listening(owner=intruder) is False
assert ww.stop_listening(owner=intruder) is False
assert ww.owns_listener(owner) is True
assert ww.stop_listening(owner=owner) is True
def test_detection_callback_can_pause_and_close_stream(monkeypatch, tmp_path):
streams = []
def _stream(**kw):
stream = _FakeStream(**kw)
streams.append(stream)
return stream
fake_sd = types.SimpleNamespace(InputStream=_stream)
monkeypatch.setattr(ww, "_import_audio", lambda: (fake_sd, None))
monkeypatch.setattr(ww, "_build_engine", lambda cfg: _FakeEngine(fire=True))
monkeypatch.setattr(ww, "_lock_path", lambda: tmp_path / "wake.lock")
owner = object()
paused = threading.Event()
def _on_wake():
if ww.pause_listening(owner=owner):
paused.set()
ww.start_listening(_on_wake, owner=owner, config={})
assert paused.wait(2)
assert ww.is_listening() is False
assert streams[0].closed is True
assert ww.stop_listening(owner=owner) is True
def test_startup_failure_releases_owner_and_machine_lock(monkeypatch, tmp_path):
class _BrokenSoundDevice:
@staticmethod
def InputStream(**_kw):
raise OSError("no microphone")
lock_path = tmp_path / "wake.lock"
monkeypatch.setattr(ww, "_import_audio", lambda: (_BrokenSoundDevice, None))
monkeypatch.setattr(ww, "_build_engine", lambda cfg: _FakeEngine(fire=False))
monkeypatch.setattr(ww, "_lock_path", lambda: lock_path)
owner = object()
with pytest.raises(RuntimeError, match="Failed to open"):
ww.start_listening(lambda: None, owner=owner, config={})
assert ww.owns_listener(owner) is False
handle = ww._acquire_machine_lock(lock_path)
ww._release_machine_lock(handle)
def test_stream_failure_releases_owner_and_machine_lock(monkeypatch, tmp_path):
class _FailingStream(_FakeStream):
def read(self, _n):
raise OSError("device disconnected")
fake_sd = types.SimpleNamespace(InputStream=lambda **kw: _FailingStream(**kw))
engine = _FakeEngine(fire=False)
lock_path = tmp_path / "wake.lock"
monkeypatch.setattr(ww, "_import_audio", lambda: (fake_sd, None))
monkeypatch.setattr(ww, "_build_engine", lambda cfg: engine)
monkeypatch.setattr(ww, "_lock_path", lambda: lock_path)
owner = object()
ww.start_listening(lambda: None, owner=owner, config={})
deadline = time.time() + 2
while ww.owns_listener(owner) and time.time() < deadline:
time.sleep(0.01)
assert ww.owns_listener(owner) is False
assert engine.closed is True
handle = ww._acquire_machine_lock(lock_path)
ww._release_machine_lock(handle)
def _hold_machine_lock(path: str, ready, release) -> None:
from tools import wake_word
handle = wake_word._acquire_machine_lock(Path(path))
ready.set()
release.wait(10)
assert handle is not None
def test_machine_lock_is_released_when_owner_process_exits(tmp_path):
lock_path = tmp_path / "wake.lock"
ctx = multiprocessing.get_context("spawn")
ready = ctx.Event()
release = ctx.Event()
process = ctx.Process(
target=_hold_machine_lock,
args=(str(lock_path), ready, release),
)
process.start()
try:
assert ready.wait(10)
with pytest.raises(ww.WakeWordInUse):
ww._acquire_machine_lock(lock_path)
release.set()
process.join(10)
assert process.exitcode == 0
handle = ww._acquire_machine_lock(lock_path)
ww._release_machine_lock(handle)
finally:
release.set()
if process.is_alive():
process.terminate()
process.join(10)
+15
View File
@@ -137,6 +137,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",),
+632
View File
@@ -0,0 +1,632 @@
"""Wake-word ("Hey Hermes") detection — hands-free session trigger.
A lightweight, always-on hotword listener that fires a callback when a wake
phrase is spoken — the "Hey Siri" / "Alexa" pattern. Shared by the CLI, TUI, and
desktop GUI (one of them owns it, gated by ``wake_surface_enabled``): say the
wake word, Hermes opens a fresh session and captures voice via the existing
pipeline, then answers.
Two engines, both fully on-device (no audio leaves the machine for detection):
* **openwakeword** (default, free, no API key) — loads an ONNX model. Defaults
to the bundled "hey hermes" model (``tools/wakewords/``) so the wake word
works out of the box; or point ``wake_word.openwakeword.model`` at a built-in
name (``hey_jarvis``, ``alexa``, …) or a custom ``.onnx`` for another phrase.
* **porcupine** (premium) — Picovoice's engine. Needs ``PORCUPINE_ACCESS_KEY``;
supports built-in keywords and custom ``.ppn`` files from the Picovoice
Console.
Audio capture reuses the same 16 kHz mono int16 ``sounddevice`` path as voice
mode. The detector runs on its own daemon thread; callers ``pause()`` it while a
voice turn holds the microphone and ``resume()`` it once the system is idle
again (two input streams on one device is unreliable cross-platform).
Nothing here mutates agent context or the prompt cache — on wake we hand a plain
string to the caller, exactly like a voice transcript.
"""
from __future__ import annotations
import logging
import os
import threading
import time
from pathlib import Path
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
_START_TIMEOUT_SECONDS = 5.0
class WakeWordInUse(RuntimeError):
"""Raised when another surface or process owns the wake-word listener."""
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
_DEFAULTS: Dict[str, Any] = {
"enabled": False,
"surface": "auto",
"provider": "openwakeword",
"phrase": "hey hermes",
"sensitivity": 0.5,
"start_new_session": True,
}
# Bundled "hey hermes" model (tools/wakewords/) — the default, so the wake word
# works out of the box. Config names in _ALIASES resolve to it, not a built-in.
_BUNDLED_MODEL_NAME = "hey_hermes"
_BUNDLED_MODEL_ALIASES = frozenset({"", "hey_hermes", "hey hermes", "hermes"})
def _bundled_wakeword_path(framework: str = "onnx") -> str:
"""Path to the shipped hey_hermes model (.onnx/.tflite) for ``framework``."""
ext = "tflite" if str(framework).strip().lower() == "tflite" else "onnx"
return os.path.join(os.path.dirname(__file__), "wakewords", f"{_BUNDLED_MODEL_NAME}.{ext}")
def load_wake_word_config() -> Dict[str, Any]:
"""Return the ``wake_word`` config section, shape-guarded to a dict."""
try:
from hermes_cli.config import load_config
cfg = load_config().get("wake_word")
except Exception:
cfg = None
return cfg if isinstance(cfg, dict) else {}
def _get(cfg: Dict[str, Any], key: str) -> Any:
val = cfg.get(key, _DEFAULTS.get(key))
return _DEFAULTS.get(key) if val is None else val
def _provider(cfg: Dict[str, Any]) -> str:
return str(_get(cfg, "provider")).strip().lower() or "openwakeword"
def _sensitivity(cfg: Dict[str, Any]) -> float:
raw = _get(cfg, "sensitivity")
try:
s = float(raw)
except (TypeError, ValueError):
s = 0.5
return min(max(s, 0.0), 1.0)
def wake_phrase(cfg: Optional[Dict[str, Any]] = None) -> str:
"""Human-facing wake phrase label (purely cosmetic; engine keys detection)."""
cfg = cfg if cfg is not None else load_wake_word_config()
return str(_get(cfg, "phrase")) or "hey hermes"
def wake_surface_enabled(surface: str, cfg: Optional[Dict[str, Any]] = None) -> bool:
"""Should ``surface`` (``cli`` / ``tui`` / ``gui``) host the listener?
True when the wake word is enabled and the configured ``surface`` is either
``auto`` or this exact surface. ``auto`` makes a surface eligible; the
process/machine ownership lock still permits only the first claimant.
"""
cfg = cfg if cfg is not None else load_wake_word_config()
if not cfg.get("enabled"):
return False
want = str(_get(cfg, "surface")).strip().lower() or "auto"
return want == "auto" or want == surface.strip().lower()
# ---------------------------------------------------------------------------
# Audio capture (lazy — never import sounddevice at module load)
# ---------------------------------------------------------------------------
def _import_audio():
import numpy as np
import sounddevice as sd
return sd, np
def _audio_available() -> bool:
try:
_import_audio()
return True
except (ImportError, OSError):
return False
# ---------------------------------------------------------------------------
# Engines
# ---------------------------------------------------------------------------
class _Engine:
"""Minimal hotword-engine contract: feed int16 frames, get a bool."""
frame_length: int = 1280 # 80 ms at 16 kHz
def process(self, frame) -> bool: # frame: 1-D int16 ndarray
raise NotImplementedError
def reset(self) -> None:
"""Clear any internal audio/feature buffer (called on every (re)start)."""
pass
def close(self) -> None:
pass
def _looks_like_path(value: str) -> bool:
return (
os.sep in value
or value.endswith((".onnx", ".tflite", ".ppn"))
or os.path.exists(value)
)
class _OpenWakeWordEngine(_Engine):
"""openWakeWord — free, local ONNX hotword detection."""
# openWakeWord recommends 80 ms frames (1280 samples) for efficiency.
frame_length = 1280
def __init__(self, cfg: Dict[str, Any]):
from tools import lazy_deps
lazy_deps.ensure("wake.openwakeword", prompt=False)
import openwakeword
from openwakeword.model import Model
sub = cfg.get("openwakeword") if isinstance(cfg.get("openwakeword"), dict) else {}
model_ref = str(sub.get("model") or _BUNDLED_MODEL_NAME).strip()
framework = str(sub.get("inference_framework") or "onnx").strip().lower()
self._threshold = _sensitivity(cfg)
# Default (or explicit "hey_hermes") → the bundled model; a built-in name
# or custom path is used as-is.
if model_ref.lower() in _BUNDLED_MODEL_ALIASES:
model_ref = _bundled_wakeword_path(framework)
# openWakeWord needs its shared feature models (melspectrogram + embedding)
# for ANY model — download_models() fetches those first on every call, so a
# custom path must call it too, else a fresh install crashes on a missing
# melspectrogram.onnx. A built-in name additionally pulls that pretrained
# model; a path matches nothing in the catalog and is a no-op beyond base.
try:
openwakeword.utils.download_models([model_ref])
except Exception as e: # pragma: no cover - network/path dependent
logger.debug("openwakeword 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,
on_failure: Optional[Callable[["WakeWordDetector"], None]] = None):
self.engine = engine
self.on_wake = on_wake
self.cooldown = cooldown
self.on_failure = on_failure
self._thread: Optional[threading.Thread] = None
self._stop = threading.Event()
self._callback_inflight = 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()
ready = threading.Event()
startup_errors: list[BaseException] = []
self._thread = threading.Thread(
target=self._run,
args=(ready, startup_errors),
daemon=True,
name="wake-word",
)
self._thread.start()
if not ready.wait(_START_TIMEOUT_SECONDS):
self._halt_thread()
raise TimeoutError("Timed out while opening the wake-word microphone.")
if startup_errors:
self._halt_thread()
raise RuntimeError("Failed to open the wake-word microphone.") from startup_errors[0]
# 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:
self._stop.set()
t = self._thread
if t is not None and t is not threading.current_thread():
t.join(timeout=2.0)
if self._thread is t:
self._thread = None
def _dispatch_wake(self) -> None:
try:
self.on_wake()
except Exception as e:
logger.warning("wake word callback failed: %s", e)
finally:
self._callback_inflight.clear()
def _run(self, ready: threading.Event,
startup_errors: list[BaseException]) -> None:
try:
sd, _ = _import_audio()
except (ImportError, OSError) as e:
logger.error("wake word: audio libraries unavailable: %s", e)
startup_errors.append(e)
ready.set()
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)
startup_errors.append(e)
ready.set()
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)
ready.set()
failed = False
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)
failed = not self._stop.is_set()
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")
if not self._callback_inflight.is_set():
self._callback_inflight.set()
threading.Thread(
target=self._dispatch_wake,
daemon=True,
name="wake-word-callback",
).start()
else:
logger.debug("wake word: detection within cooldown — ignored")
finally:
try:
stream.stop()
stream.close()
except Exception:
pass
logger.info("wake word: stream closed")
if failed and self.on_failure is not None:
self.on_failure(self)
# ---------------------------------------------------------------------------
# Process-wide singleton (mirrors hermes_cli.voice's continuous API)
# ---------------------------------------------------------------------------
_detector: Optional[WakeWordDetector] = None
_detector_owner: object | None = None
_detector_file_lock = None
_detector_lock = threading.Lock()
def _lock_path() -> Path:
from hermes_constants import get_default_hermes_root
return get_default_hermes_root() / "runtime" / "wake-word.lock"
def _acquire_machine_lock(path: Optional[Path] = None):
"""Acquire the cross-process microphone lease, or raise WakeWordInUse."""
lock_path = path or _lock_path()
lock_path.parent.mkdir(parents=True, exist_ok=True)
handle = open(lock_path, "a+b")
try:
if os.name == "nt":
import msvcrt
handle.seek(0, os.SEEK_END)
if handle.tell() == 0:
handle.write(b"\0")
handle.flush()
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except (OSError, BlockingIOError) as e:
handle.close()
raise WakeWordInUse("Wake-word microphone is already owned.") from e
return handle
def _release_machine_lock(handle) -> None:
if handle is None:
return
try:
if os.name == "nt":
import msvcrt
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
except OSError:
pass
finally:
handle.close()
def _detector_failed(detector: WakeWordDetector) -> None:
"""Release ownership if the active microphone stream dies unexpectedly."""
global _detector, _detector_owner, _detector_file_lock
with _detector_lock:
if _detector is not detector:
return
lock_handle = _detector_file_lock
_detector = None
_detector_owner = None
_detector_file_lock = None
try:
detector.engine.close()
finally:
_release_machine_lock(lock_handle)
def start_listening(
on_wake: Callable[[], None],
*,
owner: object,
config: Optional[Dict[str, Any]] = None,
) -> WakeWordDetector:
"""Claim, build, and start the detector. Idempotent for the same owner.
Raises if engine construction fails (missing deps / access key / model);
callers should probe :func:`check_wake_word_requirements` first. A different
owner, including another process, receives :class:`WakeWordInUse`.
"""
if owner is None:
raise ValueError("wake-word owner must not be None")
global _detector, _detector_owner, _detector_file_lock
with _detector_lock:
if _detector is not None:
if _detector_owner is not owner:
raise WakeWordInUse("Wake-word microphone is already owned.")
_detector.on_wake = on_wake
_detector.resume()
return _detector
lock_handle = _acquire_machine_lock()
try:
cfg = config if config is not None else load_wake_word_config()
engine = _build_engine(cfg)
detector = WakeWordDetector(engine, on_wake, on_failure=_detector_failed)
_detector = detector
_detector_owner = owner
_detector_file_lock = lock_handle
detector.start()
return detector
except Exception:
if _detector is not None:
try:
_detector.stop()
except Exception:
pass
_detector = None
_detector_owner = None
_detector_file_lock = None
_release_machine_lock(lock_handle)
raise
def owns_listener(owner: object) -> bool:
with _detector_lock:
return _detector is not None and _detector_owner is owner
def pause_listening(*, owner: object) -> bool:
"""Release the microphone only when ``owner`` holds the lease."""
with _detector_lock:
if _detector is None or _detector_owner is not owner:
return False
_detector.pause()
return True
def resume_listening(*, owner: object) -> bool:
"""Re-open the microphone only when ``owner`` holds the lease."""
with _detector_lock:
if _detector is None or _detector_owner is not owner:
return False
_detector.resume()
return True
def stop_listening(*, owner: object) -> bool:
"""Fully stop the detector only when ``owner`` holds the lease."""
global _detector, _detector_owner, _detector_file_lock
with _detector_lock:
if _detector is None or _detector_owner is not owner:
return False
det = _detector
lock_handle = _detector_file_lock
_detector = None
_detector_owner = None
_detector_file_lock = None
try:
det.stop()
finally:
_release_machine_lock(lock_handle)
return True
def is_listening() -> bool:
with _detector_lock:
det = _detector
return det is not None and det.running
+20
View File
@@ -0,0 +1,20 @@
# Bundled wake-word models
`hey_hermes.onnx` / `hey_hermes.tflite` — the on-device "Hey Hermes" hotword
model. This is the default detector for the wake word feature (see
`website/docs/user-guide/features/wake-word.md`); no training or setup is
required to say "hey hermes".
- **Engine:** [openWakeWord](https://github.com/dscripka/openWakeWord) (Apache-2.0).
- **Provenance:** trained with the openWakeWord training pipeline (synthetic
TTS-generated speech), which produces both the `.onnx` and `.tflite` artifacts.
Redistribution is permitted under the openWakeWord license.
- **Label:** the model registers as `hey_hermes` (matches the filename).
- **Runtime:** openWakeWord's shared feature-extraction models (melspectrogram +
embedding) are NOT bundled here — they are fetched once on first use by
`tools/wake_word.py` via `openwakeword.utils.download_models()`.
To use a different phrase, train your own model and point
`wake_word.openwakeword.model` at its path, or set a built-in openWakeWord name
(`hey_jarvis`, `alexa`, `hey_mycroft`, …). See the wake-word docs for the
training guide.
Binary file not shown.
Binary file not shown.
+269 -6
View File
@@ -905,6 +905,10 @@ def _close_sessions_for_transport(
def _shutdown_sessions() -> None:
try:
_release_gateway_wake_owner()
except Exception:
pass
with _sessions_lock:
sids = list(_sessions)
for sid in sids:
@@ -15877,6 +15881,7 @@ def _(rid, params: dict) -> dict:
_voice_sid_lock = threading.Lock()
_voice_event_sid: str = ""
_voice_wake_owner: "Optional[Transport]" = None
def _voice_emit(event: str, payload: dict | None = None) -> None:
@@ -15890,6 +15895,14 @@ def _voice_emit(event: str, payload: dict | None = None) -> None:
_emit(event, sid, payload)
def _resume_voice_wake() -> None:
global _voice_wake_owner
with _voice_sid_lock:
owner, _voice_wake_owner = _voice_wake_owner, None
if owner is not None:
_wake_resume_if_owner(owner)
def _voice_mode_enabled() -> bool:
"""Current voice-mode flag (runtime-only, CLI parity).
@@ -16043,6 +16056,220 @@ 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. The first eligible
# transport to call wake.start owns it until stop, disconnect, or stream failure.
# 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_owner_transport: "Optional[Transport]" = None
_wake_owner_surface = ""
def _wake_owner_snapshot():
with _wake_lock:
return _wake_owner_transport, _wake_owner_surface
def _release_wake_for_transport(transport: "Transport") -> bool:
"""Release the wake lease iff ``transport`` is the current gateway owner."""
global _wake_owner_transport, _wake_owner_surface
with _wake_lock:
if _wake_owner_transport is not transport:
return False
_wake_owner_transport = None
_wake_owner_surface = ""
try:
from tools.wake_word import stop_listening
stop_listening(owner=transport)
except Exception as e:
logger.debug("wake stop failed: %s", e)
return True
def _release_gateway_wake_owner() -> bool:
owner, _surface = _wake_owner_snapshot()
return owner is not None and _release_wake_for_transport(owner)
def _wake_resume_if_owner(owner: "Transport") -> bool:
try:
from tools.wake_word import resume_listening
return resume_listening(owner=owner)
except Exception as e:
logger.debug("wake resume failed: %s", e)
return False
@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.
"""
surface = str(params.get("surface") or "auto").strip().lower()
transport = current_transport() or _stdio_transport
try:
from tools.wake_word import (
WakeWordInUse,
check_wake_word_requirements,
load_wake_word_config,
owns_listener,
start_listening,
wake_phrase,
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": "unavailable",
"hint": reqs.get("hint") or "",
})
existing_owner, existing_surface = _wake_owner_snapshot()
if existing_owner is not None and (
_transport_is_dead(existing_owner) or not owns_listener(existing_owner)
):
_release_wake_for_transport(existing_owner)
existing_owner = None
existing_surface = ""
if existing_owner is not None and existing_owner is not transport:
return _ok(rid, {
"started": False,
"reason": "owned",
"owner_surface": existing_surface,
})
sid = str(params.get("session_id") or "")
phrase = wake_phrase(cfg)
new_session = bool(cfg.get("start_new_session", True))
def _on_detect() -> None:
from tools.wake_word import owns_listener, pause_listening
if not pause_listening(owner=transport):
return
if not owns_listener(transport):
return
if _transport_is_dead(transport):
_release_wake_for_transport(transport)
return
logger.info("wake.detected: emitting to sid=%r (transport=%s)",
sid, type(transport).__name__)
token = bind_transport(transport)
try:
_emit("wake.detected", sid, {
"phrase": phrase,
"start_new_session": new_session,
})
finally:
reset_transport(token)
try:
start_listening(_on_detect, owner=transport, config=cfg)
except WakeWordInUse:
return _ok(rid, {
"started": False,
"reason": "owned",
"owner_surface": existing_surface or None,
})
except Exception as e:
logger.warning("wake.start(%s): failed to start listener: %s", surface, e)
return _err(rid, 5026, str(e))
global _wake_owner_transport, _wake_owner_surface
with _wake_lock:
_wake_owner_transport = transport
_wake_owner_surface = surface
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"],
"owner_surface": surface,
})
@method("wake.stop")
def _(rid, params: dict) -> dict:
transport = current_transport() or _stdio_transport
stopped = _release_wake_for_transport(transport)
return _ok(rid, {
"stopped": stopped,
"reason": None if stopped else "not_owner",
})
@method("wake.pause")
def _(rid, params: dict) -> dict:
"""Release the mic (e.g. while the desktop's browser captures audio)."""
transport = current_transport() or _stdio_transport
try:
from tools.wake_word import pause_listening
paused = pause_listening(owner=transport)
logger.info("wake.pause: detector paused=%s", paused)
except Exception as e:
logger.debug("wake.pause failed: %s", e)
paused = False
return _ok(rid, {
"paused": paused,
"reason": None if paused else "not_owner",
})
@method("wake.resume")
def _(rid, params: dict) -> dict:
"""Reclaim the mic after a pause; no-op if the listener isn't armed."""
transport = current_transport() or _stdio_transport
resumed = _wake_resume_if_owner(transport)
logger.info("wake.resume: detector resumed=%s", resumed)
return _ok(rid, {
"resumed": resumed,
"reason": None if resumed else "not_owner",
})
@method("wake.status")
def _(rid, params: dict) -> dict:
try:
from tools.wake_word import (
check_wake_word_requirements,
is_listening,
load_wake_word_config,
owns_listener,
)
cfg = load_wake_word_config()
reqs = check_wake_word_requirements(cfg)
transport = current_transport() or _stdio_transport
owner, owner_surface = _wake_owner_snapshot()
owned_by_caller = owns_listener(transport)
return _ok(rid, {
"listening": owned_by_caller and is_listening(),
"owned_by_caller": owned_by_caller,
"owner_surface": owner_surface if owner is not None else None,
"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.
@@ -16155,17 +16382,23 @@ def _(rid, params: dict) -> dict:
captures emit ``voice.transcript`` with ``no_speech_limit=True``.
"""
action = params.get("action", "start")
wake_paused = False
if action not in {"start", "stop"}:
return _err(rid, 4019, f"unknown voice action: {action}")
transport = current_transport() or _stdio_transport
wake_owner, _surface = _wake_owner_snapshot()
if wake_owner is not None and wake_owner is not transport:
return _ok(rid, {"status": "busy", "reason": "wake_owned"})
try:
if action == "start":
if not _voice_mode_enabled():
return _err(rid, 4015, "voice mode is off — enable with /voice on")
with _voice_sid_lock:
global _voice_event_sid
global _voice_event_sid, _voice_wake_owner
_voice_event_sid = params.get("session_id") or _voice_event_sid
from hermes_cli.voice import start_continuous
@@ -16191,17 +16424,42 @@ 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.
try:
from tools.wake_word import pause_listening
wake_paused = pause_listening(owner=transport)
except Exception:
wake_paused = False
if wake_paused:
with _voice_sid_lock:
_voice_wake_owner = transport
def _on_transcript(t):
_voice_emit("voice.transcript", {"text": t})
_resume_voice_wake()
def _on_silent():
_voice_emit("voice.transcript", {"no_speech_limit": True})
_resume_voice_wake()
def _on_status(state):
_voice_emit("voice.status", {"state": state})
if state == "idle":
_resume_voice_wake()
started = start_continuous(
on_transcript=lambda t: _voice_emit("voice.transcript", {"text": t}),
on_status=lambda s: _voice_emit("voice.status", {"state": s}),
on_silent_limit=lambda: _voice_emit(
"voice.transcript", {"no_speech_limit": True}
),
on_transcript=_on_transcript,
on_status=_on_status,
on_silent_limit=_on_silent,
silence_threshold=safe_threshold,
silence_duration=safe_duration,
auto_restart=False,
)
if started is False:
_resume_voice_wake()
return _ok(rid, {"status": "busy"})
return _ok(rid, {"status": "recording"})
@@ -16212,12 +16470,17 @@ def _(rid, params: dict) -> dict:
from hermes_cli.voice import stop_continuous
stop_continuous(force_transcribe=True)
_resume_voice_wake()
return _ok(rid, {"status": "stopped"})
except ImportError:
if wake_paused or action == "stop":
_resume_voice_wake()
return _err(
rid, 5025, "voice module not available — install audio dependencies"
)
except Exception as e:
if wake_paused or action == "stop":
_resume_voice_wake()
return _err(rid, 5025, str(e))
+5
View File
@@ -438,6 +438,11 @@ async def handle_ws(ws: Any) -> None:
server.unregister_live_transport(transport)
transport.close()
try:
await asyncio.to_thread(server._release_wake_for_transport, transport)
except Exception:
_log.exception("ws wake-word teardown failed peer=%s", peer)
# Reap sessions this transport owned (close_on_disconnect sidecar
# sessions) or detach the rest to the drop sentinel so later emits
# don't crash into a closed socket or fall through to desktop stdout
@@ -851,6 +851,65 @@ describe('createGatewayEventHandler', () => {
expect(polarityBackgroundFromForeground('not-a-color')).toBeUndefined()
})
it('claims wake-word ownership when the gateway becomes ready', () => {
const ctx = buildCtx([])
createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)
expect(ctx.gateway.rpc).toHaveBeenCalledWith('wake.start', { surface: 'tui' })
})
it('opens a fresh session before starting voice after wake detection', async () => {
const ctx = buildCtx([])
ctx.session.newSession = vi.fn(async () => patchUiState({ sid: 'wake-session' }))
patchUiState({ sid: 'old-session' })
createGatewayEventHandler(ctx)({
payload: { phrase: 'hey hermes', start_new_session: true },
type: 'wake.detected'
} as any)
await vi.waitFor(() =>
expect(ctx.gateway.rpc).toHaveBeenCalledWith('voice.record', {
action: 'start',
session_id: 'wake-session'
})
)
expect(ctx.session.newSession).toHaveBeenCalledOnce()
expect(ctx.voice.setVoiceEnabled).toHaveBeenCalledWith(true)
})
it('keeps the current session when wake detection disables session creation', async () => {
const ctx = buildCtx([])
patchUiState({ sid: 'current-session' })
createGatewayEventHandler(ctx)({
payload: { phrase: 'hey hermes', start_new_session: false },
type: 'wake.detected'
} as any)
await vi.waitFor(() =>
expect(ctx.gateway.rpc).toHaveBeenCalledWith('voice.record', {
action: 'start',
session_id: 'current-session'
})
)
expect(ctx.session.newSession).not.toHaveBeenCalled()
})
it('rearms wake detection when no session is available', async () => {
const ctx = buildCtx([])
patchUiState({ sid: '' })
createGatewayEventHandler(ctx)({
payload: { start_new_session: false },
type: 'wake.detected'
} as any)
await vi.waitFor(() => expect(ctx.gateway.rpc).toHaveBeenCalledWith('wake.resume', {}))
expect(ctx.gateway.rpc).not.toHaveBeenCalledWith('voice.record', expect.anything())
})
it('on gateway.ready with no STARTUP_RESUME_ID and auto_resume off, forges a new session', async () => {
const appended: Msg[] = []
const newSession = vi.fn()
@@ -619,6 +619,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' }).catch(() => undefined)
rpc<CommandsCatalogResponse>('commands.catalog', {})
.then(r => {
if (!r?.pairs) {
@@ -934,6 +938,34 @@ 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) {
await rpc('wake.resume', {}).catch(() => undefined)
return
}
setVoiceEnabled(true)
await rpc('voice.toggle', { action: 'on' })
await rpc('voice.record', { action: 'start', session_id: sid })
})().catch((e: unknown) => {
sys(`wake: ${rpcErrorMessage(e)}`)
void rpc('wake.resume', {}).catch(() => undefined)
})
return
}
case 'gateway.start_timeout': {
const { cwd, python, stderr_tail: stderrTail } = ev.payload ?? {}
const trace = python || cwd ? ` · ${String(python || '')} ${String(cwd || '')}`.trim() : ''
+1
View File
@@ -584,6 +584,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' }
| {
Generated
+224 -41
View File
@@ -1718,6 +1718,13 @@ voice = [
{ name = "numpy" },
{ name = "sounddevice" },
]
wake = [
{ name = "numpy" },
{ name = "onnxruntime" },
{ name = "openwakeword" },
{ name = "pvporcupine" },
{ name = "sounddevice" },
]
web = [
{ name = "fastapi" },
{ name = "python-multipart" },
@@ -1807,7 +1814,10 @@ requires-dist = [
{ name = "modal", marker = "extra == 'modal'", specifier = "==1.3.4" },
{ name = "nemo-relay", marker = "extra == 'nemo-relay'", specifier = ">=0.5,<1.0" },
{ name = "numpy", marker = "extra == 'voice'", specifier = "==2.4.3" },
{ name = "numpy", marker = "extra == 'wake'", specifier = "==2.4.3" },
{ name = "onnxruntime", marker = "extra == 'wake'", specifier = "==1.27.0" },
{ name = "openai", specifier = "==2.24.0" },
{ name = "openwakeword", marker = "extra == 'wake'", specifier = "==0.6.0" },
{ name = "packaging", specifier = "==26.0" },
{ name = "parallel-web", marker = "extra == 'parallel-web'", specifier = "==0.4.2" },
{ name = "pathspec", specifier = "==1.1.1" },
@@ -1815,6 +1825,7 @@ requires-dist = [
{ name = "prompt-toolkit", specifier = "==3.0.52" },
{ name = "psutil", specifier = "==7.2.2" },
{ name = "ptyprocess", marker = "sys_platform != 'win32'", specifier = ">=0.7.0,<1" },
{ name = "pvporcupine", marker = "extra == 'wake'", specifier = "==4.0.3" },
{ name = "pydantic", specifier = "==2.13.4" },
{ name = "pyjwt", extras = ["crypto"], specifier = "==2.13.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.2" },
@@ -1841,6 +1852,7 @@ requires-dist = [
{ name = "slack-sdk", marker = "extra == 'messaging'", specifier = "==3.43.0" },
{ name = "slack-sdk", marker = "extra == 'slack'", specifier = "==3.43.0" },
{ name = "sounddevice", marker = "extra == 'voice'", specifier = "==0.5.5" },
{ name = "sounddevice", marker = "extra == 'wake'", specifier = "==0.5.5" },
{ name = "starlette", marker = "extra == 'computer-use'", specifier = "==1.0.1" },
{ name = "starlette", marker = "extra == 'dev'", specifier = "==1.0.1" },
{ name = "starlette", marker = "extra == 'mcp'", specifier = "==1.0.1" },
@@ -1855,7 +1867,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", "honcho", "supermemory", "mem0", "vision", "pty", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"]
provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "wake", "honcho", "supermemory", "mem0", "vision", "pty", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"]
[[package]]
name = "hf-xet"
@@ -2148,6 +2160,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
]
[[package]]
name = "joblib"
version = "1.5.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" },
]
[[package]]
name = "jsonpath-python"
version = "1.1.6"
@@ -2457,15 +2478,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/aa/f0ffbe6bf679a597e8be692ca3cde47de6156435c2b72cf752fec719bb1f/modal-1.3.4-py3-none-any.whl", hash = "sha256:d66a851969f447936b3512f1c3708435ce1ca81171eeddc3eb0678f594493380", size = 773837, upload-time = "2026-02-23T15:44:03.635Z" },
]
[[package]]
name = "mpmath"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" },
]
[[package]]
name = "msal"
version = "1.36.0"
@@ -2608,6 +2620,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
]
[[package]]
name = "narwhals"
version = "2.24.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2b/1d/58946e5aab18393e793bd4add6985b95d0e01c3a2d832f38f54468b10dcd/narwhals-2.24.0.tar.gz", hash = "sha256:b5c0f684ccd9d7475b564111e319a4964abcf2baf79d3cf6b1003d06ac9b828d", size = 661143, upload-time = "2026-07-13T10:49:19.086Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl", hash = "sha256:42fdedf44e5b2ca7505630d45b4ac3058f38d8485cba9fe1652ca23152df7489", size = 461030, upload-time = "2026-07-13T10:49:17.571Z" },
]
[[package]]
name = "nemo-relay"
version = "0.5.0"
@@ -2748,33 +2769,32 @@ wheels = [
[[package]]
name = "onnxruntime"
version = "1.24.4"
version = "1.27.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flatbuffers" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "protobuf" },
{ name = "sympy" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/69/6c40720201012c6af9aa7d4ecdd620e521bd806dc6269d636fdd5c5aeebe/onnxruntime-1.24.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:0bdfce8e9a6497cec584aab407b71bf697dac5e1b7b7974adc50bf7533bdb3a2", size = 17332131, upload-time = "2026-03-17T22:05:49.005Z" },
{ url = "https://files.pythonhosted.org/packages/38/e9/8c901c150ce0c368da38638f44152fb411059c0c7364b497c9e5c957321a/onnxruntime-1.24.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:046ff290045a387676941a02a8ae5c3ebec6b4f551ae228711968c4a69d8f6b7", size = 15152472, upload-time = "2026-03-17T22:03:26.176Z" },
{ url = "https://files.pythonhosted.org/packages/d5/b6/7a4df417cdd01e8f067a509e123ac8b31af450a719fa7ed81787dd6057ec/onnxruntime-1.24.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e54ad52e61d2d4618dcff8fa1480ac66b24ee2eab73331322db1049f11ccf330", size = 17222993, upload-time = "2026-03-17T22:04:34.485Z" },
{ url = "https://files.pythonhosted.org/packages/dd/59/8febe015f391aa1757fa5ba82c759ea4b6c14ef970132efb5e316665ba61/onnxruntime-1.24.4-cp311-cp311-win_amd64.whl", hash = "sha256:b43b63eb24a2bc8fc77a09be67587a570967a412cccb837b6245ccb546691153", size = 12594863, upload-time = "2026-03-17T22:05:38.749Z" },
{ url = "https://files.pythonhosted.org/packages/32/84/4155fcd362e8873eb6ce305acfeeadacd9e0e59415adac474bea3d9281bb/onnxruntime-1.24.4-cp311-cp311-win_arm64.whl", hash = "sha256:e26478356dba25631fb3f20112e345f8e8bf62c499bb497e8a559f7d69cf7e7b", size = 12259895, upload-time = "2026-03-17T22:05:28.812Z" },
{ url = "https://files.pythonhosted.org/packages/d7/38/31db1b232b4ba960065a90c1506ad7a56995cd8482033184e97fadca17cc/onnxruntime-1.24.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cad1c2b3f455c55678ab2a8caa51fb420c25e6e3cf10f4c23653cdabedc8de78", size = 17341875, upload-time = "2026-03-17T22:05:51.669Z" },
{ url = "https://files.pythonhosted.org/packages/aa/60/c4d1c8043eb42f8a9aa9e931c8c293d289c48ff463267130eca97d13357f/onnxruntime-1.24.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a5c5a544b22f90859c88617ecb30e161ee3349fcc73878854f43d77f00558b5", size = 15172485, upload-time = "2026-03-17T22:03:32.182Z" },
{ url = "https://files.pythonhosted.org/packages/6d/ab/5b68110e0460d73fad814d5bd11c7b1ddcce5c37b10177eb264d6a36e331/onnxruntime-1.24.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d640eb9f3782689b55cfa715094474cd5662f2f137be6a6f847a594b6e9705c", size = 17244912, upload-time = "2026-03-17T22:04:37.251Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f4/6b89e297b93704345f0f3f8c62229bee323ef25682a3f9b4f89a39324950/onnxruntime-1.24.4-cp312-cp312-win_amd64.whl", hash = "sha256:535b29475ca42b593c45fbb2152fbf1cdf3f287315bf650e6a724a0a1d065cdb", size = 12596856, upload-time = "2026-03-17T22:05:41.224Z" },
{ url = "https://files.pythonhosted.org/packages/43/06/8b8ec6e9e6a474fcd5d772453f627ad4549dfe3ab8c0bf70af5afcde551b/onnxruntime-1.24.4-cp312-cp312-win_arm64.whl", hash = "sha256:e6214096e14b7b52e3bee1903dc12dc7ca09cb65e26664668a4620cc5e6f9a90", size = 12270275, upload-time = "2026-03-17T22:05:31.132Z" },
{ url = "https://files.pythonhosted.org/packages/e9/f0/8a21ec0a97e40abb7d8da1e8b20fb9e1af509cc6d191f6faa75f73622fb2/onnxruntime-1.24.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e99a48078baaefa2b50fe5836c319499f71f13f76ed32d0211f39109147a49e0", size = 17341922, upload-time = "2026-03-17T22:03:56.364Z" },
{ url = "https://files.pythonhosted.org/packages/8b/25/d7908de8e08cee9abfa15b8aa82349b79733ae5865162a3609c11598805d/onnxruntime-1.24.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4aaed1e5e1aaacf2343c838a30a7c3ade78f13eeb16817411f929d04040a13", size = 15172290, upload-time = "2026-03-17T22:03:37.124Z" },
{ url = "https://files.pythonhosted.org/packages/7f/72/105ec27a78c5aa0154a7c0cd8c41c19a97799c3b12fc30392928997e3be3/onnxruntime-1.24.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e30c972bc02e072911aabb6891453ec73795386c0af2b761b65444b8a4c4745f", size = 17244738, upload-time = "2026-03-17T22:04:40.625Z" },
{ url = "https://files.pythonhosted.org/packages/05/fb/a592736d968c2f58e12de4d52088dda8e0e724b26ad5c0487263adb45875/onnxruntime-1.24.4-cp313-cp313-win_amd64.whl", hash = "sha256:3b6ba8b0181a3aa88edab00eb01424ffc06f42e71095a91186c2249415fcff93", size = 12597435, upload-time = "2026-03-17T22:05:43.826Z" },
{ url = "https://files.pythonhosted.org/packages/ad/04/ae2479e9841b64bd2eb44f8a64756c62593f896514369a11243b1b86ca5c/onnxruntime-1.24.4-cp313-cp313-win_arm64.whl", hash = "sha256:71d6a5c1821d6e8586a024000ece458db8f2fc0ecd050435d45794827ce81e19", size = 12269852, upload-time = "2026-03-17T22:05:33.353Z" },
{ url = "https://files.pythonhosted.org/packages/b4/af/a479a536c4398ffaf49fbbe755f45d5b8726bdb4335ab31b537f3d7149b8/onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1700f559c8086d06b2a4d5de51e62cb4ff5e2631822f71a36db8c72383db71ee", size = 15176861, upload-time = "2026-03-17T22:03:40.143Z" },
{ url = "https://files.pythonhosted.org/packages/be/13/19f5da70c346a76037da2c2851ecbf1266e61d7f0dcdb887c667210d4608/onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c74e268dc808e61e63784d43f9ddcdaf50a776c2819e8bd1d1b11ef64bf7e36", size = 17247454, upload-time = "2026-03-17T22:04:46.643Z" },
{ url = "https://files.pythonhosted.org/packages/d4/e4/5353d7e09ced4a8f473f843223fc75d726b2b5519dcefc12f22a6c92852d/onnxruntime-1.27.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:8ba14a38c570087f3cdb8cfba33f7a38a1e826c1e5b29e17c28ceda0cc910016", size = 18416484, upload-time = "2026-06-15T22:43:43.894Z" },
{ url = "https://files.pythonhosted.org/packages/ed/1f/a2117aa3f144fce88774efa37440d0ca72d0c9144854dfc0961f2b04c6fc/onnxruntime-1.27.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2eb083321af8a236a84c7c140a7f4cecbfa2a987a18c07c78db471c20cd390ef", size = 16419330, upload-time = "2026-06-15T22:42:37.58Z" },
{ url = "https://files.pythonhosted.org/packages/e0/cd/74bb804170ceb622fda9111df31a07b3024f7491472256d3a90b5391a4d2/onnxruntime-1.27.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4f7b0e90d2d212e2c2deaa6c8291616183ab815d3ec558ea12d3ac8b26d36f4", size = 18636930, upload-time = "2026-06-15T22:43:01.584Z" },
{ url = "https://files.pythonhosted.org/packages/fe/8f/5b8e2b85e81735696887175dbaf6409f215683f5ca9d4928fbb038211d32/onnxruntime-1.27.0-cp311-cp311-win_amd64.whl", hash = "sha256:ff050e4f6bf7f12918fa14dcb047c0b02e295f35e86d42532552be4b3d54e977", size = 13356110, upload-time = "2026-06-15T22:43:32.172Z" },
{ url = "https://files.pythonhosted.org/packages/b0/3a/4f568de678126b6a371a93862f015a82138359decd97fcac61fc84b5b774/onnxruntime-1.27.0-cp311-cp311-win_arm64.whl", hash = "sha256:75fbc1e1fb43a39a856c8209c544cca7817b5de7ac16b15b1bdf55d1cc67b9df", size = 13098635, upload-time = "2026-06-15T22:43:19.607Z" },
{ url = "https://files.pythonhosted.org/packages/c3/b7/dd3a524ed93a820dff1af902d0412957ab12499953333e9daa01af5bc480/onnxruntime-1.27.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a14c2ce45312def86b77aea651f46565e45960cf5f0721bfdff449165086ab76", size = 18433506, upload-time = "2026-06-15T22:43:47.026Z" },
{ url = "https://files.pythonhosted.org/packages/84/86/c3b6b17745a1997d784dadc9bd88d713d2e6721139a5a0e885b28cfb79b1/onnxruntime-1.27.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6fddce0539a4898c7bef35b052ffd37935b2190e35488eab99ce91887743ea1", size = 16438140, upload-time = "2026-06-15T22:42:40.666Z" },
{ url = "https://files.pythonhosted.org/packages/26/81/24dd9b31b0fb912ee19ca53ac1c9764bfd79d58a2ccef564eb693be831a5/onnxruntime-1.27.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c65a7438632d55dfbc8a02ee60bd6cf7dd9d1ba05a43d4b851452f32338e194", size = 18658316, upload-time = "2026-06-15T22:43:04.012Z" },
{ url = "https://files.pythonhosted.org/packages/4f/88/8ec9db1a4d126bb8b758992beb40d1249df171917d75f44a327eb5f20dda/onnxruntime-1.27.0-cp312-cp312-win_amd64.whl", hash = "sha256:20c321cf187ba496e648acf6b4cf90b4d398b0d17c2a77fdaeba365b908cc1c1", size = 13358769, upload-time = "2026-06-15T22:43:34.581Z" },
{ url = "https://files.pythonhosted.org/packages/ae/9f/fdad359dfcba7e7cd8815569b304a596531d4efa77a75d77f8b4981891a2/onnxruntime-1.27.0-cp312-cp312-win_arm64.whl", hash = "sha256:d0d1f68868e2ef30ef70998ba9bbbc5c305e9b17041e3936751c1b8aa6aade06", size = 13104440, upload-time = "2026-06-15T22:43:22.893Z" },
{ url = "https://files.pythonhosted.org/packages/fb/2b/54208fd03ad410480bc17edf4869376362da8bbf46fe186ddf4cb5cc20fe/onnxruntime-1.27.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:b3e5b58b8c89c2b20e086e890aa9527377e5c240dc3ecc1640d18e07705eeb1c", size = 18432958, upload-time = "2026-06-15T22:42:53.105Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/24fc51fcbb126da6d032372314e47b55c3faad58f2aa78c0e199ccd20b9c/onnxruntime-1.27.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48b3d87eb560ff6a772240506f3c78d6d27c63cafedd5c775672e1194f968cfd", size = 16438180, upload-time = "2026-06-15T22:42:43.093Z" },
{ url = "https://files.pythonhosted.org/packages/cb/19/14929c3c2fe0b79b41cce24463062bf3afa4cdd3c19dccf00319caa92bff/onnxruntime-1.27.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6872443f236a554921cda6f318c900e2d0c226792cf3534d00e5057c6926e5d2", size = 18658445, upload-time = "2026-06-15T22:43:08.053Z" },
{ url = "https://files.pythonhosted.org/packages/7f/76/59ed932b0244acd7bbbd6449480053a6d958ea66357f022f932872e19287/onnxruntime-1.27.0-cp313-cp313-win_amd64.whl", hash = "sha256:760021bca514d64a811837820d351a08a41741f16f8b4c26450da708fecf14e6", size = 13357856, upload-time = "2026-06-15T22:43:37.315Z" },
{ url = "https://files.pythonhosted.org/packages/79/51/d1ec60ec7b1e2ae2d7340ba52b8a13529140039cd4407ba8dddbbc046582/onnxruntime-1.27.0-cp313-cp313-win_arm64.whl", hash = "sha256:2fdfa9df40a0ded0028ce6f9cd863264237f3970559dea2b81456e9ac4622b94", size = 13104412, upload-time = "2026-06-15T22:43:27.457Z" },
{ url = "https://files.pythonhosted.org/packages/5e/7d/e6bb1c6445c94f708c38cd8fbb7bf0264108c33498b9445c93e60fe6d329/onnxruntime-1.27.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54c0c4e9202c36c4ecdb1f3443f5dfbfd5ee3b54d1362c4b4c6134110e74fb32", size = 16443331, upload-time = "2026-06-15T22:42:45.649Z" },
{ url = "https://files.pythonhosted.org/packages/72/1b/b18b31e806eabc41077810199fbbb36fbc2d5f19912416e5ccfbf73053d1/onnxruntime-1.27.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1b215aa662c8f983f7d6dedafe65a9be72c26e5338e0fe98b3e0422c32c85428", size = 18670967, upload-time = "2026-06-15T22:43:10.621Z" },
]
[[package]]
@@ -2918,6 +2938,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/16/5c/d3f1733665f7cd582ef0842fb1d2ed0bc1fba10875160593342d22bba375/opentelemetry_util_http-0.60b1-py3-none-any.whl", hash = "sha256:66381ba28550c91bee14dcba8979ace443444af1ed609226634596b4b0faf199", size = 8947, upload-time = "2025-12-11T13:36:37.151Z" },
]
[[package]]
name = "openwakeword"
version = "0.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "onnxruntime" },
{ name = "requests" },
{ name = "scikit-learn" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "tflite-runtime", marker = "sys_platform == 'linux'" },
{ name = "tqdm" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b5/9b/73b7d98b07f4e1f525ad39703e0c5f30ff61c3fa16c8bfe4d99eadc0567a/openwakeword-0.6.0.tar.gz", hash = "sha256:36858d90f1183e307485597a912a4e3c3384b14ea9923f83feaffae7c1565565", size = 70830, upload-time = "2024-02-11T20:56:17.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/33/dafd6822bebe463a9098951d06a0d88fb4f8c946ce087025bc4fa132e533/openwakeword-0.6.0-py3-none-any.whl", hash = "sha256:6f423a4e3ae9dd0e3cd12b50ff8abf69679f687b4ab349d7c82c021c0e2abc9d", size = 60690, upload-time = "2024-02-11T20:56:16.179Z" },
]
[[package]]
name = "packaging"
version = "26.0"
@@ -3190,6 +3228,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" },
]
[[package]]
name = "pvporcupine"
version = "4.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b2/37/db209e19c4e1d931d1752bdf05c763f119271bb79661d482bdf5f564f662/pvporcupine-4.0.3.tar.gz", hash = "sha256:87d0e4d743a13c3a15b1fb34a9ced66e14bb1125ae079f2e2c09423364a68386", size = 3643620, upload-time = "2026-06-25T21:58:11.366Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/ef/1c4b8e47d8248fe1b615772028265ca65d9ff3ea98022d84cd973d46db87/pvporcupine-4.0.3-py3-none-any.whl", hash = "sha256:92796dbd3cf80a56db1ce20702cbceb151aee34f19ef730591f815eb16f2ebfb", size = 3659883, upload-time = "2026-06-25T21:58:08.805Z" },
]
[[package]]
name = "pyasn1"
version = "0.6.3"
@@ -3853,6 +3903,129 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" },
]
[[package]]
name = "scikit-learn"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "joblib" },
{ name = "narwhals" },
{ name = "numpy" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "threadpoolctl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/be/e844fd9586e66540a15b71924d17a6cbc1bb749e81ddd0a796bcdba4c055/scikit_learn-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9db6f4d34e68c8899e4cab27fdf8eafe6ed21f2ba52ceb25ea250cd237f8e47b", size = 8789686, upload-time = "2026-06-02T11:53:05.439Z" },
{ url = "https://files.pythonhosted.org/packages/42/e2/ff880f62677a17d035817d543cb0fc8727d01eccbee81c5f7fc733a9d856/scikit_learn-1.9.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f401448645a3e7bc115aa3c094097865155b34bff1cba8101857d9104e99074c", size = 8256782, upload-time = "2026-06-02T11:53:08.904Z" },
{ url = "https://files.pythonhosted.org/packages/25/64/eb40435e1a508ab1b4e284ce43ae80f6a162e5be5e38ed5a6fab467a9ea4/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd3a8ef0c758555a3b23c03adaa858af32f7736785ded50ad5991f59c4ed03fa", size = 8992419, upload-time = "2026-06-02T11:53:11.551Z" },
{ url = "https://files.pythonhosted.org/packages/8d/da/4810a28e473185429e45a57eebcc91fc991b33d889cc0676063e671db03d/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7e254636164090da847715a27f8e5478feb98c40a9e0ee90cbd277de9e5ceb8", size = 9281411, upload-time = "2026-06-02T11:53:15.063Z" },
{ url = "https://files.pythonhosted.org/packages/3b/67/be3d369f40d8178ba3bd86635d132e08cb5329b023e4669d9426d84bc007/scikit_learn-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:5dc1818c77575d149e25fce9ef82dd7b7263ae372f03494158668ad632a69759", size = 8272736, upload-time = "2026-06-02T11:53:18.108Z" },
{ url = "https://files.pythonhosted.org/packages/37/79/a733f02dc2118da7e77a134b34f39f40201a353311b011d20859d2db3556/scikit_learn-1.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:366652351f092b219c248f1e72821e841960a63d8f358f1dcfd54dc1cbdbbc28", size = 7919564, upload-time = "2026-06-02T11:53:21.2Z" },
{ url = "https://files.pythonhosted.org/packages/ac/20/75f915ff375d6249e6550ac740fdbbd66159a068fd3af1400ff62036b07a/scikit_learn-1.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2bd41b0d201bc81575531b96b713d3eb5e5f50fb0b82101ff0f92294fdc236ac", size = 8741122, upload-time = "2026-06-02T11:53:24.08Z" },
{ url = "https://files.pythonhosted.org/packages/cc/d5/2b5148f2279196775e1db2aeb85d14b70ac80e7e32b3b28e7ebeafb0901d/scikit_learn-1.9.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5be45aa4a42a68a533913a6ed736cf309de2226411c79ef8d609a5456f1939b1", size = 8261512, upload-time = "2026-06-02T11:53:27.183Z" },
{ url = "https://files.pythonhosted.org/packages/a0/ee/5adbc77656b71f9456a2f5a7a9fdb4bcf9207a6b962889f1c2f9323afa4e/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e50ed4da51974e86e940690e9a3d82e729b62b5a49f7c9bac534d515d39d86f", size = 8837603, upload-time = "2026-06-02T11:53:30.328Z" },
{ url = "https://files.pythonhosted.org/packages/6c/c2/63fdda36c56437eeb44aaf9493c8bcd62ce230ab1598924fc626ffbfa943/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:056c92bb67ad4c28463c2f2653d9701449201e7e7a9e94e321be0f71c4fef2b8", size = 9132097, upload-time = "2026-06-02T11:53:33.456Z" },
{ url = "https://files.pythonhosted.org/packages/83/a4/c8e67227c680e2259c8864ae72ff48b06e16a6f51253a22167aa02a8aa4e/scikit_learn-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:4306775fad04cc4b472a1b15af1ae9cede1540fbfcc17fbce3767cd8dc7ae283", size = 8211173, upload-time = "2026-06-02T11:53:36.602Z" },
{ url = "https://files.pythonhosted.org/packages/cf/fd/3c0863792e98e67e9184aa4029288a175935eb65443afcd30d4f143450cf/scikit_learn-1.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:26e22435f63bcdcf396b574273f29f13dd531f5ea035801f5be10ba1540a4e60", size = 7867451, upload-time = "2026-06-02T11:53:39.075Z" },
{ url = "https://files.pythonhosted.org/packages/3c/01/cf3310626b6d48d3e9be69a1223f9180360b5e6edb045f50fade723ce494/scikit_learn-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80746d63bd4b6eaca54d36fe5feaf4d28bb38dc6f9470f81c7cad7c40155f119", size = 8705188, upload-time = "2026-06-02T11:53:41.964Z" },
{ url = "https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713", size = 8228299, upload-time = "2026-06-02T11:53:44.817Z" },
{ url = "https://files.pythonhosted.org/packages/0c/39/ffe829a5b8ecb40a518724a997794657fdc354ada5e8fe8e64d998c0bac9/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:38c3dcb9a1ffb85505ec53d54c7b4aea0cff70050425a7760c2af661ac85df05", size = 8789690, upload-time = "2026-06-02T11:53:47.461Z" },
{ url = "https://files.pythonhosted.org/packages/1f/88/8dab5de10c638c083772a6be83a3d8106ced492f74a928c8693638e5bb50/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da76d09304a4706db7cc1e3ebaa3b6b98a67365cc11d2996c4f1e58ba47df714", size = 9087723, upload-time = "2026-06-02T11:53:50.702Z" },
{ url = "https://files.pythonhosted.org/packages/20/3f/7917ca72464038f6240ec70c29f94862d08a34a74291ae4d4ec5eb8186a0/scikit_learn-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5808d98f15c6bf6d9d96d2348c1997392a5888ce7097e664105f930c4bca1277", size = 8184330, upload-time = "2026-06-02T11:53:53.396Z" },
{ url = "https://files.pythonhosted.org/packages/78/c7/15739eb2f61fda3c54639e9942414e5a19ad8a8d1f5a3266afad7cb7df80/scikit_learn-1.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:d77f54c017633791bc0225a43e2f8d03745fdcfe4880268fcc4df15f505dec2e", size = 7840653, upload-time = "2026-06-02T11:53:56.035Z" },
]
[[package]]
name = "scipy"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.12'",
]
dependencies = [
{ name = "numpy", marker = "python_full_version < '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" },
{ url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" },
{ url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" },
{ url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" },
{ url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" },
{ url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" },
{ url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" },
{ url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" },
{ url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" },
{ url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" },
{ url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" },
{ url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" },
{ url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" },
{ url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" },
{ url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" },
{ url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" },
{ url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" },
{ url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" },
{ url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" },
{ url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" },
{ url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" },
{ url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" },
{ url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" },
{ url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" },
{ url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" },
{ url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" },
{ url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" },
{ url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" },
{ url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" },
{ url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" },
{ url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" },
{ url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" },
{ url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" },
{ url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" },
{ url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" },
{ url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" },
{ url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" },
{ url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" },
{ url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" },
]
[[package]]
name = "scipy"
version = "1.18.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.13'",
"python_full_version == '3.12.*'",
]
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" },
{ url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" },
{ url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" },
{ url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" },
{ url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" },
{ url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" },
{ url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" },
{ url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" },
{ url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" },
{ url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" },
{ url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" },
{ url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" },
{ url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" },
{ url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" },
{ url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" },
{ url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" },
{ url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" },
{ url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" },
{ url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" },
{ url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" },
]
[[package]]
name = "setuptools"
version = "81.0.0"
@@ -4021,18 +4194,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4c/be/caf3b7d4b21851c7d8ddec7661f22089d95bb55bc7b4bdd79dea1001604e/supermemory-3.50.0-py3-none-any.whl", hash = "sha256:f6e2dd142934ec213d561414aeb0164ee408a34d339b84ed93440f03f4ca2290", size = 155533, upload-time = "2026-06-24T09:29:12.012Z" },
]
[[package]]
name = "sympy"
version = "1.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mpmath" },
]
sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" },
]
[[package]]
name = "synchronicity"
version = "0.11.1"
@@ -4072,6 +4233,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, TUI, and desktop app. An on-device hotword listener starts a voice session when you speak the wake phrase.
- **[Browser Automation](browser.md)** — Full browser automation with multiple backends: Browserbase cloud, Browser Use cloud, local Chrome/Brave/Chromium/Edge via CDP, or local Chromium. Navigate websites, fill forms, and extract information.
- **[Vision & Image Paste](vision.md)** — Multimodal vision support. Paste images from your clipboard into the CLI and ask the agent to analyze, describe, or work with them using any vision-capable model.
- **[Image Generation](image-generation.md)** — Generate images from text prompts using FAL.ai. Eleven models supported (FLUX 2 Klein/Pro, GPT-Image 1.5/2, Nano Banana Pro, Ideogram V3, Recraft V4 Pro, Qwen, Z-Image Turbo, Krea V2 Medium/Large); pick one via `hermes tools`.
@@ -0,0 +1,186 @@
---
sidebar_position: 11
title: "Wake Word"
description: "Hands-free 'Hey Hermes' wake word — start a voice session by speaking, the 'Hey Siri' way"
---
# Wake Word ("Hey Hermes")
The wake word turns Hermes into a hands-free assistant across the CLI, TUI, and
desktop app: with one setting on, Hermes listens in the background for a spoken
trigger phrase. Say it, and Hermes starts a fresh session, opens the microphone,
captures your command via the normal [voice pipeline](/user-guide/features/voice-mode),
and answers — exactly like "Hey Siri" or "Alexa". Use `surface` to pick which
one listens.
Detection runs **entirely on-device**. The always-on listener only watches for
the wake phrase; no audio leaves your machine until you actually speak a command
to the agent.
## How it works
1. With `wake_word.enabled: true` (or after `/wake on`), a lightweight hotword
detector listens on your default microphone.
2. When it hears the wake phrase it pauses itself (freeing the mic), starts a new
session, and records one utterance with voice mode's silence detection.
3. Your speech is transcribed and sent to the agent. After it replies, the
listener resumes automatically and waits for the next wake word.
It is **off by default** — nothing listens until you turn it on.
## Engines
| Engine | Cost | API key | Notes |
|--------|------|---------|-------|
| **openWakeWord** (default) | Free | None | Local ONNX models. Ships a bundled **"hey hermes"** model (default); also supports `hey_jarvis`, `alexa`, `hey_mycroft`, … and custom models |
| **Porcupine** | Free tier / paid | `PORCUPINE_ACCESS_KEY` | Picovoice engine; built-in keywords + custom `.ppn` files |
By default the phrase is **"hey hermes"** — a model for it ships with Hermes, so
it works out of the box with no training. (On first use, openWakeWord downloads
its shared feature-extraction models — a small one-time fetch.)
Both are lazy-installed the first time you enable the wake word. To install ahead
of time:
```bash
cd ~/.hermes/hermes-agent && uv pip install -e ".[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 # eligible surface: "auto" | "cli" | "tui" | "gui"
provider: openwakeword # "openwakeword" (free, local) | "porcupine"
phrase: "hey hermes" # cosmetic label only — detection is keyed by the model/keyword below
sensitivity: 0.5 # 0.0-1.0 — raise to reduce false triggers
start_new_session: true # start a fresh session on wake vs. continue the current one
openwakeword:
model: hey_hermes # bundled default; OR a built-in name OR a path to a custom .onnx/.tflite
inference_framework: onnx # "onnx" | "tflite"
porcupine:
keyword: jarvis # built-in keyword OR path to a custom .ppn
```
`sensitivity`, `phrase`, and `start_new_session` apply to both engines. The
`openwakeword` and `porcupine` blocks select the actual detection model.
### Surfaces (CLI, TUI, GUI)
The wake word works in all three Hermes surfaces, and `surface` picks which one
owns the listener and opens the new session when it fires:
| `surface` | Behavior |
|-----------|----------|
| `auto` (default) | All local surfaces are eligible; the first one to arm owns 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,
including when Hermes surfaces run in separate processes. Ownership is sticky:
the first eligible claimant keeps the listener until it stops, disconnects, or
its process exits. Hermes does not silently fail over to another open surface.
Set `surface` when you want to pin ownership instead of using first-claim wins.
The TUI and desktop GUI share the same Python backend (`tui_gateway`), which
runs the detector server-side and yields the mic to voice capture while a
command records.
## Using a different phrase
"Hey Hermes" works out of the box — the bundled openWakeWord model
(`model: hey_hermes`) is the default. To wake on something else, either name a
built-in openWakeWord model or supply your own:
### Option A — openWakeWord (free)
Name a built-in model (`hey_jarvis`, `alexa`, `hey_mycroft`, …), or train a
custom model (≈75–90 min on a free/Colab GPU), drop the `.onnx` file somewhere,
and reference it:
```yaml
wake_word:
enabled: true
provider: openwakeword
phrase: "computer"
openwakeword:
model: ~/.hermes/wakewords/computer.onnx # or a built-in name like hey_jarvis
```
Or let Hermes train one for you. Install the **wake-word-training** skill and
ask it to make a phrase (defaults to `hey <profile>`); it drives openWakeWord's
trainer end-to-end — synthetic voices (OpenAI TTS or Piper), model export into
your profile's `~/.hermes/wakewords/`, and the config wiring:
```bash
hermes skills install official/productivity/wake-word-training
```
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.