diff --git a/apps/desktop/electron/session-windows.test.ts b/apps/desktop/electron/session-windows.test.ts index b5f9cc6a8be..a60ebb37621 100644 --- a/apps/desktop/electron/session-windows.test.ts +++ b/apps/desktop/electron/session-windows.test.ts @@ -208,3 +208,13 @@ test('chatWindowWebPreferences passes the preload path through and keeps the har assert.equal(prefs.sandbox, true) assert.equal(prefs.nodeIntegration, false) }) + +test('chatWindowWebPreferences allows autoplay so wake-started voice speaks its first reply', () => { + // Regression: Chromium's default autoplay policy suspends audio until a user + // gesture. A wake-word-started voice conversation has no preceding click, so + // the first reply's playback was rejected and only turn 2+ spoke. A native + // app the user launched should not gate audio on a gesture. + const prefs = chatWindowWebPreferences('/tmp/preload.cjs') + + assert.equal(prefs.autoplayPolicy, 'no-user-gesture-required') +}) diff --git a/apps/desktop/electron/session-windows.ts b/apps/desktop/electron/session-windows.ts index 46871b5384e..312deb3d1e3 100644 --- a/apps/desktop/electron/session-windows.ts +++ b/apps/desktop/electron/session-windows.ts @@ -21,6 +21,16 @@ const SESSION_WINDOW_MIN_HEIGHT = 620 // occluded windows. A streaming chat app must keep painting in the // background, so every chat window opts out. The preload path is injected // because it depends on the Electron entry's __dirname. +// +// `autoplayPolicy: 'no-user-gesture-required'` is load-bearing for voice: +// Chromium's default autoplay policy suspends audio (HTMLAudioElement.play() +// and AudioContext) until the user has interacted with the frame. A voice +// conversation started by the "Hey Hermes" wake word has NO preceding click, +// so the FIRST reply's audio playback was rejected (NotAllowedError, silently +// swallowed) and only turn 2+ spoke — the very "first message in a new voice +// session is silent" bug. Manual voice-start worked only because the button +// click counted as the gesture. This is a native app the user deliberately +// launched; there is no drive-by-autoplay concern to protect against. function chatWindowWebPreferences(preloadPath: string) { return { preload: preloadPath, @@ -29,7 +39,8 @@ function chatWindowWebPreferences(preloadPath: string) { sandbox: true, nodeIntegration: false, devTools: true, - backgroundThrottling: false + backgroundThrottling: false, + autoplayPolicy: 'no-user-gesture-required' as const } } diff --git a/apps/desktop/src/app/chat/composer/controls.test.tsx b/apps/desktop/src/app/chat/composer/controls.test.tsx index 90d38d27495..8a84be8ea47 100644 --- a/apps/desktop/src/app/chat/composer/controls.test.tsx +++ b/apps/desktop/src/app/chat/composer/controls.test.tsx @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { ChatBarState } from '@/app/chat/composer/types' import { I18nProvider } from '@/i18n' +import { applyWakeStartResult, applyWakeStatus, resetWakeWordState } from '@/store/wake-word' import { ComposerControls } from './controls' @@ -77,3 +78,62 @@ describe('ComposerControls shortcut tooltips', () => { await expectShortcutTooltip('Queue message', 'Ctrl+↵') }) }) + +describe('wake-word ear visibility', () => { + afterEach(() => { + resetWakeWordState() + }) + + it('stays mounted during a busy agent turn', () => { + applyWakeStatus({ available: true, enabled: true, listening: true, phrase: 'hey hermes' }) + renderControls({ busy: true, busyAction: 'stop' }) + + expect(screen.getByLabelText('Wake word: "hey hermes" — listening')).toBeTruthy() + }) + + it('stays mounted (enabled in config) even when a start was refused', () => { + applyWakeStatus({ available: true, enabled: true, listening: false, phrase: 'hey hermes' }) + // Transient refusal marks available false but enabled keeps it mounted. + applyWakeStartResult({ hint: 'mic busy', reason: 'unavailable', started: false }) + renderControls() + + expect(screen.getByLabelText('Wake word: "hey hermes" — off')).toBeTruthy() + }) + + it('stays visible (never hides) even when unavailable and not enabled', () => { + applyWakeStatus({ available: false, enabled: false, listening: false, phrase: 'hey hermes' }) + renderControls() + + // The ear ALWAYS shows so the user can click to enable; a failed start + // surfaces its reason in the tooltip rather than hiding the control. + expect(screen.getByLabelText('Wake word: "hey hermes" — off')).toBeTruthy() + }) + + it('surfaces the backend refusal reason in the tooltip, still visible', () => { + applyWakeStatus({ available: false, enabled: false, listening: false, phrase: 'hey hermes' }) + applyWakeStartResult({ hint: 'run `hermes tools` (Voice section)', reason: 'unavailable', started: false }) + renderControls() + + const ear = screen.getByLabelText('Wake word: "hey hermes" — off') + expect(ear).toBeTruthy() + }) + + it('shows a disabled paused ear inside the voice-conversation pill', () => { + applyWakeStatus({ available: true, enabled: true, listening: true, phrase: 'hey hermes' }) + renderControls({ + conversation: { + active: true, + level: 0, + muted: false, + onEnd: vi.fn(), + onStart: vi.fn(), + onStopTurn: vi.fn(), + onToggleMute: vi.fn(), + status: 'listening' + } + }) + + const ear = screen.getByLabelText('Wake word: "hey hermes" — paused during voice chat') + expect((ear as HTMLButtonElement).disabled).toBe(true) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index 996b962f544..7c1a90df4de 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -1,10 +1,13 @@ +import { useStore } from '@nanostores/react' + import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { Tip, TipKeybindLabel } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' -import { AudioLines, iconSize, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons' +import { AudioLines, Ear, EarOff, iconSize, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons' import { cn } from '@/lib/utils' +import { $wakeWord, toggleWakeWord } from '@/store/wake-word' import type { ConversationStatus } from './hooks/use-voice-conversation' import { ModelPill } from './model-pill' @@ -80,6 +83,7 @@ export function ComposerControls({ + {busyAction === 'steer' ? ( }> + + ) +} + function DictationButton({ disabled, state, diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts index 8e53096f322..e25a6bfc8ed 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts @@ -1,11 +1,15 @@ +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 { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs' +import { resumeWakeAfterVoice } from '@/store/wake-word' import type { ComposerTarget } from '../focus' import { onComposerVoiceToggleRequest } from '../focus' @@ -54,6 +58,7 @@ export function useComposerVoice({ const { $messages } = useComposerScope() const [voiceConversationActive, setVoiceConversationActive] = useState(false) const lastSpokenIdRef = useRef(null) + const voiceStartRequest = useStore($voiceConversationStartRequest) const { dictate, voiceActivityState, voiceStatus } = useVoiceRecorder({ focusInput, @@ -111,14 +116,30 @@ export function useComposerVoice({ await onSubmit(text) } + const wakePausedRef = useRef(false) + // Resolves once the in-flight wake.pause round-trip completes (mic released by + // the wake listener). The conversation awaits this before opening its own mic + // so the two never contend for the device — on Windows especially, opening the + // capture device while the wake listener still holds it makes getUserMedia + // fail and the conversation never starts listening. + const wakePauseBarrierRef = useRef | null>(null) + const conversation = useVoiceConversation({ busy, consumePendingResponse, enabled: voiceConversationActive, onFatalError: () => setVoiceConversationActive(false), + // A spoken stop command ("stop", "never mind", "goodbye", …) ends the + // hands-free conversation. Flipping the flag is the authoritative off + // switch — the enabled=false prop + effect below drive conversation.end() + // teardown (mic close, wake re-arm). + onStopWord: () => setVoiceConversationActive(false), onSubmit: submitVoiceTurn, onTranscribeAudio, - pendingResponse: pendingTurnResponse + pendingResponse: pendingTurnResponse, + // Before the conversation opens the mic, wait for any in-flight wake.pause + // to finish releasing the capture device (see wakePauseBarrierRef). + beforeMicOpen: () => wakePauseBarrierRef.current ?? undefined }) // The `composer.voice` hotkey (Ctrl+B) toggles the conversation. Starting @@ -142,6 +163,51 @@ export function useComposerVoice({ [target, toggleVoiceConversation] ) + useEffect(() => { + if (target === 'main' && !disabled && takeVoiceConversationStart(voiceStartRequest) && !voiceConversationActive) { + setVoiceConversationActive(true) + } + }, [disabled, target, voiceConversationActive, voiceStartRequest]) + + const resumeWakeIfPaused = useCallback(() => { + if (!wakePausedRef.current) { + return + } + + wakePausedRef.current = false + wakePauseBarrierRef.current = null + // Reconcile, don't just resume: the wake word is a persistent setting, so + // ending a voice chat must re-arm the listener whenever config says + // enabled — including when the raw resume loses the mic-release race. + void resumeWakeAfterVoice() + }, []) + + // The ref is a request token (did WE issue wake.pause?), not an atom mirror — + // it guards resumeWakeIfPaused from resuming a detector another surface owns. + const pauseWakeForVoice = useCallback(() => { + wakePausedRef.current = true + const barrier = (async () => { + try { + await $gateway.get()?.request('wake.pause', {}) + } catch { + // No wake listener / older backend — nothing held the mic. + } + })() + wakePauseBarrierRef.current = barrier + + return barrier + }, []) + + useEffect(() => { + if (voiceConversationActive) { + pauseWakeForVoice() + } else { + resumeWakeIfPaused() + } + }, [pauseWakeForVoice, 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), []) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts index 64b1c86c0ae..2340672ed9f 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts @@ -9,6 +9,7 @@ import { startSpeechStream, stopVoicePlayback } from '@/lib/voice-playback' +import { isVoiceStopCommand } from '@/lib/voice-stop-word' import { notify, notifyError } from '@/store/notifications' import { $voicePlayback } from '@/store/voice-playback' @@ -26,20 +27,26 @@ interface VoiceConversationOptions { busy: boolean enabled: boolean onFatalError?: () => void + onStopWord?: () => void onSubmit: (text: string) => Promise | void onTranscribeAudio?: (audio: Blob) => Promise pendingResponse: () => PendingVoiceResponse | null consumePendingResponse: () => void + /** Awaited right before the mic is opened. Used to let the wake-word listener + * fully release the capture device first, so the two never contend. */ + beforeMicOpen?: () => Promise | void } export function useVoiceConversation({ busy, enabled, onFatalError, + onStopWord, onSubmit, onTranscribeAudio, pendingResponse, - consumePendingResponse + consumePendingResponse, + beforeMicOpen }: VoiceConversationOptions) { const { t } = useI18n() const voiceCopy = t.notifications.voice @@ -61,6 +68,19 @@ export function useVoiceConversation({ const busyRef = useRef(busy) const statusRef = useRef('idle') const wasEnabledRef = useRef(enabled) + const onStopWordRef = useRef(onStopWord) + + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) + useEffect(() => { + onStopWordRef.current = onStopWord + }, [onStopWord]) + + const beforeMicOpenRef = useRef(beforeMicOpen) + + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) + useEffect(() => { + beforeMicOpenRef.current = beforeMicOpen + }, [beforeMicOpen]) // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { @@ -134,6 +154,18 @@ export function useVoiceConversation({ return } + // A spoken "stop" (or "never mind", "goodbye", …) ends the + // conversation instead of being submitted as a turn. Only whole- + // utterance stop commands match, so "stop the container" still goes + // through as a real request. + if (isVoiceStopCommand(transcript)) { + dropSpeechSession() + setStatus('idle') + onStopWordRef.current?.() + + return + } + awaitingSpokenResponseRef.current = true dropSpeechSession() await onSubmit(transcript) @@ -169,6 +201,20 @@ export function useVoiceConversation({ return } + // Let the wake-word listener fully release the capture device before we + // open ours — opening the mic while wake still holds it makes getUserMedia + // fail (the "clicked voice but it never starts listening" bug). + try { + await beforeMicOpenRef.current?.() + } catch { + // A pause failure shouldn't block the user's explicit start. + } + + // enabled/muted/busy or an interleaved turn may have changed while we waited. + if (!enabledRef.current || mutedRef.current || busyRef.current || statusRef.current !== 'idle') { + return + } + try { // VAD tuning mirrors `tools.voice_mode` defaults so the browser loop matches the CLI. await handle.start({ diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 9bd6db5d80b..0676c2da77d 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -29,11 +29,13 @@ import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChat import { sessionMessagesSignature } from '@/lib/session-signatures' import { isMessagingSource } from '@/lib/session-source' import { latestSessionTodos } from '@/lib/todos' +import { playWakeSound } from '@/lib/wake-sound' 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 { $previewTarget } from '@/store/preview' -import { $activeGatewayProfile, $freshSessionRequest, $profileScope, refreshActiveProfile } from '@/store/profile' +import { $activeGatewayProfile, $freshSessionRequest, $profileScope, ensureGatewayProfile, newSessionInProfile, normalizeProfileKey, refreshActiveProfile } from '@/store/profile' import { $startWorkSessionRequest, followActiveSessionCwd } from '@/store/projects' import { $activeSessionId, @@ -54,6 +56,7 @@ import { setMessages } from '@/store/session' import { clearSessionTodos, setSessionTodos, todosForHydration } from '@/store/todos' +import { armWakeWord } from '@/store/wake-word' import { isSecondaryWindow } from '@/store/windows' import { useSkinCommand } from '@/themes/use-skin-command' @@ -662,9 +665,41 @@ export function ContribWiring({ children }: { children: ReactNode }) { const handleGatewayEventWithPlugins = useCallback( (event: Parameters[0]) => { emitGatewayEvent(event) + + if (event.type === 'wake.detected') { + const payload = event.payload as + | { profile?: null | string; start_new_session?: boolean } + | undefined + + // Audible confirmation that the wake registered, before voice capture + // starts. Gated by the shared sound-mute toggle. + playWakeSound() + + // Multi-profile routing: a wake phrase enrolled by another profile + // re-homes the gateway to that profile first (live swap — same path + // as clicking it in the profile rail), then opens the fresh session + // and starts voice there. + const targetProfile = payload?.profile?.trim() + const activeProfile = normalizeProfileKey($activeGatewayProfile.get()) + + if (targetProfile && normalizeProfileKey(targetProfile) !== activeProfile) { + if (payload?.start_new_session !== false) { + newSessionInProfile(targetProfile) + } else { + void ensureGatewayProfile(normalizeProfileKey(targetProfile)) + } + } else if (payload?.start_new_session !== false) { + startFreshSessionDraft() + } + + requestVoiceConversationStart() + + return + } + handleDesktopGatewayEvent(event) }, - [handleDesktopGatewayEvent] + [handleDesktopGatewayEvent, startFreshSessionDraft] ) useGatewayBoot({ @@ -685,6 +720,14 @@ export function ContribWiring({ children }: { children: ReactNode }) { refreshSessions }) + useEffect(() => { + if (gatewayState === 'open') { + // Status-then-arm, syncing $wakeWord so the composer toggle reflects the + // same listener this auto-arm claims. + void armWakeWord(requestGateway) + } + }, [gatewayState, requestGateway]) + // Only the open messaging transcript needs its own poll — local chats are // live over the websocket already. const activeIsMessaging = diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 3b317fe5761..87f4e84914e 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1963,6 +1963,9 @@ export const en: Translations = { voiceDictation: 'Voice dictation', speakReplies: 'Read replies aloud', stopSpeakingReplies: 'Stop reading replies aloud', + wakeWordListening: phrase => `Wake word: "${phrase}" — listening`, + wakeWordOff: phrase => `Wake word: "${phrase}" — off`, + wakeWordPausedVoice: phrase => `Wake word: "${phrase}" — paused during voice chat`, lookupLoading: 'Looking up…', lookupNoMatches: 'No matches.', lookupTry: 'Try', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 533e68cd179..88ba5809cca 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1820,6 +1820,9 @@ export const ja = defineLocale({ voiceDictation: '音声口述', speakReplies: '返信を読み上げる', stopSpeakingReplies: '返信の読み上げを停止', + wakeWordListening: phrase => `ウェイクワード:「${phrase}」— 待機中`, + wakeWordOff: phrase => `ウェイクワード:「${phrase}」— オフ`, + wakeWordPausedVoice: phrase => `ウェイクワード:「${phrase}」— 音声チャット中は一時停止`, lookupLoading: '検索中…', lookupNoMatches: '一致なし。', lookupTry: '試す', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index b3f2a84ef08..cbcf9bd1579 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1647,6 +1647,9 @@ export interface Translations { voiceDictation: string speakReplies: string stopSpeakingReplies: string + wakeWordListening: (phrase: string) => string + wakeWordOff: (phrase: string) => string + wakeWordPausedVoice: (phrase: string) => string lookupLoading: string lookupNoMatches: string lookupTry: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index bc8b5f83d51..b7ff39699d8 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1763,6 +1763,9 @@ export const zhHant = defineLocale({ voiceDictation: '語音聽寫', speakReplies: '朗讀回覆', stopSpeakingReplies: '停止朗讀回覆', + wakeWordListening: phrase => `喚醒詞:「${phrase}」— 正在聆聽`, + wakeWordOff: phrase => `喚醒詞:「${phrase}」— 已關閉`, + wakeWordPausedVoice: phrase => `喚醒詞:「${phrase}」— 語音對話期間暫停`, lookupLoading: '查詢中…', lookupNoMatches: '沒有相符項目。', lookupTry: '試試', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index f96aa1592cb..71b55234df6 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2156,6 +2156,9 @@ export const zh: Translations = { voiceDictation: '语音听写', speakReplies: '朗读回复', stopSpeakingReplies: '停止朗读回复', + wakeWordListening: phrase => `唤醒词:"${phrase}" — 正在监听`, + wakeWordOff: phrase => `唤醒词:"${phrase}" — 已关闭`, + wakeWordPausedVoice: phrase => `唤醒词:"${phrase}" — 语音对话期间暂停`, lookupLoading: '查找中…', lookupNoMatches: '没有匹配项。', lookupTry: '试试', diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index 35e6a92237d..bd5fc978bcf 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -37,6 +37,8 @@ import { IconCpu as Cpu, IconCreditCard as CreditCard, IconDownload as Download, + IconEar as Ear, + IconEarOff as EarOff, IconEgg as Egg, IconExternalLink as ExternalLink, IconEye as Eye, @@ -160,6 +162,8 @@ export { Cpu, CreditCard, Download, + Ear, + EarOff, Egg, ExternalLink, Eye, diff --git a/apps/desktop/src/lib/voice-playback.ts b/apps/desktop/src/lib/voice-playback.ts index a4094893162..2a15cd3d26b 100644 --- a/apps/desktop/src/lib/voice-playback.ts +++ b/apps/desktop/src/lib/voice-playback.ts @@ -20,6 +20,36 @@ let currentAudio: HTMLAudioElement | null = null let currentStop: (() => void) | null = null let sequence = 0 +// A shared, lazily-created AudioContext used only to nudge the browser's +// autoplay state out of "suspended". A wake-word-started voice turn has no +// preceding user gesture, so the first HTMLAudioElement.play() can be rejected +// with NotAllowedError. resume()-ing a context is the documented way to recover +// once the app is allowed to make sound; on Electron chat windows the +// no-user-gesture-required policy means this is already unlocked, so this is a +// cheap no-op fallback for other surfaces. +let unlockCtx: AudioContext | null = null + +async function unlockAutoplay(): Promise { + if (typeof window === 'undefined') { + return + } + + const Ctor = + window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext + + if (!Ctor) { + return + } + + if (!unlockCtx) { + unlockCtx = new Ctor() + } + + if (unlockCtx.state === 'suspended') { + await unlockCtx.resume() + } +} + function currentState( status: VoicePlaybackState['status'], options?: VoicePlaybackOptions, @@ -243,6 +273,14 @@ function openSpeechStream(wsUrl: string, options: VoicePlaybackOptions): SpeechS if (frame.type === 'start') { streamRate = frame.sample_rate || 24_000 context = new AudioContext() + // Autoplay policy can hand back a suspended context when playback wasn't + // started by a user gesture (e.g. a wake-word-started voice turn). Resume + // it so the first reply is audible instead of silently buffering. Electron + // chat windows also set autoplayPolicy: no-user-gesture-required, but the + // dashboard-embedded surface relies on this resume. + if (context.state === 'suspended') { + void context.resume().catch(() => undefined) + } nextStartAt = 0 } else if (frame.type === 'end') { finishWhenDrained() @@ -371,7 +409,19 @@ async function playSpeechDataUrl( audio.addEventListener('error', onError, { once: true }) audio.addEventListener('timeupdate', armStall) armStall() - void audio.play().catch(onError) + // A wake-word-started turn has no user gesture, so the autoplay policy can + // reject the first play() with NotAllowedError. Electron chat windows set + // autoplayPolicy: no-user-gesture-required to prevent this, but retry once + // after resuming a shared AudioContext as a fallback for other surfaces + // (dashboard-embedded) so the first reply isn't silently dropped. + void audio.play().catch(async () => { + try { + await unlockAutoplay() + await audio.play() + } catch { + onError() + } + }) }) if (!isCurrent()) { diff --git a/apps/desktop/src/lib/voice-stop-word.test.ts b/apps/desktop/src/lib/voice-stop-word.test.ts new file mode 100644 index 00000000000..d1aa53b4fea --- /dev/null +++ b/apps/desktop/src/lib/voice-stop-word.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' + +import { isVoiceStopCommand } from './voice-stop-word' + +describe('isVoiceStopCommand', () => { + it('matches bare stop commands', () => { + for (const phrase of ['stop', 'Stop', 'STOP', 'stop.', 'stop!', ' stop ', 'stop…']) { + expect(isVoiceStopCommand(phrase)).toBe(true) + } + }) + + it('matches multi-word stop phrases', () => { + for (const phrase of [ + 'stop listening', + 'stop it', + 'please stop', + 'stop please', + "that's all", + 'that is all', + 'never mind', + 'nevermind', + 'end conversation', + 'end the conversation', + 'goodbye', + 'bye', + 'cancel' + ]) { + expect(isVoiceStopCommand(phrase)).toBe(true) + } + }) + + it('matches stop commands addressed to Hermes', () => { + for (const phrase of ['hermes stop', 'hey hermes stop', 'hey hermes, stop', 'ok stop', 'okay stop']) { + expect(isVoiceStopCommand(phrase)).toBe(true) + } + }) + + it('does NOT match substantive requests that merely contain "stop"', () => { + for (const phrase of [ + 'stop the docker container', + 'how do I stop a running process', + 'can you stop the deployment', + 'stop the music and play something else', + "don't stop now", + 'the bus stop is closed' + ]) { + expect(isVoiceStopCommand(phrase)).toBe(false) + } + }) + + it('does not match bare address words or empty input', () => { + for (const phrase of ['', ' ', 'hermes', 'hey hermes', 'ok', 'okay', 'hey']) { + expect(isVoiceStopCommand(phrase)).toBe(false) + } + }) + + it('does not match unrelated short utterances', () => { + for (const phrase of ['hello', 'yes', 'what time is it', 'thanks']) { + expect(isVoiceStopCommand(phrase)).toBe(false) + } + }) +}) diff --git a/apps/desktop/src/lib/voice-stop-word.ts b/apps/desktop/src/lib/voice-stop-word.ts new file mode 100644 index 00000000000..e59c9da83af --- /dev/null +++ b/apps/desktop/src/lib/voice-stop-word.ts @@ -0,0 +1,94 @@ +// Spoken stop-word detection for the voice conversation loop. +// +// When someone is in a hands-free "Hey Hermes" voice chat, the natural way to +// end it is to SAY "stop" — not reach for the mouse. Without this, a spoken +// "stop" is just transcribed and sent to the agent as a normal turn, so the +// conversation never ends (the reported bug). This matcher recognises a short +// utterance whose entire content is a stop command and ends the conversation +// instead of submitting it. +// +// Deliberately conservative: it only fires when the WHOLE utterance is a stop +// phrase (optionally addressed to Hermes), so a real turn that merely contains +// the word "stop" — e.g. "stop the docker container" or "how do I stop a +// running process" — is never swallowed. + +// Canonical stop commands. Kept short and unambiguous; each must be the entire +// spoken utterance to match. +const STOP_PHRASES: readonly string[] = [ + 'stop', + 'stop listening', + 'stop it', + 'stop please', + 'please stop', + 'stop stop', + 'that is all', + "that's all", + 'never mind', + 'nevermind', + 'end conversation', + 'end the conversation', + 'goodbye', + 'good bye', + 'bye', + 'cancel' +] + +// Optional address prefixes so "hermes stop" / "ok stop" / "hey hermes, stop" +// still count. Stripped before matching the core phrase. +const ADDRESS_PREFIXES: readonly string[] = ['hey hermes', 'hey hermes,', 'hermes', 'hermes,', 'ok', 'okay', 'hey'] + +// Normalise: lowercase, strip surrounding punctuation/whitespace, collapse +// internal runs of spaces. Trailing punctuation (".", "!", "…") is common in +// STT output and must not defeat the match. +function normalize(text: string): string { + return text + .toLowerCase() + .replace(/[.,!?;:…]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +function stripAddress(text: string): string { + for (const prefix of ADDRESS_PREFIXES) { + if (text === prefix) { + // Bare address ("hermes") is not a stop command on its own. + continue + } + + if (text.startsWith(`${prefix} `)) { + return text.slice(prefix.length + 1).trim() + } + } + + return text +} + +/** + * True when the entire spoken utterance is a stop command (optionally addressed + * to Hermes). Returns false for anything that merely contains "stop" as part of + * a longer, substantive request. + */ +export function isVoiceStopCommand(transcript: string): boolean { + if (!transcript) { + return false + } + + const normalized = normalize(transcript) + + if (!normalized) { + return false + } + + // Match with the address prefix stripped, and also as-is (so a bare "stop" + // with no prefix still matches, and "please stop" — where "please" isn't a + // prefix — matches directly). + const candidates = new Set([normalized, stripAddress(normalized)]) + + for (const candidate of candidates) { + if (STOP_PHRASES.includes(candidate)) { + return true + } + } + + return false +} diff --git a/apps/desktop/src/lib/wake-sound.test.ts b/apps/desktop/src/lib/wake-sound.test.ts new file mode 100644 index 00000000000..354eff8e44f --- /dev/null +++ b/apps/desktop/src/lib/wake-sound.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $hapticsMuted } from '@/store/haptics' + +import { playWakeSound } from './wake-sound' + +// Minimal WebAudio doubles: enough to record that playWakeSound wired +// oscillators to the destination when it should, and stayed silent when muted. +class FakeParam { + setValueAtTime = vi.fn() + exponentialRampToValueAtTime = vi.fn() +} + +class FakeOscillator { + type = 'sine' + frequency = new FakeParam() + connect = vi.fn() + start = vi.fn() + stop = vi.fn() +} + +class FakeGain { + gain = new FakeParam() + connect = vi.fn() +} + +let oscillators: FakeOscillator[] + +class FakeAudioContext { + state = 'running' + currentTime = 0 + destination = {} + resume = vi.fn().mockResolvedValue(undefined) + + createOscillator() { + const osc = new FakeOscillator() + oscillators.push(osc) + + return osc + } + + createGain() { + return new FakeGain() + } +} + +describe('playWakeSound', () => { + beforeEach(() => { + oscillators = [] + $hapticsMuted.set(false) + vi.stubGlobal('AudioContext', FakeAudioContext) + }) + + afterEach(() => { + vi.unstubAllGlobals() + $hapticsMuted.set(false) + }) + + it('plays a two-note rising chime when sound is on', () => { + playWakeSound() + + // G5 then C6 — two enveloped oscillators, both routed onward. + expect(oscillators).toHaveLength(2) + expect(oscillators[0].frequency.setValueAtTime).toHaveBeenCalledWith(783.99, expect.any(Number)) + expect(oscillators[1].frequency.setValueAtTime).toHaveBeenCalledWith(1046.5, expect.any(Number)) + for (const osc of oscillators) { + expect(osc.start).toHaveBeenCalled() + expect(osc.stop).toHaveBeenCalled() + } + }) + + it('stays silent when the shared sound-mute toggle is on', () => { + $hapticsMuted.set(true) + playWakeSound() + expect(oscillators).toHaveLength(0) + }) + + it('never throws when WebAudio is unavailable', () => { + vi.stubGlobal('AudioContext', undefined) + expect(() => playWakeSound()).not.toThrow() + }) +}) diff --git a/apps/desktop/src/lib/wake-sound.ts b/apps/desktop/src/lib/wake-sound.ts new file mode 100644 index 00000000000..3eb9f1c0d86 --- /dev/null +++ b/apps/desktop/src/lib/wake-sound.ts @@ -0,0 +1,88 @@ +// Wake-word activation chime. A short, bright, rising two-note "ding" that +// plays the moment "Hey Hermes" is detected, so it's obvious the wake +// registered before voice capture starts. Deliberately distinct from the +// turn-end completion cue (completion-sound.ts): this one RISES (open/ready), +// the completion cue settles (done). Reuses the same lightweight WebAudio +// synthesis approach — no asset file to ship. + +import { $hapticsMuted } from '@/store/haptics' + +let ctx: AudioContext | null = null + +function getCtx(): AudioContext | null { + if (typeof window === 'undefined') { + return null + } + + try { + if (!ctx) { + const Ctor = + window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext + + if (!Ctor) { + return null + } + + ctx = new Ctor() + } + + // Autoplay policies can leave the context suspended until a gesture; a + // resume() here recovers it once the user has interacted with the window. + if (ctx.state === 'suspended') { + void ctx.resume().catch(() => undefined) + } + + return ctx + } catch { + return null + } +} + +// One enveloped sine voice → master. Linear-ish attack into an exponential +// decay keeps the tail smooth and avoids the click you get ramping to zero. +function ding(ac: AudioContext, master: GainNode, t0: number, freq: number, dur: number, gain: number) { + const osc = ac.createOscillator() + const env = ac.createGain() + const end = t0 + dur + + osc.type = 'sine' + osc.frequency.setValueAtTime(freq, t0) + + env.gain.setValueAtTime(0.0001, t0) + env.gain.exponentialRampToValueAtTime(Math.max(gain, 0.0002), t0 + 0.008) + env.gain.exponentialRampToValueAtTime(0.0001, end) + + osc.connect(env) + env.connect(master) + osc.start(t0) + osc.stop(end + 0.02) +} + +// Play the wake chime. Honours the shared sound-mute toggle ($hapticsMuted), +// the same gate the completion cue uses, so muting turn-end sounds also +// silences this. Best-effort: never throws into the wake-event handler. +export function playWakeSound(): void { + if ($hapticsMuted.get()) { + return + } + + const ac = getCtx() + + if (!ac) { + return + } + + try { + const master = ac.createGain() + master.gain.setValueAtTime(0.5, ac.currentTime) + master.connect(ac.destination) + + const t0 = ac.currentTime + 0.01 + // Rising perfect-fourth: G5 -> C6. Short and bright — "listening". + ding(ac, master, t0, 783.99, 0.12, 0.06) + ding(ac, master, t0 + 0.1, 1046.5, 0.28, 0.07) + } catch { + // WebAudio can throw if the context died mid-call; a missed chime must + // never break wake handling. + } +} diff --git a/apps/desktop/src/store/composer.test.ts b/apps/desktop/src/store/composer.test.ts index 1b3a174ba9f..d5445ea64c1 100644 --- a/apps/desktop/src/store/composer.test.ts +++ b/apps/desktop/src/store/composer.test.ts @@ -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 & Pick): ComposerAttachment { return { kind: 'file', label: 'doc.pdf', ...overrides } } diff --git a/apps/desktop/src/store/composer.ts b/apps/desktop/src/store/composer.ts index b984fe8bfc3..7f32348cd36 100644 --- a/apps/desktop/src/store/composer.ts +++ b/apps/desktop/src/store/composer.ts @@ -21,6 +21,24 @@ export const $composerDraft = atom('') export const $composerAttachments = atom([]) export const $composerTerminalSelections = atom>({}) +// 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 diff --git a/apps/desktop/src/store/wake-word.test.ts b/apps/desktop/src/store/wake-word.test.ts new file mode 100644 index 00000000000..f69321e3f5b --- /dev/null +++ b/apps/desktop/src/store/wake-word.test.ts @@ -0,0 +1,373 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + $wakeWord, + applyWakeStartResult, + applyWakeStatus, + applyWakeStopResult, + armWakeWord, + resetWakeWordState, + resumeWakeAfterVoice, + toggleWakeWord, + type WakeRequester +} from './wake-word' + +const requester = (impl: (method: string, params?: Record) => unknown) => + vi.fn(async (method: string, params: Record = {}) => impl(method, params)) as unknown as WakeRequester + +beforeEach(() => { + resetWakeWordState() +}) + +describe('applyWakeStatus', () => { + it('syncs availability, listening and phrase from wake.status', () => { + applyWakeStatus({ + available: true, + hint: '', + listening: true, + owned_by_caller: true, + owner_surface: 'gui', + phrase: 'hey hermes', + provider: 'openwakeword' + }) + + expect($wakeWord.get()).toMatchObject({ + available: true, + listening: true, + notice: '', + phrase: 'hey hermes' + }) + }) + + it('tracks unavailability and carries the hint for the tooltip', () => { + applyWakeStatus({ available: false, hint: 'pip install openwakeword', listening: false, phrase: 'hey hermes' }) + + const state = $wakeWord.get() + expect(state.available).toBe(false) + expect(state.listening).toBe(false) + expect(state.notice).toBe('pip install openwakeword') + }) + + it('keeps the dead-mic hint visible while listening (audio_silent)', () => { + applyWakeStatus({ + audio_silent: true, + available: true, + hint: 'Microphone delivers only silence — grant mic access', + listening: true, + phrase: 'hey hermes' + }) + + const state = $wakeWord.get() + expect(state.listening).toBe(true) + expect(state.notice).toBe('Microphone delivers only silence — grant mic access') + }) +}) + +describe('toggleWakeWord', () => { + it('starts via wake.start with surface gui when off, and flips to listening', async () => { + applyWakeStatus({ available: true, listening: false, phrase: 'hey hermes' }) + + const request = requester(method => { + expect(method).toBe('wake.start') + + return { owner_surface: 'gui', phrase: 'hey hermes', provider: 'porcupine', started: true } + }) + + await toggleWakeWord(request) + + expect(request).toHaveBeenCalledWith('wake.start', { persist: true, surface: 'gui' }) + expect($wakeWord.get()).toMatchObject({ listening: true, notice: '', pending: false }) + }) + + it('stops via wake.stop when listening', async () => { + applyWakeStatus({ available: true, listening: true, phrase: 'hey hermes' }) + + const request = requester(method => { + expect(method).toBe('wake.stop') + + return { reason: null, stopped: true } + }) + + await toggleWakeWord(request) + + expect(request).toHaveBeenCalledWith('wake.stop', { persist: true }) + expect($wakeWord.get()).toMatchObject({ listening: false, notice: '', pending: false }) + }) + + it('does NOT flip state on {started:false, reason} and surfaces the reason', async () => { + applyWakeStatus({ available: true, listening: false, phrase: 'hey hermes' }) + + await toggleWakeWord(requester(() => ({ owner_surface: 'tui', reason: 'owned', started: false }))) + + const state = $wakeWord.get() + expect(state.listening).toBe(false) + expect(state.notice).toBe('another surface owns the listener') + expect(state.available).toBe(true) + }) + + it('marks the feature unavailable when start refuses with reason unavailable', async () => { + applyWakeStatus({ available: true, listening: false, phrase: 'hey hermes' }) + + await toggleWakeWord( + requester(() => ({ hint: 'Set PORCUPINE_ACCESS_KEY', reason: 'unavailable', started: false })) + ) + + const state = $wakeWord.get() + expect(state.available).toBe(false) + expect(state.listening).toBe(false) + expect(state.notice).toBe('Set PORCUPINE_ACCESS_KEY') + }) + + it('stays off and keeps the error as the notice when the RPC throws', async () => { + applyWakeStatus({ available: true, listening: false, phrase: 'hey hermes' }) + + await toggleWakeWord( + requester(() => { + throw new Error('Hermes gateway unavailable') + }) + ) + + expect($wakeWord.get()).toMatchObject({ + listening: false, + notice: 'Hermes gateway unavailable', + pending: false + }) + }) + + it('ignores clicks while a toggle is already in flight', async () => { + applyWakeStatus({ available: true, listening: false, phrase: 'hey hermes' }) + + let resolveStart: (value: unknown) => void = () => undefined + + const request = vi.fn( + async () => + new Promise(resolve => { + resolveStart = resolve + }) + ) as unknown as WakeRequester + + const first = toggleWakeWord(request) + await toggleWakeWord(request) + + expect(request).toHaveBeenCalledTimes(1) + + resolveStart({ phrase: 'hey hermes', started: true }) + await first + + expect($wakeWord.get().listening).toBe(true) + }) +}) + +describe('armWakeWord (gateway-ready auto-arm)', () => { + it('queries wake.status then arms and syncs the store', async () => { + const calls: string[] = [] + + const request = requester(method => { + calls.push(method) + + if (method === 'wake.status') { + return { available: true, listening: false, phrase: 'hey hermes', provider: 'porcupine' } + } + + return { phrase: 'hey hermes', started: true } + }) + + await armWakeWord(request) + + expect(calls).toEqual(['wake.status', 'wake.start']) + expect($wakeWord.get()).toMatchObject({ available: true, listening: true, phrase: 'hey hermes' }) + }) + + it('does not attempt to arm when the wake word is unavailable', async () => { + const calls: string[] = [] + + const request = requester(method => { + calls.push(method) + + return { available: false, hint: 'no mic', listening: false, phrase: 'hey hermes' } + }) + + await armWakeWord(request) + + expect(calls).toEqual(['wake.status']) + expect($wakeWord.get()).toMatchObject({ available: false, listening: false, notice: 'no mic' }) + }) + + it('skips arming when this surface already listens (status sync only)', async () => { + const calls: string[] = [] + + const request = requester(method => { + calls.push(method) + + return { available: true, listening: true, owned_by_caller: true, phrase: 'hey hermes' } + }) + + await armWakeWord(request) + + expect(calls).toEqual(['wake.status']) + expect($wakeWord.get()).toMatchObject({ available: true, listening: true }) + }) + + it('keeps the default hidden state when the backend lacks wake.* methods', async () => { + await armWakeWord( + requester(() => { + throw new Error('Unknown method: wake.status') + }) + ) + + expect($wakeWord.get()).toMatchObject({ available: false, listening: false }) + }) + + it('keeps the toggle off when auto-arm is refused (e.g. TUI owns the mic)', async () => { + const request = requester(method => + method === 'wake.status' + ? { available: true, listening: false, owner_surface: 'tui', phrase: 'hey hermes' } + : { owner_surface: 'tui', reason: 'owned', started: false } + ) + + await armWakeWord(request) + + const state = $wakeWord.get() + expect(state.available).toBe(true) + expect(state.listening).toBe(false) + expect(state.notice).toBe('another surface owns the listener') + }) +}) + +describe('applyWakeStopResult', () => { + it('lands on off even when the backend says not_owner', () => { + applyWakeStatus({ available: true, listening: true, phrase: 'hey hermes' }) + + applyWakeStopResult({ reason: 'not_owner', stopped: false }) + + const state = $wakeWord.get() + expect(state.listening).toBe(false) + expect(state.notice).toBe('another surface owns the listener') + }) +}) + +describe('applyWakeStartResult', () => { + it('adopts the backend phrase when the listener starts', () => { + applyWakeStartResult({ phrase: 'computer', provider: 'porcupine', started: true }) + + expect($wakeWord.get()).toMatchObject({ available: true, listening: true, phrase: 'computer' }) + }) +}) + +describe('resumeWakeAfterVoice (post-voice reconcile)', () => { + it('re-arms when config says enabled but the listener is down', async () => { + const calls: string[] = [] + + const request = requester(method => { + calls.push(method) + + if (method === 'wake.resume') { + return { reason: 'not_owner', resumed: false } + } + + if (method === 'wake.status') { + return { available: true, enabled: true, listening: false, phrase: 'hey hermes' } + } + + return { phrase: 'hey hermes', started: true } + }) + + await resumeWakeAfterVoice(request) + + expect(calls).toEqual(['wake.resume', 'wake.status', 'wake.start']) + expect($wakeWord.get()).toMatchObject({ listening: true }) + }) + + it('re-arm start never passes persist (passive path must not write config)', async () => { + const startParams: Array | undefined> = [] + + const request = vi.fn(async (method: string, params?: Record) => { + if (method === 'wake.resume') { + return { resumed: false } + } + + if (method === 'wake.status') { + return { available: true, enabled: true, listening: false } + } + + startParams.push(params) + + return { started: true } + }) as unknown as WakeRequester + + await resumeWakeAfterVoice(request) + + expect(startParams).toEqual([{ surface: 'gui' }]) + }) + + it('stops after the resume alone brings the listener back', async () => { + const calls: string[] = [] + + const request = requester(method => { + calls.push(method) + + if (method === 'wake.resume') { + return { resumed: true } + } + + return { available: true, enabled: true, listening: true, owned_by_caller: true } + }) + + await resumeWakeAfterVoice(request) + + expect(calls).toEqual(['wake.resume', 'wake.status']) + expect($wakeWord.get()).toMatchObject({ listening: true }) + }) + + it('leaves the listener off when config says disabled', async () => { + const calls: string[] = [] + + const request = requester(method => { + calls.push(method) + + if (method === 'wake.resume') { + return { resumed: false } + } + + return { available: true, enabled: false, listening: false } + }) + + await resumeWakeAfterVoice(request) + + expect(calls).toEqual(['wake.resume', 'wake.status']) + expect($wakeWord.get().listening).toBe(false) + }) + + it('yields when another surface owns the mic lease', async () => { + const calls: string[] = [] + + const request = requester(method => { + calls.push(method) + + if (method === 'wake.resume') { + return { resumed: false } + } + + if (method === 'wake.status') { + return { available: true, enabled: true, listening: false, owner_surface: 'tui' } + } + + return { owner_surface: 'tui', reason: 'owned', started: false } + }) + + await resumeWakeAfterVoice(request) + + expect(calls).toEqual(['wake.resume', 'wake.status', 'wake.start']) + expect($wakeWord.get().listening).toBe(false) + }) + + it('is a no-op against older backends without wake.* methods', async () => { + const request = requester(() => { + throw new Error('Unknown method: wake.resume') + }) + + await resumeWakeAfterVoice(request) + + expect($wakeWord.get()).toMatchObject({ available: false, listening: false }) + }) +}) diff --git a/apps/desktop/src/store/wake-word.ts b/apps/desktop/src/store/wake-word.ts new file mode 100644 index 00000000000..fd120c3cb6a --- /dev/null +++ b/apps/desktop/src/store/wake-word.ts @@ -0,0 +1,290 @@ +import { atom } from 'nanostores' + +import { $gateway } from '@/store/gateway' + +// "Hey Hermes" wake-word listener state for the composer toggle. The gateway is +// the single source of truth (the listener lives in the backend and is shared +// with the TUI under a single-owner mic lease); this atom is the renderer's +// cache of that truth, refreshed from every wake.* RPC response we see. + +export interface WakeWordState { + /** Wake word can run at all (deps + mic + key). With `enabled` false too, hides the toggle. */ + available: boolean + /** Config truth (wake_word.enabled) — keeps the ear mounted through transient refusals. */ + enabled: boolean + /** The listener is armed and owned by this surface. */ + listening: boolean + /** Last failure reason/hint (start refused, unavailable, …) for the tooltip. */ + notice: string + /** A toggle RPC is in flight — guards double-clicks. */ + pending: boolean + /** Human-facing wake phrase, e.g. "hey hermes". */ + phrase: string +} + +const INITIAL_WAKE_WORD_STATE: WakeWordState = { + available: false, + enabled: false, + listening: false, + notice: '', + pending: false, + phrase: '' +} + +export const $wakeWord = atom(INITIAL_WAKE_WORD_STATE) + +export interface WakeStatusResponse { + /** Armed but the mic delivers only silence (macOS backend-permission gap). */ + audio_silent?: boolean + available?: boolean + /** Config truth (wake_word.enabled) — drives post-voice re-arm. */ + enabled?: boolean + hint?: string + listening?: boolean + owned_by_caller?: boolean + owner_surface?: string | null + phrase?: string + provider?: string +} + +export interface WakeStartResponse { + enabled_persisted?: boolean + hint?: string + owner_surface?: string | null + phrase?: string + provider?: string + reason?: string + started?: boolean +} + +export interface WakeStopResponse { + disabled_persisted?: boolean + reason?: string | null + stopped?: boolean +} + +/** Minimal requester shape — satisfied by both `useGatewayRequest`'s + * `requestGateway` and the `$gateway` instance wrapper below. */ +export type WakeRequester = (method: string, params?: Record) => Promise + +// First-use wake.start lazy-installs the detection engine (onnxruntime is a +// large wheel) — that legitimately takes minutes. The default 30s WS timeout +// fired mid-install, leaving a dead button that went blue on its own later. +const WAKE_START_TIMEOUT_MS = 180_000 + +const gatewayRequester: WakeRequester = async (method: string, params: Record = {}) => { + const gateway = $gateway.get() + + if (!gateway) { + throw new Error('Hermes gateway unavailable') + } + + return method === 'wake.start' + ? gateway.request(method, params, WAKE_START_TIMEOUT_MS) + : gateway.request(method, params) +} + +// Friendly text for the gateway's wake refusal codes (mirrors the TUI's +// START_REASON_TEXT). Unknown codes fall through raw so new server-side +// codes stay visible instead of silently disappearing. +const REASON_TEXT: Record = { + disabled: 'click to enable', + disabled_for_surface: 'scoped to another surface (config wake_word.surface)', + not_owner: 'another surface owns the listener', + owned: 'another surface owns the listener', + unavailable: 'unavailable' +} + +const noticeFrom = (result: { hint?: string; reason?: string | null } | null | undefined): string => { + const hint = result?.hint?.trim() + + if (hint) { + return hint + } + + const reason = result?.reason?.trim() + + return reason ? (REASON_TEXT[reason] ?? reason) : '' +} + +/** Sync the atom from a `wake.status` payload (mount / gateway-ready). */ +export function applyWakeStatus(status: WakeStatusResponse | null | undefined): void { + const current = $wakeWord.get() + const listening = Boolean(status?.listening) + // "Armed but deaf" (macOS backend without mic permission) keeps its hint + // visible in the tooltip even though the toggle shows listening. + const silent = Boolean(status?.audio_silent) + + $wakeWord.set({ + ...current, + available: Boolean(status?.available), + enabled: Boolean(status?.enabled), + listening, + notice: listening && !silent ? '' : noticeFrom(status), + phrase: status?.phrase?.trim() || current.phrase + }) +} + +/** Sync the atom from a `wake.start` response. A `{started:false, reason}` + * refusal keeps the toggle off and surfaces the reason as the tooltip. */ +export function applyWakeStartResult(result: WakeStartResponse | null | undefined): void { + const current = $wakeWord.get() + + if (result?.started) { + $wakeWord.set({ + ...current, + available: true, + enabled: true, + listening: true, + notice: '', + pending: false, + phrase: result.phrase?.trim() || current.phrase + }) + + return + } + + $wakeWord.set({ + ...current, + // The backend probes requirements on start; an explicit "unavailable" + // refusal means the feature can't run here right now. Keep `enabled` + // (config truth) as-is so the button stays mounted through transient + // refusals instead of vanishing mid-session. + available: result?.reason === 'unavailable' ? false : current.available, + listening: false, + notice: noticeFrom(result), + pending: false + }) +} + +/** Sync the atom from a `wake.stop` response. `{stopped:false, reason:'not_owner'}` + * still means WE are not listening, so the toggle lands on off either way. */ +export function applyWakeStopResult(result: WakeStopResponse | null | undefined): void { + const current = $wakeWord.get() + + $wakeWord.set({ + ...current, + enabled: result?.disabled_persisted ? false : current.enabled, + listening: false, + notice: result?.stopped ? '' : noticeFrom(result), + pending: false + }) +} + +/** + * Gateway-ready sync + auto-arm (wiring.tsx). Queries `wake.status` first so + * the button knows availability/phrase even when arming is refused, then arms + * the listener for this surface exactly like the historical auto-arm did. + * Best-effort: a gateway without the wake.* methods leaves the atom at its + * hidden default. + */ +export async function armWakeWord(request: WakeRequester = gatewayRequester): Promise { + try { + const status = await request('wake.status', {}) + applyWakeStatus(status) + + if (!status?.available || status.listening) { + return + } + + const result = await request('wake.start', { surface: 'gui' }) + applyWakeStartResult(result) + } catch { + // Older backends / transient failures — keep whatever we last knew. + } +} + +/** The composer button's click handler: stop when listening, start otherwise. */ +export async function toggleWakeWord(request: WakeRequester = gatewayRequester): Promise { + const state = $wakeWord.get() + + if (state.pending) { + return + } + + $wakeWord.set({ + ...state, + // First arm may lazy-install the detection engine — say so instead of + // freezing a silent disabled button for the duration. + notice: state.listening ? '' : 'arming — first use may take a minute while the engine installs', + pending: true + }) + + try { + if (state.listening) { + applyWakeStopResult(await request('wake.stop', { persist: true })) + } else { + // persist: true — a deliberate click is consent, so the backend flips + // wake_word.enabled in config.yaml (on/off) and the choice sticks for + // future sessions. Auto-arm (armWakeWord) never passes it. + applyWakeStartResult(await request('wake.start', { persist: true, surface: 'gui' })) + } + } catch (error) { + const current = $wakeWord.get() + + $wakeWord.set({ + ...current, + notice: error instanceof Error ? error.message : String(error), + pending: false + }) + } +} + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + +/** + * Post-voice-turn reconcile: the wake word is a persistent setting, so ending a + * voice conversation must land the listener back where config says it belongs. + * `wake.resume` alone isn't enough — the mic can still be held by the just-torn + * -down WebRTC capture, and a fire-and-forget resume that loses that race left + * the ear silently off until the user re-toggled. Resume, then verify against + * `wake.status` (config `enabled` is the authority) and re-arm, with a couple + * of spaced retries to ride out mic-release latency. Never passes `persist` — + * this is a passive path and must not flip config. + */ +export async function resumeWakeAfterVoice(request: WakeRequester = gatewayRequester): Promise { + try { + await request('wake.resume', {}) + } catch { + // Older backend without wake.* — nothing to reconcile. + return + } + + for (let attempt = 0; attempt < 3; attempt++) { + try { + const status = await request('wake.status', {}) + applyWakeStatus(status) + + // Config says off (or the feature can't run) — off is the correct rest + // state. A user /wake off during the voice turn stays respected. + if (!status?.enabled || !status.available) { + return + } + + if (status.listening) { + return + } + + const started = await request('wake.start', { surface: 'gui' }) + applyWakeStartResult(started) + + if (started?.started) { + return + } + + // Another surface holds the mic lease — theirs to keep. + if (started?.reason === 'owned') { + return + } + } catch { + // Transient (mic still releasing) — fall through to the next attempt. + } + + await sleep(1500) + } +} + +/** Test-only reset. */ +export function resetWakeWordState(): void { + $wakeWord.set(INITIAL_WAKE_WORD_STATE) +} diff --git a/cli.py b/cli.py index 18dbe94e02d..696321daacc 100644 --- a/cli.py +++ b/cli.py @@ -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: @@ -4016,10 +4023,20 @@ def save_config_value(key_path: str, value: any) -> bool: Returns: True if successful, False otherwise """ - # Use the same precedence as load_cli_config: user config first, then project config - user_config_path = _hermes_home / 'config.yaml' - project_config_path = Path(__file__).parent / 'cli-config.yaml' - config_path = user_config_path if user_config_path.exists() else project_config_path + # Runtime persistence ALWAYS targets the user's HERMES_HOME config.yaml, + # creating it if needed. Resolve HERMES_HOME live (not the import-time + # _hermes_home constant) so profile switches and test isolation land right. + # + # We deliberately do NOT fall back to the repo's project cli-config.yaml: + # that file is a shipped default/template, and most config readers + # (load_config → get_hermes_home()/config.yaml, including + # load_wake_word_config) never read it. Writing a user setting there means + # the reader never sees it. This was the "wake-word ear reverts to disabled + # after restart" bug — the toggle's persist wrote to cli-config.yaml (which + # exists in the checkout) while startup read HERMES_HOME/config.yaml, so the + # setting silently vanished every restart on any install whose + # HERMES_HOME/config.yaml didn't exist yet. + config_path = get_hermes_home() / 'config.yaml' try: # Ensure parent directory exists (for ~/.hermes/config.yaml on first use) @@ -9947,6 +9964,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: @@ -12262,6 +12281,230 @@ 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 + + if announce and not reqs.get("deps_available", True): + # Fresh install: the engine constructor lazy-installs its deps + # (onnxruntime is a large wheel) — tell the user why this is slow. + _cprint(f"{_DIM}Installing wake word engine (first use — this may take a minute)...{_RST}") + + 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 + + # Multi-profile routing: the CLI is a single-profile process, so a + # phrase enrolled by ANOTHER profile can't be routed here — print the + # switch command and re-arm rather than answering as the wrong profile. + try: + from tools.wake_word import get_last_match + _match = get_last_match() + except Exception: + _match = None + if _match and _match[1]: + from tools.wake_word import _active_profile_name + if _match[1] != _active_profile_name(): + _cprint(f"\n{_DIM}Wake phrase for profile '{_match[1]}' — " + f"run: hermes -p {_match[1]}{_RST}") + self._wake_suspended = True # watchdog resumes the listener + return + + _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 ( + audio_is_silent, + 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 state == "LISTENING" and audio_is_silent(): + _cprint(f" {_ACCENT}⚠ Microphone delivers only silence — the listener can't hear anything.{_RST}") + _cprint(f" {_DIM}On macOS: System Settings > Privacy & Security > Microphone — allow your" + f" terminal/Hermes, then /wake off + /wake on.{_RST}") + 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: @@ -16435,7 +16678,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): # Start processing thread process_thread = threading.Thread(target=process_loop, daemon=True) process_thread.start() - + + # Wake word ("Hey Hermes") — start the always-on hotword listener if + # enabled. Off-thread so a first-run engine install never blocks the + # prompt; best-effort, so deps/mic/key gaps are surfaced, never fatal. + def _wake_startup(): + try: + self._maybe_start_wake_word() + except Exception as e: + logger.debug("wake-word startup skipped: %s", e) + threading.Thread(target=_wake_startup, daemon=True, name="wake-startup").start() + # Register atexit cleanup so resources are freed even on unexpected exit atexit.register(_run_cleanup) diff --git a/contributors/emails/omid3098@gmail.com b/contributors/emails/omid3098@gmail.com new file mode 100644 index 00000000000..d5e0895e274 --- /dev/null +++ b/contributors/emails/omid3098@gmail.com @@ -0,0 +1 @@ +omid3098 diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 1891ca268fd..f8c592522a3 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -3189,3 +3189,49 @@ 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. + + The toggle IS the config: an explicit on/off (or bare toggle) also + writes ``wake_word.enabled`` to config.yaml so the choice persists + across sessions. Startup auto-arm (_maybe_start_wake_word) only reads. + """ + from cli import _cprint + parts = command.strip().split(maxsplit=1) + subcommand = parts[1].lower().strip() if len(parts) > 1 else "" + + if subcommand == "on": + if self._start_wake_word_listener(announce=True): + self._persist_wake_word_enabled(True) + elif subcommand == "off": + self._stop_wake_word_listener(announce=True) + self._persist_wake_word_enabled(False) + elif subcommand in ("", "status"): + if subcommand == "": + # Bare /wake toggles. + if getattr(self, "_wake_word_active", False): + self._stop_wake_word_listener(announce=True) + self._persist_wake_word_enabled(False) + elif self._start_wake_word_listener(announce=True): + self._persist_wake_word_enabled(True) + else: + self._show_wake_word_status() + else: + _cprint(f"Unknown wake subcommand: {subcommand}") + _cprint("Usage: /wake [on|off|status]") + + def _persist_wake_word_enabled(self, enabled: bool): + """Save ``wake_word.enabled`` so the /wake toggle sticks for future sessions.""" + from cli import _cprint, _DIM, _RST, save_config_value + + try: + from tools.wake_word import load_wake_word_config + + if bool(load_wake_word_config().get("enabled")) == enabled: + return # already persisted — don't rewrite config or re-announce + except Exception: + pass + if save_config_value("wake_word.enabled", enabled): + _cprint(f"{_DIM}Wake word {'enabled' if enabled else 'disabled'} in config " + f"(wake_word.enabled: {str(enabled).lower()}).{_RST}") diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 2a5f82cb388..939c407370d 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -181,6 +181,9 @@ COMMAND_REGISTRY: list[CommandDef] = [ subcommands=("kaomoji", "emoji", "unicode", "ascii")), CommandDef("voice", "Toggle voice mode", "Configuration", args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")), + CommandDef("wake", "Toggle the 'Hey Hermes' wake word listener", "Configuration", + cli_only=True, args_hint="[on|off|status]", + subcommands=("on", "off", "status")), CommandDef("busy", "Control what Enter does while Hermes is working", "Configuration", cli_only=True, args_hint="[queue|steer|interrupt|status]", subcommands=("queue", "steer", "interrupt", "status")), diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 5e147004fb6..b6676337849 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2374,6 +2374,42 @@ DEFAULT_CONFIG = { # surrounding punctuation ignored. Set [] to disable. "stop_phrases": ["stop"], }, + + # "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) | "sherpa" (free, ANY phrase, no training) | "porcupine" (premium; needs PORCUPINE_ACCESS_KEY) + "phrase": "hey hermes", # for "sherpa" this IS the detected phrase (any text works); for other engines it's a cosmetic label — detection is keyed by the model/keyword below + "sensitivity": 0.6, # 0.0-1.0 detection threshold, consistent across engines (higher = stricter, fewer false triggers) + "confirmation_frames": 3, # openWakeWord only: consecutive over-threshold frames required to fire (higher = fewer false triggers on ambient speech, slightly more latency; 1 = old single-frame behavior) + "start_new_session": True, # start a fresh session on wake vs. continue the current one + "profile_routing": True, # sherpa only: also listen for every wake-enabled profile's phrase and route the wake to the matching profile + "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", + # "" (auto — tflite on macOS ARM64, onnx elsewhere) | "onnx" | "tflite". + # openWakeWord's onnx backend scores near-zero on macOS ARM64 + # (dscripka/openWakeWord#336), so auto avoids a listener that arms + # but never fires. Set explicitly only to override that choice. + "inference_framework": "", + }, + "sherpa": { + # Optional path to a sherpa-onnx KWS model directory. Empty = + # auto-download the small English zipformer model on first use. + "model_dir": "", + }, + "porcupine": { + # Built-in keyword ("jarvis", "computer", "bumblebee", ...) or a path + # to a custom .ppn from the Picovoice Console. + "keyword": "jarvis", + }, + }, "human_delay": { "mode": "off", @@ -4447,6 +4483,13 @@ OPTIONAL_ENV_VARS = { "password": True, "category": "tool", }, + "PORCUPINE_ACCESS_KEY": { + "description": "Picovoice access key for the Porcupine 'Hey Hermes' wake word engine (optional; openWakeWord is the free default)", + "prompt": "Picovoice access key", + "url": "https://console.picovoice.ai/", + "password": True, + "category": "tool", + }, "GITHUB_TOKEN": { "description": "GitHub token for Skills Hub (higher API rate limits, skill publish)", "prompt": "GitHub Token", diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 8a0cc48e179..8c81b21ba06 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -4454,10 +4454,15 @@ async def transcribe_audio_upload( pass if not result.get("success"): - raise HTTPException( - status_code=400, - detail=result.get("error") or "Transcription failed", - ) + err = result.get("error") or "Transcription failed" + # An empty transcript means no speech was detected — a normal outcome + # for VAD/continuous voice loops (e.g. a wake-word conversation + # re-listening on silence), not an error. Return an empty transcript so + # the client quietly re-listens instead of surfacing a "transcription + # failed" toast on every silent gap. + if "empty transcript" in err.lower(): + return {"ok": True, "transcript": "", "provider": result.get("provider")} + raise HTTPException(status_code=400, detail=err) return { "ok": True, diff --git a/pyproject.toml b/pyproject.toml index d623df04b9b..40dabe1987c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -180,6 +180,26 @@ voice = [ "sounddevice==0.5.5", "numpy==2.4.3", ] +# "Hey Hermes" wake word — on-device hotword detection. All engines are +# optional; openWakeWord (ONNX) is the free default, sherpa-onnx adds +# open-vocabulary phrases (any typed phrase, zero training), Porcupine is +# the premium alternative. Desktop installs ([--include-desktop]) eager-install +# [wake]+[voice] so the ear works instantly; CLI-only installs lazy-install on +# first /wake; mirrored in tools/lazy_deps.py. +wake = [ + "openwakeword==0.6.0", + "onnxruntime==1.27.0", + "sherpa-onnx==1.13.4", + "sentencepiece==0.2.2", + "pvporcupine==4.0.3", + "sounddevice==0.5.5", + "numpy==2.4.3", + # openWakeWord's onnx embedding model scores near-zero on macOS ARM64 + # (dscripka/openWakeWord#336), so the wake word runs on tflite there. + # Upstream declares tflite-runtime for Linux only; ai-edge-litert is the + # macOS equivalent, bridged in tools/wake_word.py. + "ai-edge-litert==2.1.6; platform_system == 'Darwin'", +] 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 diff --git a/scripts/install.ps1 b/scripts/install.ps1 index e02acfff881..aa434eed47f 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -2813,6 +2813,34 @@ function Try-RestoreElectronDist { return Restore-ElectronDist -InstallDir $InstallDir -Mirror $script:DesktopElectronFallbackMirror } +function Install-DesktopVoiceDeps { + # Desktop ships with working voice out of the box: eagerly install the + # wake-word + local-STT stacks ([wake] + [voice] extras) instead of + # leaving them to lazy first-use install. Policy change (Teknium, July + # 2026, #70509 testing): the first ear-click used to trigger a + # multi-minute onnxruntime pip install that froze the UI and blew RPC + # timeouts. Best-effort -- lazy install remains the fallback for anything + # this step fails to fetch. + if (-not $script:UvCmd) { Resolve-UvCmd } + if (-not $script:UvCmd) { + Write-Warn "uv unavailable -- voice/wake deps will lazy-install at first use instead" + return + } + $env:VIRTUAL_ENV = "$InstallDir\venv" + Write-Info "Installing voice + wake-word dependencies (onnxruntime, faster-whisper -- 1-3min)..." + Push-Location $InstallDir + try { + Invoke-NativeWithRelaxedErrorAction { & $UvCmd pip install -e ".[wake,voice]" } + if ($LASTEXITCODE -eq 0) { + Write-Success "Voice + wake-word dependencies installed" + } else { + Write-Warn "Voice/wake dependency install failed (exit $LASTEXITCODE) -- they will lazy-install at first use" + } + } finally { + Pop-Location + } +} + function Install-Desktop { # Build apps/desktop into a launchable Hermes.exe. Only called from # Stage-Desktop, which is itself only included in the manifest when @@ -3577,7 +3605,7 @@ function Stage-Repository { Install-Repository } function Stage-Venv { Resolve-UvCmd; Install-Venv } function Stage-Dependencies { Resolve-UvCmd; Install-Dependencies } function Stage-NodeDeps { Install-NodeDeps } -function Stage-Desktop { Install-Desktop } +function Stage-Desktop { Install-DesktopVoiceDeps; Install-Desktop } function Stage-Path { Set-PathVariable } function Stage-ConfigTemplates { Copy-ConfigTemplates } function Stage-PlatformSdks { Resolve-UvCmd; Install-PlatformSdks } diff --git a/scripts/install.sh b/scripts/install.sh index 53daa95ddc3..3303f7c78fb 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2817,6 +2817,37 @@ _restore_electron_dist_with_fallback() { # (electron-builder --dir) which emits an unpacked app for the current OS. Only invoked # via the 'desktop' stage / --include-desktop, which the Electron app's own # first-launch bootstrap never requests (it must not rebuild itself). +install_desktop_voice_deps() { + # Desktop ships with working voice out of the box: eagerly install the + # wake-word + local-STT stacks ([wake] + [voice] extras) instead of + # leaving them to lazy first-use install. Policy change (Teknium, July + # 2026, #70509 testing): the first ear-click used to trigger a + # multi-minute onnxruntime pip install that froze the UI and blew RPC + # timeouts. Lazy install remains the fallback for CLI-only installs and + # for anything this best-effort step fails to fetch. + local _prev_venv="${VIRTUAL_ENV:-}" + if [ "$USE_VENV" = true ]; then + export VIRTUAL_ENV="$INSTALL_DIR/venv" + fi + if [ -z "${UV_CMD:-}" ]; then + install_uv || true + fi + if [ -z "${UV_CMD:-}" ]; then + log_warn "uv unavailable — voice/wake deps will lazy-install at first use instead" + return 0 + fi + log_info "Installing voice + wake-word dependencies (onnxruntime, faster-whisper — 1-3min)..." + if (cd "$INSTALL_DIR" && $UV_CMD pip install -e ".[wake,voice]") ; then + log_success "Voice + wake-word dependencies installed" + else + log_warn "Voice/wake dependency install failed — they will lazy-install at first use" + fi + if [ "$USE_VENV" = true ] && [ -z "$_prev_venv" ]; then + unset VIRTUAL_ENV + fi + return 0 +} + install_desktop() { local desktop_dir="$INSTALL_DIR/apps/desktop" @@ -3124,6 +3155,7 @@ run_stage_body() { # isn't on PATH here. check_node re-adds it (or installs if missing) # so install_desktop can find npm instead of silently skipping. check_node + install_desktop_voice_deps install_desktop ;; complete) @@ -3210,6 +3242,7 @@ main() { maybe_start_gateway if [ "$INCLUDE_DESKTOP" = true ]; then + install_desktop_voice_deps install_desktop fi diff --git a/tests/cli/test_cli_save_config_value.py b/tests/cli/test_cli_save_config_value.py index a966217065b..e820920a2b3 100644 --- a/tests/cli/test_cli_save_config_value.py +++ b/tests/cli/test_cli_save_config_value.py @@ -1,8 +1,10 @@ """Tests for save_config_value() in cli.py — atomic write behavior.""" -import yaml +from pathlib import Path from unittest.mock import MagicMock +import yaml + import pytest @@ -19,6 +21,10 @@ class TestSaveConfigValueAtomic: "model": {"default": "test-model", "provider": "openrouter"}, "display": {"skin": "default"}, })) + # save_config_value resolves the target live via get_hermes_home(), so + # point HERMES_HOME at the temp dir (the _hermes_home import-time + # constant is no longer consulted). + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) monkeypatch.setattr("cli._hermes_home", hermes_home) return config_path @@ -144,3 +150,51 @@ class TestSaveConfigValueAtomic: assert result is False assert config_env.read_text() == original_content + + +class TestSaveConfigValueTargetsUserConfig: + """Regression: persisted runtime settings must land in HERMES_HOME/config.yaml + (which config readers actually read), never the repo's cli-config.yaml. + + This was the "wake-word ear reverts to disabled after restart" bug: on an + install whose HERMES_HOME/config.yaml did not exist yet, save_config_value + fell back to the checked-in cli-config.yaml. The toggle reported success, but + startup read HERMES_HOME/config.yaml and never saw the setting.""" + + def test_creates_user_config_when_absent(self, tmp_path, monkeypatch): + # Fresh HERMES_HOME with NO config.yaml (managed/desktop first launch). + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + from cli import save_config_value + + assert save_config_value("wake_word.enabled", True) is True + + config_path = hermes_home / "config.yaml" + assert config_path.exists(), "user config.yaml must be created, not skipped" + result = yaml.safe_load(config_path.read_text()) + assert result["wake_word"]["enabled"] is True + + def test_does_not_write_repo_cli_config(self, tmp_path, monkeypatch): + # Even when the repo's cli-config.yaml exists, the write goes to the + # user config, so a runtime setting is never buried in the shipped file. + import cli as cli_module + + repo_cli_config = Path(cli_module.__file__).parent / "cli-config.yaml" + before = repo_cli_config.read_text() if repo_cli_config.exists() else None + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + from cli import save_config_value + + save_config_value("wake_word.enabled", True) + + # The repo template is untouched… + after = repo_cli_config.read_text() if repo_cli_config.exists() else None + assert after == before + # …and the value landed in the user config. + result = yaml.safe_load((hermes_home / "config.yaml").read_text()) + assert result["wake_word"]["enabled"] is True diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 8676d87cdf2..5ac3ef247c5 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -1284,6 +1284,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 @@ -1314,6 +1315,264 @@ 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", + "disabled_persisted": False, + } + assert denied_voice_stop["result"] == { + "status": "busy", + "reason": "wake_owned", + } + + state["callback"]() + assert emitted == [( + "wake.detected", + "first-session", + {"phrase": "hey hermes", "profile": None, "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, + "disabled_persisted": False, + } + + 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", "profile": None, "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, + "disabled_persisted": False, + } + finally: + server._wake_owner_transport = None + server._wake_owner_surface = "" + + +def test_wake_toggle_persists_enabled_flag_only_on_explicit_gesture(monkeypatch): + """The ear toggle / /wake on|off write wake_word.enabled; auto-arm never does.""" + from tools import wake_word + + config = {"enabled": False, "phrase": "hey hermes", "surface": "auto", + "start_new_session": True} + persisted = [] + + def fake_persist(enabled): + persisted.append(enabled) + config["enabled"] = enabled + return True + + monkeypatch.setattr(server, "_persist_wake_enabled", fake_persist) + monkeypatch.setattr(wake_word, "load_wake_word_config", lambda: dict(config)) + monkeypatch.setattr(wake_word, "check_wake_word_requirements", lambda _cfg: { + "available": True, + "phrase": "hey hermes", + "provider": "test", + "hint": "", + }) + listener = {"owner": None} + monkeypatch.setattr( + wake_word, "start_listening", + lambda callback, *, owner, config: listener.update(owner=owner), + ) + monkeypatch.setattr( + wake_word, "stop_listening", + lambda *, owner: listener["owner"] is owner and not listener.update(owner=None), + ) + monkeypatch.setattr(wake_word, "owns_listener", lambda owner: listener["owner"] is owner) + + transport = types.SimpleNamespace(_closed=False) + server._wake_owner_transport = None + server._wake_owner_surface = "" + try: + # Passive auto-arm (no persist): refused, config untouched. + passive = server.dispatch({ + "id": "wake-passive", + "method": "wake.start", + "params": {"surface": "gui"}, + }, transport=transport) + assert passive["result"] == {"started": False, "reason": "disabled"} + assert persisted == [] + + # Explicit gesture: enables in config AND arms. + clicked = server.dispatch({ + "id": "wake-click", + "method": "wake.start", + "params": {"surface": "gui", "persist": True}, + }, transport=transport) + assert clicked["result"]["started"] is True + assert clicked["result"]["enabled_persisted"] is True + assert persisted == [True] + + # Explicit stop: disables in config. + stopped = server.dispatch({ + "id": "wake-click-off", + "method": "wake.stop", + "params": {"persist": True}, + }, transport=transport) + assert stopped["result"]["stopped"] is True + assert stopped["result"]["disabled_persisted"] is True + assert persisted == [True, False] + + # persist does NOT override an explicit surface scoping. + config.update(enabled=True, surface="tui") + scoped = server.dispatch({ + "id": "wake-scoped", + "method": "wake.start", + "params": {"surface": "gui", "persist": True}, + }, transport=transport) + assert scoped["result"] == {"started": False, "reason": "disabled_for_surface"} + assert persisted == [True, False] + finally: + server._wake_owner_transport = None + server._wake_owner_surface = "" + + def test_voice_record_start_forwards_max_recording_seconds(monkeypatch): """voice.max_recording_seconds must reach start_continuous from the TUI. @@ -13802,9 +14061,9 @@ def test_persist_model_switch_preserves_sibling_model_keys(tmp_path, monkeypatch "agent:\n" " system_prompt: keepme\n" ) - # save_config_value() resolves the config path from cli._hermes_home, which - # is captured at import time — patch it directly (set_hermes_home_override - # does NOT affect this snapshot). + # save_config_value() resolves the config path from get_hermes_home() (live + # env var), always targeting HERMES_HOME/config.yaml — point it at tmp_path. + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.setattr(cli, "_hermes_home", tmp_path) result = types.SimpleNamespace( @@ -13837,6 +14096,7 @@ def test_persist_model_switch_clears_stale_base_url(tmp_path, monkeypatch): " provider: custom:mylocal\n" " base_url: http://localhost:1234/v1\n" ) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.setattr(cli, "_hermes_home", tmp_path) # Switch to a native provider with no base_url. diff --git a/tests/test_tui_gateway_ws.py b/tests/test_tui_gateway_ws.py index ad41ba9dade..8c32f9798ed 100644 --- a/tests/test_tui_gateway_ws.py +++ b/tests/test_tui_gateway_ws.py @@ -152,6 +152,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 diff --git a/tests/tools/test_wake_word.py b/tests/tools/test_wake_word.py new file mode 100644 index 00000000000..baca95ab91b --- /dev/null +++ b/tests/tools/test_wake_word.py @@ -0,0 +1,1049 @@ +"""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 + # Invalid input falls back to the configured default, not a hardcoded 0.5. + assert ww._sensitivity({"sensitivity": "nope"}) == ww._DEFAULTS["sensitivity"] + assert ww._sensitivity({}) == ww._DEFAULTS["sensitivity"] + 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 _voice_loop_ready(monkeypatch, stt=True, tts=True): + """Pin the STT/TTS probes so requirements tests don't depend on the + test venv's installed voice stack.""" + monkeypatch.setattr(ww, "_stt_ready", lambda: stt) + monkeypatch.setattr(ww, "_tts_ready", lambda: tts) + + +def test_requirements_openwakeword_available(monkeypatch): + _voice_loop_ready(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_need_stt_and_tts(monkeypatch): + """No STT/TTS → wake refuses to arm (mic would hear you, then nothing).""" + monkeypatch.setattr(ww, "_audio_available", lambda: True) + monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: True) + + _voice_loop_ready(monkeypatch, stt=False, tts=True) + r = ww.check_wake_word_requirements({"provider": "openwakeword"}) + assert r["available"] is False + assert r["stt_available"] is False + assert "speech-to-text" in r["hint"] + assert "text-to-speech" not in r["hint"] + + _voice_loop_ready(monkeypatch, stt=True, tts=False) + r = ww.check_wake_word_requirements({"provider": "openwakeword"}) + assert r["available"] is False + assert r["tts_available"] is False + assert "text-to-speech" in r["hint"] + + _voice_loop_ready(monkeypatch, stt=False, tts=False) + r = ww.check_wake_word_requirements({"provider": "openwakeword"}) + assert r["available"] is False + assert "speech-to-text and text-to-speech" in r["hint"] + + +def test_tts_ready_is_a_probe_never_an_installer(monkeypatch): + """_tts_ready must NOT trigger lazy pip installs from a status poll. + + Regression: check_tts_requirements → _import_edge_tts → lazy_deps.ensure + ran pip inside wake.status; a slow/failed install froze the poll and + unmounted the desktop ear. Uninstalled-but-lazy-installable counts as + ready WITHOUT calling ensure/check. + """ + import types as _types + + monkeypatch.setattr( + ww, "_tts_ready", ww.__dict__["_tts_ready"] + ) # use the real implementation + fake_tts = _types.SimpleNamespace( + _get_provider=lambda cfg: "edge", + _load_tts_config=lambda: {}, + check_tts_requirements=lambda: (_ for _ in ()).throw( + AssertionError("check_tts_requirements must not run when deps are missing") + ), + ) + monkeypatch.setitem(sys.modules, "tools.tts_tool", fake_tts) + + # Deps missing + lazy installs allowed → ready (installs at first speak). + monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: False) + monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: True) + assert ww._tts_ready() is True + + # Deps missing + lazy installs disabled → not ready. + monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: False) + assert ww._tts_ready() is False + + # Deps present → falls through to the real requirements check. + fake_tts.check_tts_requirements = lambda: True + monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: True) + assert ww._tts_ready() is True + + +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 + + +def test_requirements_fresh_install_lazy_allowed(monkeypatch): + """Deps missing + lazy installs allowed → available, so /wake on can + reach the engine constructor's ``lazy_deps.ensure()`` call. + + Regression: the audio probe imports sounddevice/numpy — packages the + lazy installer would fetch — so gating ``available`` on it made the + lazy-install path unreachable on a fresh machine (the /wake on handler + printed the pip hint and bailed before ensure() ever ran). + """ + def _boom(): + raise AssertionError("audio probe must not run while deps are missing") + + _voice_loop_ready(monkeypatch) + monkeypatch.setattr(ww, "_audio_available", _boom) + monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: False) + monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: True) + r = ww.check_wake_word_requirements({"provider": "openwakeword"}) + assert r["available"] is True + assert r["deps_available"] is False + assert r["hint"] == "" + + +def test_requirements_fresh_install_lazy_disabled(monkeypatch): + """Deps missing + lazy installs disabled → unavailable, with the manual + pip command as the remediation hint.""" + monkeypatch.setattr(ww, "_audio_available", lambda: True) + monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: False) + monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: False) + monkeypatch.setattr( + "tools.lazy_deps.feature_install_command", lambda f: f"uv pip install {f}" + ) + r = ww.check_wake_word_requirements({"provider": "openwakeword"}) + assert r["available"] is False + assert r["deps_available"] is False + assert "install" in r["hint"] + + +def test_requirements_deps_present_but_no_audio_hint(monkeypatch): + """Once deps ARE installed, a failing audio probe blocks with a mic hint + (lazy installs can't fix a missing audio device).""" + monkeypatch.setattr(ww, "_audio_available", lambda: False) + monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: True) + monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: True) + r = ww.check_wake_word_requirements({"provider": "openwakeword"}) + assert r["available"] is False + assert "audio device" in r["hint"] + + +# ── 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. + # Which artifact is bundled follows the platform's default backend (tflite on + # macOS ARM64, where openWakeWord's onnx path scores near-zero). + 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(ww.default_inference_framework())] + + +def test_openwakeword_bundled_model_matches_framework(monkeypatch): + calls = _install_fake_openwakeword(monkeypatch) + # Pin the tflite runtime as present so this exercises artifact selection, + # not runtime availability — off-Darwin the bridge legitimately returns + # False and the engine falls back to onnx (covered separately below). + monkeypatch.setattr(ww, "ensure_tflite_runtime", lambda: True) + ww._OpenWakeWordEngine( + {"provider": "openwakeword", "openwakeword": {"inference_framework": "tflite"}} + ) + (downloaded,) = calls["download"] + assert downloaded == [ww._bundled_wakeword_path("tflite")] + + +# ── platform-aware backend selection (openWakeWord onnx is broken on macOS ARM64, +# upstream dscripka/openWakeWord#336) ──────────────────────────────────────── + +def test_default_framework_is_tflite_on_macos_arm64(monkeypatch): + monkeypatch.setattr(ww.sys, "platform", "darwin") + monkeypatch.setattr("platform.machine", lambda: "arm64") + assert ww.default_inference_framework() == "tflite" + + +@pytest.mark.parametrize( + "plat,machine", + [("linux", "x86_64"), ("linux", "aarch64"), ("win32", "AMD64"), ("darwin", "x86_64")], +) +def test_default_framework_is_onnx_elsewhere(monkeypatch, plat, machine): + # Only the broken platform changes behaviour; everyone else keeps onnx. + monkeypatch.setattr(ww.sys, "platform", plat) + monkeypatch.setattr("platform.machine", lambda: machine) + assert ww.default_inference_framework() == "onnx" + + +def test_explicit_framework_overrides_platform_default(monkeypatch): + # An operator who pins a backend keeps it, even on macOS ARM64. + calls = _install_fake_openwakeword(monkeypatch) + monkeypatch.setattr(ww.sys, "platform", "darwin") + monkeypatch.setattr("platform.machine", lambda: "arm64") + ww._OpenWakeWordEngine( + {"provider": "openwakeword", "openwakeword": {"inference_framework": "onnx"}} + ) + (downloaded,) = calls["download"] + assert downloaded == [ww._bundled_wakeword_path("onnx")] + + +# ── ambient-speech rejection: consecutive-frame confirmation ────────────────── + +def _openwakeword_engine_with_scores(monkeypatch, cfg_wake, scores): + """Build a real _OpenWakeWordEngine whose predict() replays ``scores``.""" + seq = iter(scores) + + class _ScriptedModel: + def __init__(self, wakeword_models, inference_framework="onnx"): + self.models = {"hey_hermes": object()} + + def predict(self, frame): + return {"hey_hermes": next(seq)} + + def reset(self): + pass + + oww = types.ModuleType("openwakeword") + oww.utils = types.SimpleNamespace(download_models=lambda names=[]: None) + model_mod = types.ModuleType("openwakeword.model") + model_mod.Model = _ScriptedModel + monkeypatch.setitem(sys.modules, "openwakeword", oww) + monkeypatch.setitem(sys.modules, "openwakeword.model", model_mod) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *a, **k: None) + monkeypatch.setattr(ww, "ensure_tflite_runtime", lambda: True) + return ww._OpenWakeWordEngine({"provider": "openwakeword", **cfg_wake}) + + +def test_confirmation_frames_reject_single_frame_spike(monkeypatch): + # A lone over-threshold frame (ambient phoneme) must NOT fire with the + # default 3-frame confirmation; the streak resets on the next quiet frame. + eng = _openwakeword_engine_with_scores( + monkeypatch, + {"sensitivity": 0.5, "confirmation_frames": 3}, + [0.9, 0.0, 0.0, 0.9, 0.0], + ) + assert [eng.process(None) for _ in range(5)] == [False, False, False, False, False] + + +def test_confirmation_frames_fire_on_sustained_phrase(monkeypatch): + # Three consecutive over-threshold frames (a real utterance) fire exactly + # once, on the third frame. + eng = _openwakeword_engine_with_scores( + monkeypatch, + {"sensitivity": 0.5, "confirmation_frames": 3}, + [0.9, 0.9, 0.9, 0.0], + ) + assert [eng.process(None) for _ in range(4)] == [False, False, True, False] + + +def test_confirmation_frames_one_restores_single_frame_behavior(monkeypatch): + # confirmation_frames=1 is the old behavior: fire on the first frame. + eng = _openwakeword_engine_with_scores( + monkeypatch, + {"sensitivity": 0.5, "confirmation_frames": 1}, + [0.9, 0.0], + ) + assert eng.process(None) is True + + +def test_confirmation_streak_resets_on_engine_reset(monkeypatch): + # A pause (reset) between two over-threshold frames must not let a + # pre-pause frame count toward the post-resume streak. + eng = _openwakeword_engine_with_scores( + monkeypatch, + {"sensitivity": 0.5, "confirmation_frames": 2}, + [0.9, 0.9, 0.9], + ) + assert eng.process(None) is False # streak = 1 + eng.reset() # streak -> 0 + assert eng.process(None) is False # streak = 1 again, not 2 + assert eng.process(None) is True # streak = 2 -> fire + + +def test_confirmation_frames_config_clamped(monkeypatch): + assert ww._confirmation_frames({"confirmation_frames": 0}) == 1 + assert ww._confirmation_frames({"confirmation_frames": 99}) == 10 + assert ww._confirmation_frames({"confirmation_frames": "x"}) == ww._DEFAULT_CONFIRMATION_FRAMES + assert ww._confirmation_frames({}) == ww._DEFAULT_CONFIRMATION_FRAMES + + +def test_porcupine_sensitivity_is_inverted_to_match_shared_contract(monkeypatch): + # Our config contract is "higher sensitivity = stricter" for every engine. + # Porcupine's own `sensitivities` param means the OPPOSITE (higher = looser, + # more false alarms), so the engine must pass 1 - sensitivity. + captured = {} + + class _FakePorcupine: + frame_length = 512 + + def process(self, frame): + return -1 + + def _create(**kwargs): + captured.update(kwargs) + return _FakePorcupine() + + pv = types.ModuleType("pvporcupine") + pv.create = _create + monkeypatch.setitem(sys.modules, "pvporcupine", pv) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *a, **k: None) + monkeypatch.setenv("PORCUPINE_ACCESS_KEY", "test-key") + + # Strict (0.9) → Porcupine gets a low 0.1 (few false alarms). + ww._PorcupineEngine({"provider": "porcupine", "sensitivity": 0.9}) + assert captured["sensitivities"] == [pytest.approx(0.1)] + + # Loose (0.2) → Porcupine gets a high 0.8. + ww._PorcupineEngine({"provider": "porcupine", "sensitivity": 0.2}) + assert captured["sensitivities"] == [pytest.approx(0.8)] + + +def test_default_sensitivity_is_stricter_than_openwakeword_baseline(): + # Regression: the 0.5 default let near-misses ("hey hor") through. The + # default must sit above openWakeWord's permissive 0.5 baseline. + assert ww._DEFAULTS["sensitivity"] >= 0.6 + assert ww._sensitivity({}) >= 0.6 + + +def test_macos_tflite_refuses_silent_onnx_downgrade(monkeypatch): + # openWakeWord silently falls back to onnx when no tflite runtime imports. + # On macOS ARM64 that lands on the broken backend, so we must raise instead + # of arming a listener that can never fire. + _install_fake_openwakeword(monkeypatch) + monkeypatch.setattr(ww.sys, "platform", "darwin") + monkeypatch.setattr("platform.machine", lambda: "arm64") + monkeypatch.setattr(ww, "ensure_tflite_runtime", lambda: False) + with pytest.raises(RuntimeError, match="ai-edge-litert"): + ww._OpenWakeWordEngine({"provider": "openwakeword", "openwakeword": {}}) + + +def test_non_macos_tflite_falls_back_to_onnx(monkeypatch): + # Off macOS the onnx backend works, so a missing tflite runtime is a + # downgrade, not a hard failure. + calls = _install_fake_openwakeword(monkeypatch) + monkeypatch.setattr(ww.sys, "platform", "linux") + monkeypatch.setattr("platform.machine", lambda: "x86_64") + monkeypatch.setattr(ww, "ensure_tflite_runtime", lambda: False) + ww._OpenWakeWordEngine( + {"provider": "openwakeword", "openwakeword": {"inference_framework": "tflite"}} + ) + (downloaded,) = calls["download"] + assert downloaded == [ww._bundled_wakeword_path("onnx")] + + +def test_requirements_report_missing_tflite_runtime(monkeypatch): + # A missing runtime must surface as unavailable + an actionable hint rather + # than an armed-but-deaf detector. + monkeypatch.setattr(ww.sys, "platform", "darwin") + monkeypatch.setattr("platform.machine", lambda: "arm64") + monkeypatch.setattr(ww, "ensure_tflite_runtime", lambda: False) + monkeypatch.setattr("tools.lazy_deps.is_available", lambda feature: feature != "wake.openwakeword.tflite") + monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: False) + monkeypatch.setattr(ww, "_audio_available", lambda: True) + reqs = ww.check_wake_word_requirements({"provider": "openwakeword", "openwakeword": {}}) + assert reqs["available"] is False + assert "ai-edge-litert" in reqs["hint"] + + +# ── sherpa-onnx open-vocabulary engine ─────────────────────────────────── + + +def _install_fake_sherpa(monkeypatch, tmp_path): + """Fake sherpa_onnx + a fake model dir so the engine builds offline.""" + calls = {"text2token": [], "spotter": [], "results": []} + + model_dir = tmp_path / "kws-model" + model_dir.mkdir() + for name in ( + "tokens.txt", + "bpe.model", + "encoder-epoch-12-avg-2-chunk-16-left-64.onnx", + "decoder-epoch-12-avg-2-chunk-16-left-64.onnx", + "joiner-epoch-12-avg-2-chunk-16-left-64.onnx", + ): + (model_dir / name).write_bytes(b"x") + + class _FakeStream: + def accept_waveform(self, sample_rate, samples): + pass + + class _FakeSpotter: + def __init__(self, **kwargs): + calls["spotter"].append(kwargs) + + def create_stream(self): + return _FakeStream() + + def is_ready(self, stream): + return bool(calls["results"]) + + def decode_stream(self, stream): + pass + + def get_result(self, stream): + return calls["results"].pop(0) if calls["results"] else "" + + def reset_stream(self, stream): + pass + + def _fake_text2token(phrases, tokens, tokens_type, bpe_model): + calls["text2token"].append(list(phrases)) + return [p.split() for p in phrases] + + sherpa = types.ModuleType("sherpa_onnx") + sherpa.KeywordSpotter = _FakeSpotter + sherpa.text2token = _fake_text2token + monkeypatch.setitem(sys.modules, "sherpa_onnx", sherpa) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *a, **k: None) + + # numpy is an optional voice-extra dep, lazy-installed at runtime — CI's + # hermetic slices don't have it. process() only calls asarray(...)/32768, + # so a minimal stub keeps these tests runnable without the real package. + if "numpy" not in sys.modules: + class _FakeArr(list): + def __truediv__(self, other): + return self + + np_stub = types.ModuleType("numpy") + np_stub.float32 = "float32" + np_stub.asarray = lambda x, dtype=None: _FakeArr(x) + monkeypatch.setitem(sys.modules, "numpy", np_stub) + return calls, model_dir + + +def test_sherpa_engine_tokenizes_configured_phrase_at_runtime(monkeypatch, tmp_path): + # The open-vocab core: the phrase from config is tokenized at runtime — + # no per-phrase model, no training artifact. + calls, model_dir = _install_fake_sherpa(monkeypatch, tmp_path) + eng = ww._SherpaKwsEngine({ + "provider": "sherpa", + "phrase": "purple monkey dishwasher", + "sherpa": {"model_dir": str(model_dir)}, + }) + assert calls["text2token"] == [["PURPLE MONKEY DISHWASHER"]] + # keywords file was materialized with an underscored display name + with open(eng._keywords_file) as f: + line = f.read().strip() + assert line.endswith("@PURPLE_MONKEY_DISHWASHER") + eng.close() + assert not os.path.exists(eng._keywords_file) + + +def test_sherpa_engine_process_fires_and_resets(monkeypatch, tmp_path): + calls, model_dir = _install_fake_sherpa(monkeypatch, tmp_path) + eng = ww._SherpaKwsEngine({ + "provider": "sherpa", "phrase": "hey hermes", + "sherpa": {"model_dir": str(model_dir)}, + }) + frame = [0] * eng.frame_length + assert eng.process(frame) is False # no result queued + calls["results"].append("HEY_HERMES") + assert eng.process(frame) is True # queued result → fire + old_stream = eng._stream + eng.reset() + assert eng._stream is not old_stream # fresh decoder state + + +def test_sherpa_provider_routing(monkeypatch, tmp_path): + calls, model_dir = _install_fake_sherpa(monkeypatch, tmp_path) + for alias in ("sherpa", "sherpa-onnx", "kws", "open"): + eng = ww._build_engine({ + "provider": alias, "phrase": "x", + "sherpa": {"model_dir": str(model_dir)}, + }) + assert isinstance(eng, ww._SherpaKwsEngine) + + +def test_sherpa_requirements_probe_uses_sherpa_feature(monkeypatch): + seen = {} + monkeypatch.setattr(ww, "_audio_available", lambda: True) + monkeypatch.setattr( + "tools.lazy_deps.is_available", lambda f: seen.setdefault("feature", f) or True + ) + r = ww.check_wake_word_requirements({"provider": "sherpa", "phrase": "anything at all"}) + assert seen["feature"] == "wake.sherpa" + assert r["provider"] == "sherpa" + assert r["phrase"] == "anything at all" + + +# ── Multi-profile phrase routing ───────────────────────────────────────── + + +def test_sherpa_engine_enrolls_all_profile_phrases(monkeypatch, tmp_path): + calls, model_dir = _install_fake_sherpa(monkeypatch, tmp_path) + monkeypatch.setattr(ww, "_active_profile_name", lambda: "default") + monkeypatch.setattr( + ww, "enrolled_profile_phrases", + lambda: {"coder": "hey coder", "trader": "hey trader"}, + ) + eng = ww._SherpaKwsEngine({ + "provider": "sherpa", "phrase": "hey hermes", + "sherpa": {"model_dir": str(model_dir)}, + }) + with open(eng._keywords_file, encoding="utf-8") as f: + lines = f.read().strip().splitlines() + assert len(lines) == 3 + assert eng._display_to_profile == { + "HEY_HERMES": "default", + "HEY_CODER": "coder", + "HEY_TRADER": "trader", + } + eng.close() + + +def test_sherpa_engine_profile_routing_can_be_disabled(monkeypatch, tmp_path): + calls, model_dir = _install_fake_sherpa(monkeypatch, tmp_path) + monkeypatch.setattr(ww, "_active_profile_name", lambda: "default") + monkeypatch.setattr( + ww, "enrolled_profile_phrases", lambda: {"coder": "hey coder"} + ) + eng = ww._SherpaKwsEngine({ + "provider": "sherpa", "phrase": "hey hermes", "profile_routing": False, + "sherpa": {"model_dir": str(model_dir)}, + }) + assert eng._display_to_profile == {"HEY_HERMES": "default"} + eng.close() + + +def test_sherpa_engine_match_maps_back_to_profile(monkeypatch, tmp_path): + calls, model_dir = _install_fake_sherpa(monkeypatch, tmp_path) + monkeypatch.setattr(ww, "_active_profile_name", lambda: "default") + monkeypatch.setattr( + ww, "enrolled_profile_phrases", lambda: {"coder": "hey coder"} + ) + eng = ww._SherpaKwsEngine({ + "provider": "sherpa", "phrase": "hey hermes", + "sherpa": {"model_dir": str(model_dir)}, + }) + frame = [0] * eng.frame_length + calls["results"].append("HEY_CODER") + assert eng.process(frame) is True + assert eng.last_match == ("hey coder", "coder") + calls["results"].append("HEY_HERMES") + assert eng.process(frame) is True + assert eng.last_match == ("hey hermes", "default") + + +def test_enrolled_profile_phrases_reads_profile_configs(monkeypatch, tmp_path): + profiles_root = tmp_path / "profiles" + for name, body in ( + ("coder", "wake_word:\n enabled: true\n phrase: hey coder\n"), + ("trader", "wake_word:\n enabled: true\n"), # phrase defaults + ("quiet", "wake_word:\n enabled: false\n"), # not enrolled + ("empty", ""), # no wake_word at all + ): + d = profiles_root / name + d.mkdir(parents=True) + (d / "config.yaml").write_text(body, encoding="utf-8") + + class _Info: + def __init__(self, name): + self.name = name + + import types as _types + fake_profiles = _types.ModuleType("hermes_cli.profiles") + fake_profiles.list_profiles = lambda: [ + _Info(p.name) for p in sorted(profiles_root.iterdir()) + ] + fake_profiles.get_profile_dir = lambda name: str(profiles_root / name) + fake_profiles.get_active_profile_name = lambda: "default" + monkeypatch.setitem(sys.modules, "hermes_cli.profiles", fake_profiles) + + phrases = ww.enrolled_profile_phrases() + assert phrases == {"coder": "hey coder", "trader": "hey trader"} + + +def test_get_last_match_reads_detector_engine(monkeypatch): + class _Eng: + last_match = ("hey coder", "coder") + + class _Det: + engine = _Eng() + + monkeypatch.setattr(ww, "_detector", _Det()) + assert ww.get_last_match() == ("hey coder", "coder") + monkeypatch.setattr(ww, "_detector", None) + assert ww.get_last_match() is None + + +# ── 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)) + + +class _Frame(list): + """List with numpy-ish abs()/max() so the silence probe sees real peaks.""" + + def __abs__(self): + return _Frame(abs(x) for x in self) + + def max(self): + return max(self) if self else 0 + + +class _SilentStream(_FakeStream): + """Stream that always yields near-zero frames (dead macOS mic).""" + + def read(self, n): + time.sleep(0.005) + return _Frame([0] * n), False + + +class _LoudStream(_FakeStream): + """Stream that yields audible frames.""" + + def read(self, n): + time.sleep(0.005) + return _Frame([500] * n), False + + +def test_detector_flags_silent_stream_and_recovers(monkeypatch): + """A stream of zeros sets audio_silent (macOS no-permission mode); audio clears it.""" + monkeypatch.setattr(ww, "_SILENCE_ALERT_SECONDS", 0.001) # trip on the first frame + stream_cls = {"cls": _SilentStream} + fake_sd = types.SimpleNamespace(InputStream=lambda **kw: stream_cls["cls"](**kw)) + monkeypatch.setattr(ww, "_import_audio", lambda: (fake_sd, None)) + + det = ww.WakeWordDetector(_FakeEngine(fire=False), lambda: None) + det.start() + try: + deadline = time.monotonic() + 2.0 + while not det.audio_silent and time.monotonic() < deadline: + time.sleep(0.01) + assert det.audio_silent is True + assert ww.audio_is_silent() is False # module accessor needs the singleton + + monkeypatch.setattr(ww, "_detector", det) + assert ww.audio_is_silent() is True + + # Audio returns (permission granted / real mic) — flag clears. + det.pause() + stream_cls["cls"] = _LoudStream + det.resume() + deadline = time.monotonic() + 2.0 + while det.audio_silent and time.monotonic() < deadline: + time.sleep(0.01) + assert det.audio_silent is False + finally: + monkeypatch.setattr(ww, "_detector", None) + det.stop() + + +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) diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index 155e43ce616..faa919d028f 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -140,6 +140,40 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = { # small silk-v3 codec binding; installed on first .silk transcription. "stt.silk": ("pilk==0.2.4",), + # ─── 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. + # openWakeWord's ONNX embedding model returns near-zero scores on macOS + # ARM64 (dscripka/openWakeWord#336), so the wake word runs on the tflite + # backend there. Upstream declares tflite-runtime for Linux only; + # ai-edge-litert is the macOS equivalent, bridged in tools/wake_word.py. + # It lives in its own feature because lazy-dep specs cannot carry PEP 508 + # environment markers (_spec_is_safe rejects ";"), so the platform gate is + # applied by the caller instead. + "wake.openwakeword.tflite": ( + "ai-edge-litert==2.1.6", + ), + "wake.openwakeword": ( + "openwakeword==0.6.0", + "onnxruntime==1.27.0", + "sounddevice==0.5.5", + "numpy==2.4.3", + ), + # Open-vocabulary keyword spotting: any typed phrase, zero training. + # sentencepiece is required by sherpa_onnx.text2token (runtime phrase + # tokenization) even though sherpa-onnx doesn't declare it. + "wake.sherpa": ( + "sherpa-onnx==1.13.4", + "sentencepiece==0.2.2", + "sounddevice==0.5.5", + "numpy==2.4.3", + ), + "wake.porcupine": ( + "pvporcupine==4.0.3", + "sounddevice==0.5.5", + "numpy==2.4.3", + ), + # ─── Image generation backends ───────────────────────────────────────── "image.fal": ("fal-client==0.13.1",), diff --git a/tools/wake_word.py b/tools/wake_word.py new file mode 100644 index 00000000000..9367127fe3f --- /dev/null +++ b/tools/wake_word.py @@ -0,0 +1,1122 @@ +"""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. + +Three engines, all 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. +* **sherpa** (free, no API key, open vocabulary) — sherpa-onnx keyword + spotting. Detects ANY typed phrase with no training: set + ``wake_word.phrase`` and the phrase is tokenized at runtime against a small + streaming zipformer model (~13 MB English model, one-time download). +* **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 sys +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 + +# Ambient-speech rejection: openWakeWord scores one ~80ms frame at a time, and a +# stray phoneme in background conversation can spike a single frame over the +# threshold. A real utterance of the phrase holds the score high across several +# consecutive frames, so we require N-in-a-row above threshold before firing. +# This is the primary lever against unintended triggers on ambient talk. +_DEFAULT_CONFIRMATION_FRAMES = 3 + +# Dead-mic detection: an int16 stream whose peak stays at/below this for this +# many consecutive seconds is flagged as silent. macOS grants the *app* mic +# permission per-process — a backend spawned without the entitlement gets a +# "working" CoreAudio stream that delivers zeros forever, so the listener +# looks armed but can never hear the phrase. +_SILENCE_PEAK = 10 +_SILENCE_ALERT_SECONDS = 10 + + +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.6, + "confirmation_frames": _DEFAULT_CONFIRMATION_FRAMES, + "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 _is_macos_arm64() -> bool: + import platform + + return sys.platform == "darwin" and platform.machine() == "arm64" + + +def default_inference_framework() -> str: + """The openWakeWord backend to use on this platform. + + openWakeWord's ONNX backend produces near-zero scores on macOS ARM64 — its + shared *embedding* model is the broken stage (the melspectrogram front-end + and the wake classifier both match tflite exactly). The detector arms, the + microphone works, and no phrase can ever cross the threshold. Prefer the + tflite backend there; ONNX stays the default everywhere else. + + Upstream: https://github.com/dscripka/openWakeWord/issues/336 + """ + return "tflite" if _is_macos_arm64() else "onnx" + + +def ensure_tflite_runtime() -> bool: + """Make ``import tflite_runtime.interpreter`` resolve, returning success. + + openWakeWord hardcodes that import but only declares ``tflite-runtime`` for + ``platform_system == "Linux"``; on macOS the equivalent wheel is + ``ai-edge-litert``. Alias the module so the upstream import succeeds. The + alias is process-local — nothing is written to site-packages. + """ + try: + import tflite_runtime.interpreter # noqa: F401 + + return True + except ImportError: + pass + + try: + from ai_edge_litert import interpreter as _litert # type: ignore[import-not-found] + except ImportError: + return False + + import types + + pkg = types.ModuleType("tflite_runtime") + pkg.__path__ = [] # type: ignore[attr-defined] # mark as package + sys.modules.setdefault("tflite_runtime", pkg) + sys.modules["tflite_runtime.interpreter"] = _litert + logger.debug("wake word: bridged tflite_runtime -> ai_edge_litert") + return True + + +def load_wake_word_config() -> Dict[str, Any]: + """Return the ``wake_word`` config section, shape-guarded to a dict.""" + try: + from hermes_cli.config import load_config + + cfg = load_config().get("wake_word") + except Exception: + cfg = None + return cfg if isinstance(cfg, dict) else {} + + +def _get(cfg: Dict[str, Any], key: str) -> Any: + val = cfg.get(key, _DEFAULTS.get(key)) + return _DEFAULTS.get(key) if val is None else val + + +def _provider(cfg: Dict[str, Any]) -> str: + return str(_get(cfg, "provider")).strip().lower() or "openwakeword" + + +def _sensitivity(cfg: Dict[str, Any]) -> float: + raw = _get(cfg, "sensitivity") + try: + s = float(raw) + except (TypeError, ValueError): + s = float(_DEFAULTS["sensitivity"]) + return min(max(s, 0.0), 1.0) + + +def _confirmation_frames(cfg: Dict[str, Any]) -> int: + """How many consecutive over-threshold frames are required to fire. + + ``1`` restores the old single-frame behaviour; higher values reject + ambient-speech blips at the cost of a few tens of ms of extra latency. + Clamped to a sane 1..10. + """ + raw = _get(cfg, "confirmation_frames") + try: + n = int(raw) + except (TypeError, ValueError): + n = _DEFAULT_CONFIRMATION_FRAMES + return min(max(n, 1), 10) + + +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() + + +# --------------------------------------------------------------------------- +# Multi-profile phrase enrollment (open-vocabulary routing) +# --------------------------------------------------------------------------- + +def _active_profile_name() -> str: + try: + from hermes_cli.profiles import get_active_profile_name + + return get_active_profile_name() or "default" + except Exception: + return "default" + + +def enrolled_profile_phrases() -> Dict[str, str]: + """Map ``profile name -> wake phrase`` for every wake-enabled profile. + + Reads each profile's own ``config.yaml`` raw (cheap, no full config merge). + A profile is enrolled when its ``wake_word.enabled`` is truthy; its phrase + defaults to ``"hey "`` when unset. Used by the sherpa engine to + listen for every enrolled profile's phrase at once and route the wake to + the matching profile. Best-effort: unreadable profiles are skipped. + """ + phrases: Dict[str, str] = {} + try: + import yaml + + from hermes_cli.profiles import get_profile_dir, list_profiles + + for info in list_profiles(): + name = getattr(info, "name", None) or str(info) + try: + cfg_path = Path(get_profile_dir(name)) / "config.yaml" + raw = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {} + wc = raw.get("wake_word") or {} + if not isinstance(wc, dict) or not wc.get("enabled"): + continue + phrase = str(wc.get("phrase") or f"hey {name}").strip() + if phrase: + phrases[name] = phrase + except Exception: + continue + except Exception: + pass + return phrases + + +# --------------------------------------------------------------------------- +# 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 + + #: Optional (matched phrase, profile name) of the most recent fire. + #: Multi-phrase engines (sherpa) set this for profile routing; the + #: single-phrase engines leave it None (callers fall back to the + #: configured phrase / active profile). + last_match: Optional[tuple[str, str]] = None + + 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 "").strip().lower() + if not framework: + framework = default_inference_framework() + # openWakeWord returns a 0..1 score per frame; sensitivity IS the raw + # threshold a score must clear. Higher = stricter (fewer false fires). + # Default 0.6 sits above openWakeWord's permissive 0.5 baseline, which + # let near-misses like "hey hor" through. + self._threshold = _sensitivity(cfg) + self._confirm_needed = _confirmation_frames(cfg) + self._confirm_streak = 0 + + # openWakeWord silently downgrades tflite -> onnx when no tflite runtime + # imports (model.py). On macOS ARM64 that lands on the backend whose + # embedding model is broken, so the listener would arm and never fire. + # Install + bridge the runtime first, and refuse the downgrade rather + # than ship a dead ear. + if framework == "tflite" and not ensure_tflite_runtime(): + # Same lazy-install contract as every other backend; the platform + # gate lives here because dep specs can't carry PEP 508 markers. + try: + lazy_deps.ensure("wake.openwakeword.tflite", prompt=False) + except Exception as e: + logger.debug("wake word: tflite runtime install failed: %s", e) + if not ensure_tflite_runtime(): + if _is_macos_arm64(): + raise RuntimeError( + "The wake word needs the tflite backend on this Mac, but its " + "runtime is missing. Install it with: pip install ai-edge-litert" + ) + logger.warning("wake word: no tflite runtime available — falling back to onnx") + framework = "onnx" + + # 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) + over = any(score >= self._threshold for score in scores.values()) + # Require N consecutive over-threshold frames: a real phrase holds the + # score high across frames, a stray ambient phoneme spikes just one. + if over: + self._confirm_streak += 1 + if self._confirm_streak >= self._confirm_needed: + self._confirm_streak = 0 + return True + return False + self._confirm_streak = 0 + return False + + 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. + self._confirm_streak = 0 + try: + self._model.reset() + except Exception: + pass + + def close(self) -> None: + self.reset() + + +# sherpa-onnx open-vocabulary KWS model: a small streaming zipformer +# transducer. English (GigaSpeech); one-time download, cached under +# HERMES_HOME. Keywords are typed phrases tokenized at RUNTIME — no +# training step, unlike openWakeWord/Porcupine custom models. +_SHERPA_KWS_MODEL_URL = ( + "https://github.com/k2-fsa/sherpa-onnx/releases/download/kws-models/" + "sherpa-onnx-kws-zipformer-gigaspeech-3.3M-2024-01-01.tar.bz2" +) +_SHERPA_KWS_MODEL_DIR = "sherpa-onnx-kws-zipformer-gigaspeech-3.3M-2024-01-01" + + +def _sherpa_model_root() -> Path: + from hermes_constants import get_hermes_home + + return get_hermes_home() / "cache" / "wakewords" + + +def _ensure_sherpa_model(root: Optional[Path] = None) -> Path: + """Download + unpack the sherpa KWS model once; return its directory.""" + root = root or _sherpa_model_root() + target = root / _SHERPA_KWS_MODEL_DIR + if (target / "tokens.txt").exists(): + return target + import tarfile + import urllib.request + + root.mkdir(parents=True, exist_ok=True) + archive = root / f"{_SHERPA_KWS_MODEL_DIR}.tar.bz2" + logger.info("wake word: downloading sherpa KWS model (one-time, ~13 MB)") + urllib.request.urlretrieve(_SHERPA_KWS_MODEL_URL, archive) # noqa: S310 + with tarfile.open(archive, "r:bz2") as tf: + tf.extractall(root, filter="data") + archive.unlink(missing_ok=True) + if not (target / "tokens.txt").exists(): + raise RuntimeError(f"sherpa KWS model unpack failed: {target}") + return target + + +class _SherpaKwsEngine(_Engine): + """sherpa-onnx open-vocabulary keyword spotting — any typed phrase, zero training. + + The configured ``wake_word.phrase`` is BPE-tokenized at runtime against the + model's vocabulary, so "hey hermes", "hey coder", or any other phrase works + immediately. Here ``phrase`` is DETECTION config, not a cosmetic label. + """ + + # sherpa's streaming zipformer consumes arbitrary chunk sizes; 1280 + # samples (80 ms) matches the shared capture path. + frame_length = 1280 + + def __init__(self, cfg: Dict[str, Any]): + from tools import lazy_deps + + lazy_deps.ensure("wake.sherpa", prompt=False) + + import sherpa_onnx + from sherpa_onnx import text2token + + sub = cfg.get("sherpa") if isinstance(cfg.get("sherpa"), dict) else {} + model_dir = str(sub.get("model_dir") or "").strip() + d = Path(model_dir) if model_dir else _ensure_sherpa_model() + if not (d / "tokens.txt").exists(): + raise RuntimeError(f"sherpa KWS model not found at {d}") + + # Phrase set: this profile's own phrase, plus — when profile routing is + # on — every other wake-enabled profile's phrase, so ONE listener can + # wake any profile ("hey hermes" / "hey coder" / ...). display-name → + # profile is kept for routing the match back. + phrase = str(_get(cfg, "phrase") or "hey hermes").strip() + own_profile = _active_profile_name() + phrase_map: Dict[str, str] = {phrase: own_profile} + if bool(cfg.get("profile_routing", True)): + for prof, p in enrolled_profile_phrases().items(): + phrase_map.setdefault(p.strip(), prof) + + phrases = list(phrase_map) + # Runtime tokenization of the arbitrary phrases — the open-vocab core. + tokens = text2token( + [p.upper() for p in phrases], + tokens=str(d / "tokens.txt"), + tokens_type="bpe", + bpe_model=str(d / "bpe.model"), + ) + import tempfile + + # sherpa keyword entries reject spaces in the @display-name; underscore + # them and map display → profile for match routing. + self._display_to_profile: Dict[str, str] = {} + kw = tempfile.NamedTemporaryFile( + mode="w", suffix=".txt", prefix="hermes-kws-", delete=False, encoding="utf-8" + ) + for p, toks in zip(phrases, tokens): + display = p.upper().replace(" ", "_") + self._display_to_profile[display] = phrase_map[p] + kw.write(" ".join(toks) + f" @{display}\n") + kw.close() + self._keywords_file = kw.name + #: (phrase display name, profile) of the most recent fire, for routing. + self.last_match: Optional[tuple[str, str]] = None + + # Map the shared 0..1 sensitivity onto sherpa's keywords_threshold. + # 0.5 lands exactly on sherpa's recommended default (0.25); live TTS + # matrix testing showed our previous stricter mapping (0.35) missed + # ~12% of true positives while 0.25 held zero false fires. + threshold = 0.05 + 0.4 * _sensitivity(cfg) + + def _model_file(pattern: str) -> str: + hits = sorted(d.glob(pattern)) + if not hits: + raise RuntimeError(f"sherpa KWS model file missing: {d}/{pattern}") + return str(hits[0]) + + self._spotter = sherpa_onnx.KeywordSpotter( + tokens=str(d / "tokens.txt"), + encoder=_model_file("encoder-*[!8].onnx"), + decoder=_model_file("decoder-*[!8].onnx"), + joiner=_model_file("joiner-*[!8].onnx"), + keywords_file=self._keywords_file, + keywords_threshold=threshold, + num_threads=1, + ) + self._stream = self._spotter.create_stream() + + def process(self, frame) -> bool: + import numpy as np + + samples = np.asarray(frame, dtype=np.float32) / 32768.0 + self._stream.accept_waveform(SAMPLE_RATE, samples) + fired = False + while self._spotter.is_ready(self._stream): + self._spotter.decode_stream(self._stream) + result = self._spotter.get_result(self._stream) + if result: + fired = True + display = str(result) + self.last_match = ( + display.replace("_", " ").lower(), + self._display_to_profile.get(display, ""), + ) + # Reset decoder state so one utterance can't fire repeatedly. + self._spotter.reset_stream(self._stream) + return fired + + def reset(self) -> None: + # Fresh stream drops all buffered audio/decoder state (pause → resume + # must not re-fire on stale audio). + try: + self._stream = self._spotter.create_stream() + except Exception: + pass + + def close(self) -> None: + try: + os.unlink(self._keywords_file) + except OSError: + pass + + +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() + # Porcupine's `sensitivities` runs the OPPOSITE way to our shared knob: + # per Picovoice, higher = more true positives AND more false alarms + # (looser). Our config contract is "higher = stricter" everywhere, so + # invert it here to keep one consistent meaning across all engines. + porcupine_sensitivity = 1.0 - _sensitivity(cfg) + + kwargs: Dict[str, Any] = {"access_key": access_key, "sensitivities": [porcupine_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 ("sherpa", "sherpa-onnx", "kws", "open"): + return _SherpaKwsEngine(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 _stt_ready() -> bool: + """Is a speech-to-text provider configured and enabled? + + A wake without STT arms the mic but every captured utterance dies at + transcription — a useless (and confusing) experience. Same standard as + voice mode's ``check_voice_requirements``: enabled + a real provider. + """ + try: + from tools.transcription_tools import _get_provider, _load_stt_config, is_stt_enabled + + stt_config = _load_stt_config() + return is_stt_enabled(stt_config) and _get_provider(stt_config) != "none" + except Exception: + return False + + +def _tts_ready() -> bool: + """Can the configured text-to-speech provider run (or install at first use)? + + The wake flow is fully hands-free (wake → speak → hear the reply); without + TTS the reply is silent and the loop is pointless. + + PROBE, not an installer: ``check_tts_requirements`` lazily pip-installs the + provider SDK via ``_import_*`` → ``lazy_deps.ensure`` — running that inside + a status poll froze wake.status for the length of a pip install (and a + failed install marked the wake word unavailable, unmounting the desktop + ear). When the provider's deps aren't installed yet, "installable at first + use" counts as ready and we never touch pip from here. + """ + try: + from tools.tts_tool import _get_provider, _load_tts_config + + provider = _get_provider(_load_tts_config()) + except Exception: + return False + + _LAZY_TTS_FEATURES = { + "edge": "tts.edge", + "elevenlabs": "tts.elevenlabs", + "mistral": "tts.mistral", + } + feature = _LAZY_TTS_FEATURES.get(provider) + if feature is not None: + try: + from tools import lazy_deps + + if not lazy_deps.is_available(feature): + # Not installed: ready iff it can install at first speak. + return lazy_deps._allow_lazy_installs() + except Exception: + return False + + try: + from tools.tts_tool import check_tts_requirements + + return bool(check_tts_requirements()) + except Exception: + return False + + +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 + + if provider == "porcupine": + feature = "wake.porcupine" + elif provider in ("sherpa", "sherpa-onnx", "kws", "open"): + feature = "wake.sherpa" + else: + feature = "wake.openwakeword" + deps_ok = lazy_deps.is_available(feature) + lazy_ok = lazy_deps._allow_lazy_installs() + # The audio probe imports sounddevice + numpy — two of the very packages + # the lazy installer would fetch — so it can only be trusted once the + # feature's deps are installed. On a fresh install (deps missing, lazy + # installs allowed) we defer the mic check: the engine constructors call + # ``lazy_deps.ensure()`` and the stream-open surfaces any real audio + # problem. Gating ``available`` on the probe here made the lazy-install + # path unreachable (the probe always failed before ensure() could run). + audio_ok = _audio_available() if deps_ok else False + key_ok = True + # The full wake loop is wake → record → STT → agent → TTS. Arming without + # either end configured gives a mic that hears you and then does nothing + # the user can perceive — refuse with a pointer instead. + stt_ok = _stt_ready() + tts_ok = _tts_ready() + hint = "" + + # The tflite backend needs a runtime openWakeWord doesn't declare off Linux. + # Report it as a real remediation instead of arming a detector that can't fire. + tflite_ok = True + if provider not in ("porcupine", "sherpa", "sherpa-onnx", "kws", "open"): + sub = cfg.get("openwakeword") if isinstance(cfg.get("openwakeword"), dict) else {} + framework = str(sub.get("inference_framework") or "").strip().lower() or default_inference_framework() + if framework == "tflite": + tflite_ok = ensure_tflite_runtime() or lazy_deps.is_available("wake.openwakeword.tflite") or lazy_ok + + 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 and not lazy_ok: + hint = lazy_deps.feature_install_command(feature) or "" + elif not tflite_ok: + hint = "The wake word needs the tflite runtime on this Mac: pip install ai-edge-litert" + elif deps_ok and not audio_ok: + hint = "Microphone capture needs sounddevice + numpy and a working audio device." + elif not stt_ok or not tts_ok: + missing = " and ".join( + name for name, ok in (("speech-to-text", stt_ok), ("text-to-speech", tts_ok)) if not ok + ) + hint = (f"Wake word needs {missing} configured — run `hermes tools` " + f"(Voice section) or see the voice-mode docs.") + + return { + "available": key_ok and stt_ok and tts_ok and tflite_ok + and ((deps_ok and audio_ok) or (not deps_ok and lazy_ok)), + "provider": provider, + "deps_available": deps_ok, + "audio_available": audio_ok, + "access_key_set": key_ok, + "stt_available": stt_ok, + "tts_available": tts_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() + # True when the stream is open but every frame is (near-)silence — the + # classic macOS symptom of a backend process without mic permission: + # CoreAudio "succeeds" and delivers zeros forever. Surfaced via + # wake.status / /wake status so users can tell "armed" from "deaf". + self.audio_silent = False + self._silent_frames = 0 + + @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 + # ~seconds of consecutive near-zero frames before we flag the stream + # as silent (macOS no-permission streams deliver zeros forever). + silent_alert_frames = max(1, int(_SILENCE_ALERT_SECONDS * SAMPLE_RATE / max(1, frame_length))) + 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: + peak = int(abs(frame).max()) if len(frame) else 0 + except Exception: + peak = _SILENCE_PEAK + 1 + if peak <= _SILENCE_PEAK: + self._silent_frames += 1 + if self._silent_frames == silent_alert_frames: + self.audio_silent = True + logger.warning( + "wake word: mic delivers only silence (peak<=%d for %ds) — " + "on macOS check System Settings > Privacy & Security > " + "Microphone for the Hermes backend process", + _SILENCE_PEAK, _SILENCE_ALERT_SECONDS, + ) + elif self._silent_frames: + if self.audio_silent: + logger.info("wake word: mic audio detected — stream healthy") + self._silent_frames = 0 + self.audio_silent = False + 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 + + +def audio_is_silent() -> bool: + """True when the armed stream has delivered only silence (dead mic). + + The macOS no-permission failure mode: the stream opens fine but every + frame is zeros, so detection can never fire. Lets status surfaces show + "listening but the microphone appears silent" instead of a healthy state. + """ + with _detector_lock: + det = _detector + return det is not None and det.audio_silent + + +def get_last_match() -> Optional[tuple[str, str]]: + """(matched phrase, profile) of the most recent wake fire, if the engine + reports per-phrase matches (sherpa multi-profile routing). None otherwise.""" + with _detector_lock: + det = _detector + if det is None: + return None + return getattr(det.engine, "last_match", None) diff --git a/tools/wakewords/README.md b/tools/wakewords/README.md new file mode 100644 index 00000000000..439bbae4aae --- /dev/null +++ b/tools/wakewords/README.md @@ -0,0 +1,20 @@ +# Bundled wake-word models + +`hey_hermes.onnx` / `hey_hermes.tflite` — the on-device "Hey Hermes" hotword +model. This is the default detector for the wake word feature (see +`website/docs/user-guide/features/wake-word.md`); no training or setup is +required to say "hey hermes". + +- **Engine:** [openWakeWord](https://github.com/dscripka/openWakeWord) (Apache-2.0). +- **Provenance:** trained with the openWakeWord training pipeline (synthetic + TTS-generated speech), which produces both the `.onnx` and `.tflite` artifacts. + Redistribution is permitted under the openWakeWord license. +- **Label:** the model registers as `hey_hermes` (matches the filename). +- **Runtime:** openWakeWord's shared feature-extraction models (melspectrogram + + embedding) are NOT bundled here — they are fetched once on first use by + `tools/wake_word.py` via `openwakeword.utils.download_models()`. + +To use a different phrase, train your own model and point +`wake_word.openwakeword.model` at its path, or set a built-in openWakeWord name +(`hey_jarvis`, `alexa`, `hey_mycroft`, …). See the wake-word docs for the +training guide. diff --git a/tools/wakewords/hey_hermes.onnx b/tools/wakewords/hey_hermes.onnx new file mode 100644 index 00000000000..a730b34747f Binary files /dev/null and b/tools/wakewords/hey_hermes.onnx differ diff --git a/tools/wakewords/hey_hermes.tflite b/tools/wakewords/hey_hermes.tflite new file mode 100644 index 00000000000..6811e7f07c7 Binary files /dev/null and b/tools/wakewords/hey_hermes.tflite differ diff --git a/tui_gateway/server.py b/tui_gateway/server.py index ef68e2f7d4f..dda53c8c856 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -958,6 +958,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: @@ -17628,6 +17632,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: @@ -17641,6 +17646,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). @@ -17794,6 +17807,340 @@ 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) + + +_wake_resume_retry_lock = threading.Lock() +_wake_resume_retry_active = False + + +def _wake_resume_if_owner(owner: "Transport", *, retry_seconds: float = 15.0, + retry_interval: float = 1.0) -> bool: + """Resume the wake detector for ``owner``; self-heal a busy microphone. + + Reopening the mic right after a voice turn can fail while the capture + device is still being released (browser WebRTC tracks release async). + The CLI covers this with its idle watchdog; the gateway had nothing, so + one failed resume left the listener silently dead until the user toggled + it by hand — despite ``wake_word.enabled: true``. On an exception (mic + open failure) we retry in a background thread until it sticks, the lease + changes hands, or ``retry_seconds`` elapses. ``False`` from + ``resume_listening`` (lease gone / different owner) is final — never + retried, so this can't steal another surface's mic. + """ + from tools.wake_word import resume_listening + + try: + return resume_listening(owner=owner) + except Exception as e: + logger.debug("wake resume failed (will retry): %s", e) + + global _wake_resume_retry_active + with _wake_resume_retry_lock: + if _wake_resume_retry_active: + return False + _wake_resume_retry_active = True + + def _retry() -> None: + global _wake_resume_retry_active + deadline = time.monotonic() + retry_seconds + try: + while time.monotonic() < deadline: + time.sleep(retry_interval) + try: + if resume_listening(owner=owner): + logger.info("wake: detector resumed after retry") + return + except Exception: + continue + # False — detector gone or lease moved: stop, don't fight it. + return + logger.warning( + "wake: could not resume detector after voice turn " + "(microphone still busy?) — toggle the wake word to re-arm" + ) + finally: + with _wake_resume_retry_lock: + _wake_resume_retry_active = False + + threading.Thread(target=_retry, daemon=True, name="wake-resume-retry").start() + return False + + +def _persist_wake_enabled(enabled: bool) -> bool: + """Write ``wake_word.enabled`` to config.yaml. + + Only called for explicit user gestures (the desktop ear toggle, ``/wake + on|off``) — never from passive auto-arm paths, so a mic can't become + persistently enabled without a deliberate click. + """ + try: + from cli import save_config_value + + return bool(save_config_value("wake_word.enabled", enabled)) + except Exception as e: + logger.warning("wake: failed to persist wake_word.enabled=%s: %s", enabled, 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. + + ``persist: true`` marks an explicit user gesture (toggle click, /wake on): + when the feature is disabled in config, it flips ``wake_word.enabled`` on + and saves it before arming, so the choice sticks for future sessions. + Passive auto-arm callers omit it and keep getting the config-gated refusal. + """ + surface = str(params.get("surface") or "auto").strip().lower() + persist = bool(params.get("persist")) + 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() + # Requirements first: a gesture on an unarmed-able setup (no STT/TTS, no + # mic, missing key) must refuse WITHOUT flipping wake_word.enabled — else + # config says on while nothing can ever arm, and auto-arm paths churn. + 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 "", + }) + enabled_persisted = False + if persist and not cfg.get("enabled"): + enabled_persisted = _persist_wake_enabled(True) + if enabled_persisted: + cfg = dict(cfg) + cfg["enabled"] = True + if not wake_surface_enabled(surface, cfg): + # Distinguish "feature off in config" (reason: disabled — a persist:true + # retry can turn it on) from "scoped to a different surface" (reason: + # disabled_for_surface — respects an explicit wake_word.surface choice, + # which persist does NOT override). + reason = "disabled" if not cfg.get("enabled") else "disabled_for_surface" + logger.info("wake.start(%s): %s (enabled=%s, surface=%s)", + surface, reason, cfg.get("enabled"), cfg.get("surface")) + return _ok(rid, {"started": False, "reason": reason}) + + 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 get_last_match, 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 + # Multi-phrase engines report WHICH phrase fired and the profile it + # belongs to, so one listener can wake any enrolled profile. Falls + # back to the owner's configured phrase / no profile for + # single-phrase engines. + matched_phrase, matched_profile = get_last_match() or (phrase, "") + logger.info("wake.detected: emitting to sid=%r (transport=%s, profile=%r)", + sid, type(transport).__name__, matched_profile) + token = bind_transport(transport) + try: + _emit("wake.detected", sid, { + "phrase": matched_phrase or phrase, + "profile": matched_profile or None, + "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, + "enabled_persisted": enabled_persisted, + }) + + +@method("wake.stop") +def _(rid, params: dict) -> dict: + """Stop this surface's listener. + + ``persist: true`` (explicit user gesture) also writes + ``wake_word.enabled: false`` to config.yaml so auto-arm stays off in + future sessions — the toggle is the config, not just the live listener. + """ + transport = current_transport() or _stdio_transport + stopped = _release_wake_for_transport(transport) + disabled_persisted = False + if bool(params.get("persist")): + try: + from tools.wake_word import load_wake_word_config + + currently_enabled = bool(load_wake_word_config().get("enabled")) + except Exception: + currently_enabled = True + if currently_enabled: + disabled_persisted = _persist_wake_enabled(False) + return _ok(rid, { + "stopped": stopped, + "reason": None if stopped else "not_owner", + "disabled_persisted": disabled_persisted, + }) + + +@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 ( + audio_is_silent, + 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) + listening = owned_by_caller and is_listening() + silent = listening and audio_is_silent() + hint = reqs.get("hint", "") + if silent and not hint: + hint = ("Microphone delivers only silence — on macOS grant the " + "Hermes backend mic access (System Settings > Privacy & " + "Security > Microphone), then toggle the wake word.") + return _ok(rid, { + "listening": 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": hint, + # Config truth: clients use this to re-arm after a voice turn + # ("permanent on") without guessing from runtime listener state. + "enabled": bool(cfg.get("enabled")), + # Armed but deaf (macOS permission failure mode) — see hint. + "audio_silent": silent, + }) + 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. @@ -17906,17 +18253,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 @@ -17942,6 +18295,32 @@ 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() + # voice.max_recording_seconds — hard cap on a single recording's # length. Same guard as the silence params: non-numeric / bool / # missing falls back to the documented 120 default, while an @@ -17953,17 +18332,16 @@ def _(rid, params: dict) -> dict: else 120.0 ) 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, max_recording_seconds=safe_max_rec, ) if started is False: + _resume_voice_wake() return _ok(rid, {"status": "busy"}) return _ok(rid, {"status": "recording"}) @@ -17974,12 +18352,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)) diff --git a/tui_gateway/ws.py b/tui_gateway/ws.py index df2dcda322b..b3b6c4bcc1c 100644 --- a/tui_gateway/ws.py +++ b/tui_gateway/ws.py @@ -425,6 +425,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 diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index 42bf75e74d2..451edcfc591 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -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() diff --git a/ui-tui/src/__tests__/wakeCommand.test.ts b/ui-tui/src/__tests__/wakeCommand.test.ts new file mode 100644 index 00000000000..32f86b1a67e --- /dev/null +++ b/ui-tui/src/__tests__/wakeCommand.test.ts @@ -0,0 +1,208 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { wakeCommands } from '../app/slash/commands/wake.js' +import { isWakeUserDisabled, setWakeUserDisabled } from '../app/wakeState.js' + +const wakeCommand = wakeCommands.find(cmd => cmd.name === 'wake')! + +const guarded = + (fn: (r: T) => void) => + (r: null | T) => { + if (r) { + fn(r) + } + } + +/** Build a ctx whose rpc routes by method name to a supplied map of results. */ +const buildCtx = (results: Record) => { + const sys = vi.fn() + + const rpc = vi.fn((method: string, _params: unknown) => Promise.resolve(results[method])) + + const ctx = { + gateway: { rpc }, + guarded, + guardedErr: vi.fn(), + sid: 'sid-1', + stale: () => false, + transcript: { page: vi.fn(), sys } + } + + const run = async (arg: string) => { + wakeCommand.run(arg, ctx as any, `/wake${arg ? ` ${arg}` : ''}`) + await rpc.mock.results[0]?.value + await Promise.resolve() + await Promise.resolve() + } + + return { ctx, rpc, run, sys } +} + +const printed = (sys: ReturnType) => sys.mock.calls.map(c => c[0]).join('\n') + +describe('/wake slash command', () => { + beforeEach(() => { + vi.clearAllMocks() + setWakeUserDisabled(false) + }) + + it('registers with usage metadata', () => { + expect(wakeCommand).toBeDefined() + expect(wakeCommand.usage).toBe('/wake [on|off|status]') + }) + + it('/wake on calls wake.start with surface tui and reports listening', async () => { + const { rpc, run, sys } = buildCtx({ + 'wake.start': { phrase: 'hey hermes', provider: 'openwakeword', started: true } + }) + + await run('on') + + expect(rpc).toHaveBeenCalledWith('wake.start', { persist: true, surface: 'tui' }) + expect(printed(sys)).toContain('listening') + expect(printed(sys)).toContain('hey hermes') + expect(printed(sys)).toContain('openwakeword') + }) + + it('/wake on clears the session opt-out flag', async () => { + setWakeUserDisabled(true) + + const { run } = buildCtx({ 'wake.start': { started: true } }) + + await run('on') + + expect(isWakeUserDisabled()).toBe(false) + }) + + it('/wake on prints the reason when the gateway refuses', async () => { + const { run, sys } = buildCtx({ + 'wake.start': { owner_surface: 'gui', reason: 'owned', started: false } + }) + + await run('on') + + const out = printed(sys) + expect(out).toContain('not started') + expect(out).toContain('another surface owns the listener') + expect(out).toContain('gui') + }) + + it('/wake on surfaces the hint when unavailable', async () => { + const { run, sys } = buildCtx({ + 'wake.start': { hint: 'pip install openwakeword', reason: 'unavailable', started: false } + }) + + await run('on') + + const out = printed(sys) + expect(out).toContain('unavailable') + expect(out).toContain('pip install openwakeword') + }) + + it('/wake off calls wake.stop, remembers the opt-out, and reports', async () => { + const { rpc, run, sys } = buildCtx({ 'wake.stop': { stopped: true } }) + + await run('off') + + expect(rpc).toHaveBeenCalledWith('wake.stop', { persist: true }) + expect(isWakeUserDisabled()).toBe(true) + expect(printed(sys)).toContain('listener off') + }) + + it('/wake on reports when the gesture also enabled the config flag', async () => { + const { run, sys } = buildCtx({ + 'wake.start': { enabled_persisted: true, phrase: 'hey hermes', provider: 'openwakeword', started: true } + }) + + await run('on') + + expect(printed(sys)).toContain('enabled in config') + }) + + it('/wake off reports when the gesture also disabled the config flag', async () => { + const { run, sys } = buildCtx({ 'wake.stop': { disabled_persisted: true, stopped: true } }) + + await run('off') + + expect(printed(sys)).toContain('disabled in config') + }) + + it('/wake off explains a not_owner refusal but still records the opt-out', async () => { + const { run, sys } = buildCtx({ 'wake.stop': { reason: 'not_owner', stopped: false } }) + + await run('off') + + expect(isWakeUserDisabled()).toBe(true) + expect(printed(sys)).toContain('nothing to stop') + expect(printed(sys)).toContain('doesn’t own the listener') + }) + + it('/wake status prints a listening one-liner', async () => { + const { rpc, run, sys } = buildCtx({ + 'wake.status': { + available: true, + listening: true, + owned_by_caller: true, + owner_surface: 'tui', + phrase: 'hey hermes', + provider: 'openwakeword' + } + }) + + await run('status') + + expect(rpc).toHaveBeenCalledWith('wake.status', {}) + + const out = printed(sys) + expect(out).toContain('listening') + expect(out).toContain('hey hermes') + expect(out).toContain('openwakeword') + }) + + it('bare /wake behaves like /wake status', async () => { + const { rpc, run } = buildCtx({ 'wake.status': { available: true, listening: false } }) + + await run('') + + expect(rpc).toHaveBeenCalledWith('wake.status', {}) + }) + + it('status reports another surface owning the listener', async () => { + const { run, sys } = buildCtx({ + 'wake.status': { + available: true, + listening: false, + owned_by_caller: false, + owner_surface: 'gui', + phrase: 'hey hermes' + } + }) + + await run('status') + + const out = printed(sys) + expect(out).toContain('off here') + expect(out).toContain('gui') + }) + + it('status surfaces the hint when the wake word is unavailable', async () => { + const { run, sys } = buildCtx({ + 'wake.status': { available: false, hint: 'no microphone detected', listening: false } + }) + + await run('status') + + const out = printed(sys) + expect(out).toContain('unavailable') + expect(out).toContain('no microphone detected') + }) + + it('rejects unknown subcommands with usage text', async () => { + const { rpc, run, sys } = buildCtx({}) + + await run('banana') + + expect(rpc).not.toHaveBeenCalled() + expect(printed(sys)).toContain('usage: /wake [on|off|status]') + }) +}) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index bbd82613860..338ea7ea498 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -32,6 +32,7 @@ import { flashGoodVibes, flashPet } from './petFlashStore.js' import { turnController } from './turnController.js' import { getTurnState } from './turnStore.js' import { getUiState, patchUiState } from './uiStore.js' +import { isWakeUserDisabled } from './wakeState.js' const NO_PROVIDER_RE = /\bNo (?:LLM|inference) provider configured\b/i @@ -621,6 +622,14 @@ 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. + // Skipped when the user explicitly ran `/wake off` this session — an + // explicit opt-out must survive gateway reconnects (see wakeState.ts). + if (!isWakeUserDisabled()) { + void rpc('wake.start', { surface: 'tui' }).catch(() => undefined) + } + rpc('commands.catalog', {}) .then(r => { if (!r?.pairs) { @@ -936,6 +945,47 @@ 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 () => { + // Multi-profile routing: the TUI is a single-profile process, so a + // phrase enrolled by ANOTHER profile can't be routed here — surface + // the switch command instead of starting voice on the wrong profile. + const wakeProfile = ev.payload?.profile?.trim() + const ownProfile = getUiState().info?.profile_name || 'default' + + if (wakeProfile && wakeProfile !== ownProfile) { + sys(`wake phrase for profile '${wakeProfile}' — run: hermes -p ${wakeProfile} --tui`) + await rpc('wake.resume', {}).catch(() => undefined) + + return + } + + 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() : '' diff --git a/ui-tui/src/app/slash/commands/wake.ts b/ui-tui/src/app/slash/commands/wake.ts new file mode 100644 index 00000000000..8011b853809 --- /dev/null +++ b/ui-tui/src/app/slash/commands/wake.ts @@ -0,0 +1,132 @@ +import type { WakeStartResponse, WakeStatusResponse, WakeStopResponse } from '../../../gatewayTypes.js' +import { setWakeUserDisabled } from '../../wakeState.js' +import type { SlashCommand, SlashRunCtx } from '../types.js' + +const WAKE_SUBCOMMANDS = ['on', 'off', 'status'] as const + +type WakeSub = (typeof WAKE_SUBCOMMANDS)[number] + +const isWakeSub = (value: string): value is WakeSub => (WAKE_SUBCOMMANDS as readonly string[]).includes(value) + +// Friendly text for the gateway's wake.start refusal codes. Unknown codes +// fall through to the raw reason so new server-side codes stay visible. +const START_REASON_TEXT: Record = { + disabled: 'disabled (config wake_word.enabled)', + disabled_for_surface: 'scoped to another surface (config wake_word.surface)', + not_owner: 'another surface owns the listener', + owned: 'another surface owns the listener', + unavailable: 'unavailable' +} + +const startFailureLine = (r: WakeStartResponse): string => { + const reason = r.reason ?? 'unknown' + const base = START_REASON_TEXT[reason] ?? reason + const owner = r.owner_surface ? ` (owned by ${r.owner_surface})` : '' + const hint = r.hint?.trim() ? ` — ${r.hint.trim()}` : '' + + return `wake: not started — ${base}${owner}${hint}` +} + +const statusLine = (r: WakeStatusResponse): string => { + const phrase = r.phrase ? ` for “${r.phrase}”` : '' + const provider = r.provider ? ` · ${r.provider}` : '' + + if (r.listening) { + if (r.audio_silent) { + const hint = r.hint?.trim() ? ` — ${r.hint.trim()}` : '' + + return `wake: listening${phrase}${provider} · ⚠ mic delivers only silence${hint}` + } + + return `wake: listening${phrase}${provider}` + } + + if (r.owner_surface && !r.owned_by_caller) { + return `wake: off here · listener owned by ${r.owner_surface}${phrase}${provider}` + } + + if (r.available === false) { + const hint = r.hint?.trim() ? ` — ${r.hint.trim()}` : '' + + return `wake: unavailable${hint}` + } + + return `wake: off${phrase}${provider} · /wake on to arm` +} + +const runOn = (ctx: SlashRunCtx): void => { + setWakeUserDisabled(false) + + // persist: true — an explicit /wake on writes wake_word.enabled to config + // so the choice survives restarts (the backend only persists on gesture + // paths; reconnect auto-arm never does). + ctx.gateway + .rpc('wake.start', { persist: true, surface: 'tui' }) + .then( + ctx.guarded(r => { + if (!r.started) { + return ctx.transcript.sys(startFailureLine(r)) + } + + const phrase = r.phrase ? ` for “${r.phrase}”` : '' + const provider = r.provider ? ` · ${r.provider}` : '' + const saved = r.enabled_persisted ? ' · enabled in config' : '' + + ctx.transcript.sys(`wake: listening${phrase}${provider}${saved}`) + }) + ) + .catch(ctx.guardedErr) +} + +const runOff = (ctx: SlashRunCtx): void => { + // Remember the explicit opt-out so gateway reconnects don't re-arm the + // listener behind the user's back (see wakeState.ts). + setWakeUserDisabled(true) + + ctx.gateway + .rpc('wake.stop', { persist: true }) + .then( + ctx.guarded(r => { + const saved = r.disabled_persisted ? ' · disabled in config' : '' + + if (r.stopped) { + return ctx.transcript.sys(`wake: listener off${saved}`) + } + + const reason = r.reason === 'not_owner' ? 'this surface doesn’t own the listener' : (r.reason ?? 'not running') + + ctx.transcript.sys(`wake: nothing to stop — ${reason}${saved}`) + }) + ) + .catch(ctx.guardedErr) +} + +const runStatus = (ctx: SlashRunCtx): void => { + ctx.gateway + .rpc('wake.status', {}) + .then(ctx.guarded(r => ctx.transcript.sys(statusLine(r)))) + .catch(ctx.guardedErr) +} + +const WAKE_RUNNERS: Record void> = { + off: runOff, + on: runOn, + status: runStatus +} + +export const wakeCommands: SlashCommand[] = [ + { + help: "toggle the 'Hey Hermes' wake word listener [on|off|status]", + name: 'wake', + usage: '/wake [on|off|status]', + run: (arg, ctx) => { + const sub = arg.trim().toLowerCase() + + if (sub && !isWakeSub(sub)) { + return ctx.transcript.sys('usage: /wake [on|off|status]') + } + + WAKE_RUNNERS[sub && isWakeSub(sub) ? sub : 'status'](ctx) + } + } +] diff --git a/ui-tui/src/app/slash/registry.ts b/ui-tui/src/app/slash/registry.ts index f87d53f9bfe..6922280e16a 100644 --- a/ui-tui/src/app/slash/registry.ts +++ b/ui-tui/src/app/slash/registry.ts @@ -5,6 +5,7 @@ import { sessionCommands } from './commands/session.js' import { setupCommands } from './commands/setup.js' import { subscriptionCommands } from './commands/subscription.js' import { topupCommands } from './commands/topup.js' +import { wakeCommands } from './commands/wake.js' import type { SlashCommand } from './types.js' export const SLASH_COMMANDS: SlashCommand[] = [ @@ -13,6 +14,7 @@ export const SLASH_COMMANDS: SlashCommand[] = [ ...sessionCommands, ...subscriptionCommands, ...opsCommands, + ...wakeCommands, ...setupCommands, ...debugCommands ] diff --git a/ui-tui/src/app/wakeState.ts b/ui-tui/src/app/wakeState.ts new file mode 100644 index 00000000000..a9e527ecd24 --- /dev/null +++ b/ui-tui/src/app/wakeState.ts @@ -0,0 +1,15 @@ +// Session-scoped memory of an explicit `/wake off`. +// +// The gateway auto-arms the "Hey Hermes" listener on every `gateway.ready` +// (see createGatewayEventHandler.ts). When the user explicitly disables the +// listener with `/wake off`, a reconnect must NOT silently re-arm it — this +// module-level flag records that intent for the lifetime of the process. +// `/wake on` clears it. Deliberately not persisted: config (`wake_word.*`) +// remains the durable on/off switch; this is only per-session steering. +let wakeUserDisabled = false + +export const isWakeUserDisabled = (): boolean => wakeUserDisabled + +export const setWakeUserDisabled = (disabled: boolean): void => { + wakeUserDisabled = disabled +} diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 41c5063295e..954190d028a 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -400,6 +400,38 @@ export interface VoiceRecordResponse { text?: string } +// ── Wake word ──────────────────────────────────────────────────────── + +export interface WakeStartResponse { + enabled_persisted?: boolean + hint?: string + owner_surface?: null | string + phrase?: string + provider?: string + reason?: string + started?: boolean +} + +export interface WakeStopResponse { + disabled_persisted?: boolean + reason?: null | string + stopped?: boolean +} + +export interface WakeStatusResponse { + /** Armed but the mic delivers only silence (macOS backend-permission gap). */ + audio_silent?: boolean + available?: boolean + /** Config truth (wake_word.enabled). */ + enabled?: boolean + hint?: string + listening?: boolean + owned_by_caller?: boolean + owner_surface?: null | string + phrase?: string + provider?: string +} + // ── Tools (TS keeps configure since it resets local history) ───────── export interface ToolsConfigureResponse { @@ -589,6 +621,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; profile?: null | string; start_new_session?: boolean }; session_id?: string; type: 'wake.detected' } | { payload?: { reason?: string }; session_id?: string; type: 'dashboard.new_session_requested' } | { payload: { line: string }; session_id?: string; type: 'gateway.stderr' } | { diff --git a/uv.lock b/uv.lock index dee539ca975..ef3672e745e 100644 --- a/uv.lock +++ b/uv.lock @@ -19,6 +19,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/ed/c284543c08aa443a4ef2c8bd120be51da8433dd174c01749b5d87c333f22/agent_client_protocol-0.9.0-py3-none-any.whl", hash = "sha256:06911500b51d8cb69112544e2be01fc5e7db39ef88fecbc3848c5c6f194798ee", size = 56850, upload-time = "2026-03-26T01:20:59.252Z" }, ] +[[package]] +name = "ai-edge-litert" +version = "2.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-strenum" }, + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "protobuf" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/3d/41a85023e1c6cc76d895f3ba6ac7c22b0785db7e08d0827ebeb8a403eefc/ai_edge_litert-2.1.6-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:edf598814004e594b40c888f52cae59e950dbeffd821e83ba45d28db0a0aa3f5", size = 10031164, upload-time = "2026-07-01T21:42:50.986Z" }, + { url = "https://files.pythonhosted.org/packages/52/d5/164aaf69f60f72b7076900ef1cc6153bf50d82cd15202bdf1239c0dbfb1c/ai_edge_litert-2.1.6-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5adf0c9afde6151dc7f2989d039c800f3060d98d40bb5dfc95e426ad4eb3680b", size = 10033170, upload-time = "2026-07-01T21:42:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/ad/92/578b31c4c05afa9081a664cd86afe33c304fecd70de1c2a4d3a3b9ca51c9/ai_edge_litert-2.1.6-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:2e8a3f92fa407690189533bea8b64d49bb1b8a9e96f707ba27e1a64a9c3cc8cf", size = 10032950, upload-time = "2026-07-01T21:42:55.102Z" }, +] + [[package]] name = "aiofiles" version = "24.1.0" @@ -472,6 +490,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, ] +[[package]] +name = "backports-strenum" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/c7/2ed54c32fed313591ffb21edbd48db71e68827d43a61938e5a0bc2b6ec91/backports_strenum-1.3.1.tar.gz", hash = "sha256:77c52407342898497714f0596e86188bb7084f89063226f4ba66863482f42414", size = 7257, upload-time = "2023-12-09T14:36:40.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/50/56cf20e2ee5127b603b81d5a69580a1a325083e2b921aa8f067da83927c0/backports_strenum-1.3.1-py3-none-any.whl", hash = "sha256:cdcfe36dc897e2615dc793b7d3097f54d359918fc448754a517e6f23044ccf83", size = 8304, upload-time = "2023-12-09T14:36:39.905Z" }, +] + [[package]] name = "base58" version = "2.1.1" @@ -1718,6 +1745,16 @@ voice = [ { name = "numpy" }, { name = "sounddevice" }, ] +wake = [ + { name = "ai-edge-litert", marker = "sys_platform == 'darwin'" }, + { name = "numpy" }, + { name = "onnxruntime" }, + { name = "openwakeword" }, + { name = "pvporcupine" }, + { name = "sentencepiece" }, + { name = "sherpa-onnx" }, + { name = "sounddevice" }, +] web = [ { name = "fastapi" }, { name = "python-multipart" }, @@ -1734,6 +1771,7 @@ youtube = [ [package.metadata] requires-dist = [ { name = "agent-client-protocol", marker = "extra == 'acp'", specifier = "==0.9.0" }, + { name = "ai-edge-litert", marker = "sys_platform == 'darwin' and extra == 'wake'", specifier = "==2.1.6" }, { name = "aiohttp", marker = "extra == 'homeassistant'", specifier = "==3.14.1" }, { name = "aiohttp", marker = "extra == 'matrix'", specifier = "==3.14.1" }, { name = "aiohttp", marker = "extra == 'messaging'", specifier = "==3.14.1" }, @@ -1807,7 +1845,10 @@ requires-dist = [ { name = "modal", marker = "extra == 'modal'", specifier = "==1.3.4" }, { name = "nemo-relay", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'ARM64' and sys_platform == 'win32')", specifier = ">=0.6.0,<0.7" }, { 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 +1856,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" }, @@ -1834,13 +1876,16 @@ requires-dist = [ { name = "rich", specifier = "==14.3.3" }, { name = "ruamel-yaml", specifier = "==0.18.17" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.10" }, + { name = "sentencepiece", marker = "extra == 'wake'", specifier = "==0.2.2" }, { name = "setuptools", marker = "extra == 'dev'", specifier = "==81.0.0" }, + { name = "sherpa-onnx", marker = "extra == 'wake'", specifier = "==1.13.4" }, { name = "simple-term-menu", marker = "extra == 'cli'", specifier = "==1.6.6" }, { name = "slack-bolt", marker = "extra == 'messaging'", specifier = "==1.29.0" }, { name = "slack-bolt", marker = "extra == 'slack'", specifier = "==1.29.0" }, { 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.3.1" }, { name = "starlette", marker = "extra == 'dev'", specifier = "==1.3.1" }, { name = "starlette", marker = "extra == 'mcp'", specifier = "==1.3.1" }, @@ -1855,7 +1900,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" @@ -2139,6 +2184,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" @@ -2448,15 +2502,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" @@ -2599,6 +2644,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.6.0" @@ -2739,33 +2793,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]] @@ -2909,6 +2962,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" @@ -3181,6 +3252,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" @@ -3844,6 +3927,165 @@ 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" }, +] +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" }, +] +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 = "sentencepiece" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/33/ea3cb3839607eb175da835244a798f797f478c5ddf0e8ecdf57ea85a4c70/sentencepiece-0.2.2.tar.gz", hash = "sha256:3d2b5e824b5622038dc7b490897efe05ebbbb9e7350fc142f3ecc8789ef9bdf6", size = 8218435, upload-time = "2026-07-12T08:39:34.701Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/31/f23a2efaa0210b883574001b88fa64e499f798f0848a0b610fb9b384d162/sentencepiece-0.2.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:69e9dc8078e128286ed3b975e37c837ba96e215a50c3ef9f3f8b7ab9e5a832a0", size = 2184255, upload-time = "2026-07-12T08:38:14.855Z" }, + { url = "https://files.pythonhosted.org/packages/96/f2/1ee0ccb772d71e822f625d6cb5f0ea825835e877f28a9ef299a1291df19e/sentencepiece-0.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6dd76f3e5c8b2eb8a3a3efee787bbf5b9a66e52a048fe09cab85eca33fec6790", size = 1438545, upload-time = "2026-07-12T08:38:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/2a/92/3a6ea4a2c6dd9e7062698a5a33534ca0e20844883338ae9c6b9c122c1a9f/sentencepiece-0.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:443ac618c7a2a1377cf5c82581fbb849591d14e656d5e5a3e4682d4e36a34e4e", size = 1346997, upload-time = "2026-07-12T08:38:18.499Z" }, + { url = "https://files.pythonhosted.org/packages/f3/3a/7839048997c7bc0c34c57526f539f835e20c7a57dc2a99f99579b11cdbef/sentencepiece-0.2.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0e2aae42960392d6dcb9a72d8e1e65a97294c965071b43c7b3429a42f350250e", size = 1324282, upload-time = "2026-07-12T08:38:20.342Z" }, + { url = "https://files.pythonhosted.org/packages/06/5f/9117bf854aef817ad0d0ee9310eed0308a7e529e7eaf2e80ad9cd281ef82/sentencepiece-0.2.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1416b92f2f010333786fe6306ed2631121d5ea492219b0841e967b6765e64107", size = 1394242, upload-time = "2026-07-12T08:38:22.976Z" }, + { url = "https://files.pythonhosted.org/packages/ab/62/9e2569867e3dcff7ad6d89642a9615b9801b5cd698abe7df3b490361f66e/sentencepiece-0.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:70d4ca6f4d06df7f0ccab6fe4f49c8a712c8c8b6847b4f0af9a0e1dbb0e0337e", size = 1246268, upload-time = "2026-07-12T08:38:24.857Z" }, + { url = "https://files.pythonhosted.org/packages/96/c9/5d781d4ef1124564a45c98b9ff25d531c10cdf568ec6314a2d1946f9251c/sentencepiece-0.2.2-cp311-cp311-win_arm64.whl", hash = "sha256:252908153eeec06c3ca3a32077e64a49d572e3d89881475b4e0f02d99d9fcc7c", size = 1190702, upload-time = "2026-07-12T08:38:26.789Z" }, + { url = "https://files.pythonhosted.org/packages/b8/13/7a562289c8d5b49ebdf3f9c1e8ab67cf14a8743b1d90c8f406bfdec36b72/sentencepiece-0.2.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1edb10e520e4bddf74d85b0f5ae74cc2d60c2b448885080bfb618bc2b3a49f6b", size = 2188384, upload-time = "2026-07-12T08:38:28.486Z" }, + { url = "https://files.pythonhosted.org/packages/85/d1/912f14fd5eae168aba726ffb6a9a2dc1c71fe7676c53da6f5c442b886d4a/sentencepiece-0.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7c06c751c19d923435a54bff4f7e66e728fad160e8da28254f133abc9725820", size = 1441553, upload-time = "2026-07-12T08:38:30.552Z" }, + { url = "https://files.pythonhosted.org/packages/bd/44/caa9cab5f261a019e2808bc5046152775dc57352ba9cbae7525e9e7a1ed4/sentencepiece-0.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38111ed1f79268f399c505028023d5eaaf0ab4e5eafceb709468b0d3323e7838", size = 1347176, upload-time = "2026-07-12T08:38:32.211Z" }, + { url = "https://files.pythonhosted.org/packages/19/90/cd798935668cff71d309d8ff10385844ecf216b1fe454f1993ed8bf2cb91/sentencepiece-0.2.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cbce24284f51f71d10a42b7b9c964dcb9048b28f1c8e5db40bcbcb6f428cba6a", size = 1325200, upload-time = "2026-07-12T08:38:33.689Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2d/37e3da037318a70066ded0d51bc2a7f35491ae6338dd993d5eb1503fc3b5/sentencepiece-0.2.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c8a168b040bc61681293f79a949b5d911c8e25086f4260285b8d97ab5f1195da", size = 1397736, upload-time = "2026-07-12T08:38:35.771Z" }, + { url = "https://files.pythonhosted.org/packages/8d/11/753fca2e6b109be3ab7867abf357dfe48677fe726ae5a5363d0b54ca9450/sentencepiece-0.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:7c6e7bf684dc12145bfa685d3060beaea55139134ba848289bee514ed42e7383", size = 1248030, upload-time = "2026-07-12T08:38:37.604Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0a/70efbe861ca182d7d4b6e1a20f58e043400848fa9f2915229f082e221648/sentencepiece-0.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:76ff5814db72e7462dece042d7593cdf102b8ec82c2b1cc201a2add34ee3050d", size = 1187325, upload-time = "2026-07-12T08:38:39.348Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a3/b3b05095c174d6e80d37d5ddc2f57c2c56237333e7bbd6079cf3243c2a8a/sentencepiece-0.2.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:77c3ce990b23441e5ecfa5bce181fd6f408b564aeb6d7e1d1e7de9c5612501c8", size = 2188346, upload-time = "2026-07-12T08:38:41.089Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f3/72ebc4acb10a06bcf7503fbc6091c8f5db68300f6aac4356c09e6c76e0e1/sentencepiece-0.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fd523c4992041faa5c2b3cde62253d11a96c30d73a34afe48a486e8e2254cd1c", size = 1441434, upload-time = "2026-07-12T08:38:42.56Z" }, + { url = "https://files.pythonhosted.org/packages/34/db/f9ea1a6844b4fa5dfe2312095cd866a1f724cd0905054ab9d5991778ba50/sentencepiece-0.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:201a8e0f55501a76e08dbf2c54bc45f4642b379271e89c667d517bfbc2191f2a", size = 1347267, upload-time = "2026-07-12T08:38:44.389Z" }, + { url = "https://files.pythonhosted.org/packages/32/4f/31c1073314ad94466bca37d29581761d70110237ee3d46b0efece59a8c1e/sentencepiece-0.2.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8eed98514bffe5ecac37f493f91869c351fbb05629328bfdbc08502c6c094dc0", size = 1324980, upload-time = "2026-07-12T08:38:46.304Z" }, + { url = "https://files.pythonhosted.org/packages/59/b4/a0356fa04d6a14337a6e0e443556785a0422c53ec58baae6b9568120eb0f/sentencepiece-0.2.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64b656f025355cf8c51abe9fbe3848540756c6d7ca5e6791b1afa664bc24c7cb", size = 1397593, upload-time = "2026-07-12T08:38:48.302Z" }, + { url = "https://files.pythonhosted.org/packages/09/fa/d2d6369257fd2f0de616b1c7110b73fab409ef61b14f1b9e0010ed325914/sentencepiece-0.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:74f0ee601047c0c12a783088b51be4e6214a62ecd9e02278c477433cd16e0ed9", size = 1247987, upload-time = "2026-07-12T08:38:50.15Z" }, + { url = "https://files.pythonhosted.org/packages/17/ee/2bb594da6fd95e32f29057f1aa7fa996701b8980090923c2d8711fdc0a24/sentencepiece-0.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:b23fe17779834d3c27aaf2edac9486d04cca1a7deb8f5facda35150ac6263a91", size = 1187250, upload-time = "2026-07-12T08:38:52.246Z" }, + { url = "https://files.pythonhosted.org/packages/58/9c/dfc82846460e7a712310f5613f23d8b553cabb4e2e648663c11d8382af56/sentencepiece-0.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:72b7825b331b1b7e7c45be2e674b3e3c65af608fa376bad2d851b20aaf0cdc78", size = 2223080, upload-time = "2026-07-12T08:38:54.391Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4e/3ff12cebe6d31662d9ceeabfb282de20bd0d6098fa282b4a3b8305abc7e8/sentencepiece-0.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d795c4ac689a57f9d4ba2288126ec7901d389ad5827d2f8b8533c883974fe563", size = 1458511, upload-time = "2026-07-12T08:38:56.811Z" }, + { url = "https://files.pythonhosted.org/packages/59/5a/16d51d05360be4cee3ebfe4837c184054c4eed16cabaeb3b039524e9a000/sentencepiece-0.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ab3f1ae98970b5590e2209341522718900ba19bcc2c207ffaa6bd417ad960c5", size = 1361138, upload-time = "2026-07-12T08:38:58.808Z" }, + { url = "https://files.pythonhosted.org/packages/0f/af/c30ee2a9f99d51db9844acaa8fa0b611a97c2fa7116646fa43db3300b187/sentencepiece-0.2.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec27c152a1f1b24bc9168b55a5880f3c16e2334e697da6f55a1046a22405a3d", size = 1328625, upload-time = "2026-07-12T08:39:00.849Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1a/4c6b39d03f5ba8439509adbd5a23c9538088a3cb679e7a47b911e8442bc6/sentencepiece-0.2.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59d6588712101ccfcae9b03692be3aaae1514c2078666d7b05f15ba3a702e41b", size = 1398595, upload-time = "2026-07-12T08:39:02.86Z" }, + { url = "https://files.pythonhosted.org/packages/0f/bc/9eedddcec1fd57bc70200fa3ebf792d18fa63527a5369581cd416c81f97f/sentencepiece-0.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:89625fb43765cccaa1443b9adb61f283e5fe4cb1536728205d06bada730caa53", size = 1259346, upload-time = "2026-07-12T08:39:04.559Z" }, + { url = "https://files.pythonhosted.org/packages/41/15/7e74c8533848866ff560b29f7d8719921b76c4ec7149592d6d28e0deee75/sentencepiece-0.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:4f0603267cd15b92b68c2c0e852a441507614b70dc7773659baa6b8c214a91fd", size = 1196596, upload-time = "2026-07-12T08:39:06.454Z" }, +] + [[package]] name = "setuptools" version = "81.0.0" @@ -3862,6 +4104,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "sherpa-onnx" +version = "1.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/f8/735244770b4bc63f85fabdad0e46d6ec1f4cc24e64f6e082c2e0fea92b8c/sherpa_onnx-1.13.4.tar.gz", hash = "sha256:29547692418513ad88034c2b5f98985e33042b2351e4ab375469f19a8de18c5f", size = 982750, upload-time = "2026-07-07T13:04:55.145Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/4f/6ab541c5b2a4b2a6e9970edc4b558f270f7b6624c861eccb85126ccd279c/sherpa_onnx-1.13.4-cp311-cp311-linux_armv7l.whl", hash = "sha256:8e1cdbd53b432630a81ea479ce5bad6aa8192eb4a458d8c9432c54052cb9cc7d", size = 11930819, upload-time = "2026-07-07T14:22:29.536Z" }, + { url = "https://files.pythonhosted.org/packages/1d/41/b750ec336f882e75c5e23c9ad5d52b0902be2337cc50d13ce68cde9e4459/sherpa_onnx-1.13.4-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:5d35aeb5ad13b54cea0d6fed681660f6308acb841de981745e33d457255b9134", size = 4349088, upload-time = "2026-07-07T13:14:19.408Z" }, + { url = "https://files.pythonhosted.org/packages/41/c8/a2ff828ce9a2702b607c25f6303b21d7d41b6ad1520f660b95a0113f4051/sherpa_onnx-1.13.4-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c3e9f96a07570faefca8e3aabcfb78690e680d188d5d44d06fd8711185fe37d6", size = 2288314, upload-time = "2026-07-07T12:08:36.643Z" }, + { url = "https://files.pythonhosted.org/packages/e8/93/4385fcdb1f197521fe13fa887893cc200d27569d3cbcccf0d7b92d6a9e62/sherpa_onnx-1.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c2d4337b80b54dd68f566cab941a2ad47ab6cfabb68e88002ee0c920493c16d3", size = 2100029, upload-time = "2026-07-07T12:23:26.936Z" }, + { url = "https://files.pythonhosted.org/packages/ea/cb/c80832f800719c72fc5805a94fe489e805fc67156e102927609917ad8f67/sherpa_onnx-1.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7c4a1c178eb801af92f70120128c1f956b5388b9d684f0af2a08614e05dc3047", size = 4132838, upload-time = "2026-07-07T11:55:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ac/88b4e1ce614ddebe2484e95cad4b19d7db24f3b489d04f9877667cb48ccb/sherpa_onnx-1.13.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fbfc385b98d730080e1b12dc94c604be3867e4fb7bd6b15b830eb33cbf390111", size = 4356406, upload-time = "2026-07-07T12:42:29.823Z" }, + { url = "https://files.pythonhosted.org/packages/b5/73/2fff5d28669e91851e981d223c50aa21f90d712a4551dcb51c231b3a27fe/sherpa_onnx-1.13.4-cp311-cp311-win32.whl", hash = "sha256:1d746b8c6ed1ce9eb94868d71b5ea9c22274b8cb166420bd1772f7df470b753a", size = 1927745, upload-time = "2026-07-07T12:33:50.17Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b1/ae1c113ac9c67dcabbed559f50950c7220a62e49e6b5acb4c2219ab22409/sherpa_onnx-1.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:04a82d79c13a4ce2bd9ccf51de93e83cbfc7bc50520c53e5e967565100d0724d", size = 2239901, upload-time = "2026-07-07T12:22:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/8e/57/179e3a6c1fec33aa6535051feddd5da36e5622d35630b12a67a2805b76b3/sherpa_onnx-1.13.4-cp312-cp312-linux_armv7l.whl", hash = "sha256:bcf64f2d853a1afe236e9e220df62f2f53ef6ad792ca7e406d6173ec003319b8", size = 11933213, upload-time = "2026-07-07T13:50:35.989Z" }, + { url = "https://files.pythonhosted.org/packages/e8/34/b6d3483b08ec8a4a141e978c4b92530fd0a61dd571a575c1fe24bee300d7/sherpa_onnx-1.13.4-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:2257545ea170f58b7977309793979d6d078761b7fdb0528561285f8ead4169db", size = 4422157, upload-time = "2026-07-07T12:29:40.234Z" }, + { url = "https://files.pythonhosted.org/packages/82/37/07f03e97f157b206f6e62d722ec7c5ff41c7e9dc6aa2dc7de69b57e39b5a/sherpa_onnx-1.13.4-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:02b57dc2c829976eb842e6aee6a0e4ac3b9991aeb5afa89fd44eb71d848a4ecd", size = 2345135, upload-time = "2026-07-07T12:10:19.957Z" }, + { url = "https://files.pythonhosted.org/packages/fc/79/ee999f0c3b7789077d0939716a38234573d139f851a31409aa028fe2c610/sherpa_onnx-1.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:84e58b5a074b97c5307c9b6221d1d20fbf412a1a5dff4960ca9c32bb5184219f", size = 2105219, upload-time = "2026-07-07T11:48:22.518Z" }, + { url = "https://files.pythonhosted.org/packages/f4/90/9b67ed3e7adc79daf0ba49c4936a691521488125b04fe469b64a8b5398ff/sherpa_onnx-1.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f709e6dd02ebf7d37dcb02d5eadc5fb66c9922dd5809df770c1ef5d625ae7a44", size = 4135963, upload-time = "2026-07-07T11:58:30.98Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b1/8dfe5d1d72c92ea1c95db999a95b61bfbb9769f1c569f06e572eda095c52/sherpa_onnx-1.13.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f0158f3513d3adab1ebba0c26f0c815e53ba13b96846d92ef095ae25d648860", size = 4358555, upload-time = "2026-07-07T12:58:59.654Z" }, + { url = "https://files.pythonhosted.org/packages/d4/04/cfd543933ae24430d124e533847c73a84a5f0efd60f07bbc6403032f9624/sherpa_onnx-1.13.4-cp312-cp312-win32.whl", hash = "sha256:d49928a3455bae1dd4e93f6b013cfbd2c3ccb5cde74aabae3710b656e7d79b6b", size = 1930647, upload-time = "2026-07-07T12:02:06.689Z" }, + { url = "https://files.pythonhosted.org/packages/bb/bb/1e723ab703a1e354f390de19981ec0c347576f87be01915d826dc6fc9f41/sherpa_onnx-1.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:b8436ffe2763b3fd522fbac8fe53f47d611721c84819c241acfb65d122403d7d", size = 2244142, upload-time = "2026-07-07T13:01:42.293Z" }, + { url = "https://files.pythonhosted.org/packages/4b/36/45b17335f041f1383f6fd142ab57c2d8a337ba2386b7547b125ec9d780af/sherpa_onnx-1.13.4-cp313-cp313-linux_armv7l.whl", hash = "sha256:9e98dc5e0559ad953f227fc884958c71b10c65a93667331405e7d4441ed5f76d", size = 11932654, upload-time = "2026-07-07T14:18:59.791Z" }, + { url = "https://files.pythonhosted.org/packages/68/d7/1e9a7dedab2da8af1a8417b4f4d5f496bd7700a71b59c5e085de5e10761b/sherpa_onnx-1.13.4-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:083747c2d0362ead0501cc773a618be19862a800b3f8f259d3bd3486f1494af4", size = 4372494, upload-time = "2026-07-07T13:02:05.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/09aa9461e8bdf894ba8466e047e40fb5da8aaa6d68c19cf1e2aabe01e706/sherpa_onnx-1.13.4-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:b4f54363b264b16148a724b4442f00cda97fcd4e9beeda3d75637753910e8557", size = 2307539, upload-time = "2026-07-07T12:28:22.056Z" }, + { url = "https://files.pythonhosted.org/packages/21/ed/d07787dd4be4119e6587c840f6b417c2d57c14d694d334af609d68cb5a41/sherpa_onnx-1.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8ec5394b4ea73bf01e6883cf078348f87350f4eb3567d51d92cae77ea2582403", size = 2115586, upload-time = "2026-07-07T12:47:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/6d/c2/281d84dc9e448ea99d7fb77708cbe1cc7cfd8c7d669727dc94385a9e4ca5/sherpa_onnx-1.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a39352ceb2ec6671a1f252fb768fff75bb2f0bc849cca5f66f490e89910a860d", size = 4136268, upload-time = "2026-07-07T12:10:09.762Z" }, + { url = "https://files.pythonhosted.org/packages/db/47/da3ea14ab647a4f6580227853fe29353e1173ff77064d42c0bb31d01b453/sherpa_onnx-1.13.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:88af596be24eac32982dd64fcac30af99d9130ca498bfa1a0064189c8498195b", size = 4358385, upload-time = "2026-07-07T13:10:10.265Z" }, + { url = "https://files.pythonhosted.org/packages/44/90/84205ff383ba9335c3821c2cd6d514350f52199cb640ec094517f1f911a0/sherpa_onnx-1.13.4-cp313-cp313-win32.whl", hash = "sha256:0cabb508a15be22138f9fb7695d7ec5f3893ecd088ee419b9df559fed7e8f649", size = 1929707, upload-time = "2026-07-07T12:51:53.196Z" }, + { url = "https://files.pythonhosted.org/packages/82/40/ee8a0a8c83fc6d7f5245a5a031e471d3b115e20cce867e7abb2f9d4185c9/sherpa_onnx-1.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:17050fdfb48d37ae996364f697c554a1399740d18e5a56b143c011d00cfed3e0", size = 2244504, upload-time = "2026-07-07T12:32:59.882Z" }, +] + [[package]] name = "simple-term-menu" version = "1.6.6" @@ -4012,18 +4286,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" @@ -4063,6 +4325,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, ] +[[package]] +name = "tflite-runtime" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/a6/02d68cb62cd221589a0ff055073251d883936237c9c990e34a1d7cecd06f/tflite_runtime-2.14.0-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:195ab752e7e57329a68e54dd3dd5439fad888b9bff1be0f0dc042a3237a90e4d", size = 2414486, upload-time = "2023-10-03T21:15:44.331Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e9/5fc0435129c23c17551fcfadc82bd0d5482276213dfbc641f07b4420cb6d/tflite_runtime-2.14.0-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:ce9fa5d770a9725c746dcbf6f59f3178233b3759f09982e8b2db8d2234c333b0", size = 2325913, upload-time = "2023-10-03T21:15:46.348Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/e246c39d92929655bac8878d76406d6fb0293c678237e55621e7ece4a269/tflite_runtime-2.14.0-cp311-cp311-manylinux_2_34_armv7l.whl", hash = "sha256:c4e66a74165b18089c86788400af19fa551768ac782d231a9beae2f6434f7949", size = 1820588, upload-time = "2023-10-03T21:15:48.399Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + [[package]] name = "tokenizers" version = "0.22.2" diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 948a118c97f..99f87241512 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -154,6 +154,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | `KREA_API_KEY` | Krea API key for Krea 2 image generation ([krea.ai](https://krea.ai/)) | | `GROQ_API_KEY` | Groq Whisper STT API key ([groq.com](https://groq.com/)) | | `ELEVENLABS_API_KEY` | ElevenLabs premium TTS voices ([elevenlabs.io](https://elevenlabs.io/)) | +| `PORCUPINE_ACCESS_KEY` | Picovoice Porcupine wake-word engine ([console.picovoice.ai](https://console.picovoice.ai/)) — only for `wake_word.provider: porcupine`; the default openWakeWord and sherpa engines need no key | | `STT_GROQ_MODEL` | Override the Groq STT model (default: `whisper-large-v3-turbo`) | | `GROQ_BASE_URL` | Override the Groq OpenAI-compatible STT endpoint | | `STT_OPENAI_MODEL` | Override the OpenAI STT model (default: `whisper-1`) | diff --git a/website/docs/user-guide/features/overview.md b/website/docs/user-guide/features/overview.md index 75f365b3892..094b2962260 100644 --- a/website/docs/user-guide/features/overview.md +++ b/website/docs/user-guide/features/overview.md @@ -32,6 +32,7 @@ Hermes Agent includes a rich set of capabilities that extend far beyond basic ch ## Media & Web - **[Voice Mode](voice-mode.md)** — Full voice interaction across CLI and messaging platforms. Talk to the agent using your microphone, hear spoken replies, and have live voice conversations in Discord voice channels. +- **[Wake Word](wake-word.md)** — Hands-free "Hey Hermes" trigger for the CLI, 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`. diff --git a/website/docs/user-guide/features/voice-mode.md b/website/docs/user-guide/features/voice-mode.md index 86d671f3062..fa6a0803cc4 100644 --- a/website/docs/user-guide/features/voice-mode.md +++ b/website/docs/user-guide/features/voice-mode.md @@ -10,6 +10,8 @@ Hermes Agent supports full voice interaction across CLI and messaging platforms. If you want a practical setup walkthrough with recommended configurations and real usage patterns, see [Use Voice Mode with Hermes](/guides/use-voice-mode-with-hermes). +For hands-free session start — saying "hey hermes" (or any phrase) to open a fresh voice session on the CLI, TUI, or desktop app — see [Wake Word](/user-guide/features/wake-word). + ## Prerequisites Before using voice features, make sure you have: diff --git a/website/docs/user-guide/features/wake-word.md b/website/docs/user-guide/features/wake-word.md new file mode 100644 index 00000000000..e8438d765f2 --- /dev/null +++ b/website/docs/user-guide/features/wake-word.md @@ -0,0 +1,275 @@ +--- +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. + +On the desktop app, a hands-free voice conversation can be ended by simply +**saying "stop"** (or "never mind", "goodbye", "cancel", "that's all") — the +spoken command ends the conversation instead of being sent to the agent. Only a +whole-utterance stop command matches, so a real request like "stop the docker +container" still goes through normally. + +## 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 | +| **sherpa** | Free | None | **Open vocabulary** — detects ANY typed phrase with zero training. Small English model auto-downloads on first use (~13 MB) | +| **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 (desktop +installs made with `--include-desktop` pre-install them, so the ear works +instantly). 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 +``` + +In the desktop app, click the ear icon in the composer. + +The toggle IS the setting: turning the wake word on or off — via `/wake` or the +desktop ear button — also writes `wake_word.enabled` to `~/.hermes/config.yaml`, +so your choice persists across sessions. You can also flip it by hand: + +```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.6 # 0.0-1.0 — higher = stricter (fewer false triggers), consistent across all engines + confirmation_frames: 3 # openWakeWord only — consecutive over-threshold frames required to fire + 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: "" # "" (auto) | "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. + +### Reducing false triggers on ambient speech + +openWakeWord scores one short (~80ms) audio frame at a time, so a stray phoneme +in background conversation can occasionally spike a single frame over the +threshold and fire the wake word unintentionally. Two knobs control this: + +- **`confirmation_frames`** (default `3`, openWakeWord only) — how many + *consecutive* over-threshold frames are required before the wake fires. A real + "hey hermes" holds a high score across several frames; an ambient blip spikes + just one. Raise it (e.g. `4`–`5`) if you still get false triggers in a noisy + room; the cost is a few tens of milliseconds of extra latency. `1` restores + the old fire-on-first-frame behavior. +- **`sensitivity`** (default `0.6`) — the detection threshold, `0.0`–`1.0`. + Higher is stricter (fewer false triggers). This direction is consistent across + **all** engines — for openWakeWord it's the raw per-frame score threshold, for + sherpa it maps onto the keyword threshold, and for Porcupine it's inverted + internally so "higher = stricter" holds there too. The `0.6` default sits + above openWakeWord's permissive `0.5` baseline, which let near-misses like + "hey hor" through; raise toward `0.8` if you still get false fires, lower it + if real "hey hermes" utterances are missed. + +The `sherpa` and `porcupine` engines decode the whole phrase internally, so they +don't have the single-frame-spike problem and ignore `confirmation_frames` +(but they still honor `sensitivity`). + +`inference_framework` picks the openWakeWord backend. Leave it empty (the +default) to let Hermes choose per platform: **tflite on Apple Silicon**, onnx +everywhere else. openWakeWord's onnx backend returns near-zero scores on macOS +ARM64 ([openWakeWord#336](https://github.com/dscripka/openWakeWord/issues/336)), +so a listener pinned to `onnx` there will arm, show as listening, and never +fire. The tflite backend needs `ai-edge-litert` on macOS, which Hermes installs +on demand alongside the other wake-word deps. + +### 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, the easiest +path is the open-vocabulary engine: + +### Option A — sherpa (any phrase, zero training) + +Type the phrase you want; it's tokenized at runtime — "hey coder", +"computer", "wake up neo", anything: + +```yaml +wake_word: + enabled: true + provider: sherpa + phrase: "hey coder" # detection key — just type your phrase +``` + +The small English KWS model (~13 MB) downloads once on first use. Each +profile can set its own phrase — "hey \" for every profile you run. + +### Waking a specific profile (desktop) + +With the sherpa engine, ONE listener can wake ANY profile. Every profile +whose config has `wake_word.enabled: true` is enrolled automatically; its +phrase defaults to `hey ` when unset. Say a profile's phrase +and the desktop app live-switches to that profile, opens a fresh session +there, and starts hands-free voice: + +- "hey hermes" → default profile +- "hey coder" → the `coder` profile +- "hey trader" → the `trader` profile + +Set `wake_word.profile_routing: false` on the listener's profile to opt out +and listen only for its own phrase. The CLI and TUI are single-profile +processes: a wake phrase belonging to another profile prints the switch +command (`hermes -p `) instead of routing. + +Names are matched acoustically by their English subword sounds: two-word +phrases with distinct, 2+ syllable names work best. Very short names, heavy +non-English phonology, or two profiles with similar-sounding names will +degrade accuracy — tune per-profile `sensitivity` if needed. + +### Option B — openWakeWord (free, trained model) + +Name a built-in model (`hey_jarvis`, `alexa`, `hey_mycroft`, …), or train a +custom model (≈75–90 min on a free/Colab GPU) for maximum robustness, 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 +``` + +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 C — 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. +- A TTS provider for speaking the reply (the default `edge-tts` works with no + key). The wake flow is fully hands-free, so the toggle refuses to arm until + both STT and TTS are ready — `hermes tools` (Voice section) sets them up. +- The wake engine deps (auto-installed, or `hermes-agent[wake]`). + +`/wake status` reports exactly what's missing if the listener won't start. + +### "Listening" but never wakes (macOS) + +macOS grants microphone access per **process**. STT working in the desktop app +proves the *renderer* has mic access — the wake listener runs in the Python +*backend*, which needs its own grant. Without it, CoreAudio hands the backend a +"working" stream that only ever delivers silence, so the ear shows listening +but the phrase never fires. Hermes detects this (`/wake status` shows +"mic delivers only silence"; the desktop ear tooltip carries the same hint). +Fix: System Settings → Privacy & Security → Microphone → enable the Hermes +backend (it may appear as your terminal, `python`, or Hermes), then toggle the +wake word off and on. + +## 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. diff --git a/website/sidebars.ts b/website/sidebars.ts index bc4310097f5..1f4832611bb 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -109,6 +109,7 @@ const sidebars: SidebarsConfig = { label: 'Media & Web', items: [ 'user-guide/features/voice-mode', + 'user-guide/features/wake-word', 'user-guide/features/web-search', 'user-guide/features/x-search', 'user-guide/features/browser',