mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Merge pull request #70509 from NousResearch/hermes/hermes-29661bf6
feat(voice): on-device wake words with open-vocabulary phrases and multi-profile voice routing
This commit is contained in:
commit
7e7f7d3059
55 changed files with 5886 additions and 68 deletions
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<ModelPill compact={compactModelPill} disabled={disabled} model={state.model} />
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
|
||||
<WakeWordButton disabled={disabled} />
|
||||
{busyAction === 'steer' ? (
|
||||
<Tip label={<TipKeybindLabel actionId="composer.queue" text={c.queueMessage} />}>
|
||||
<Button
|
||||
|
|
@ -181,6 +185,9 @@ function ConversationPill({
|
|||
|
||||
return (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||
{/* Keep the ear visible during voice chat — shown paused, since the
|
||||
conversation holds the mic (the one time wake must not listen). */}
|
||||
<WakeWordButton disabled={disabled} pausedForVoice />
|
||||
<Tip label={muted ? c.unmuteMic : c.muteMic}>
|
||||
<Button
|
||||
aria-label={muted ? c.unmuteMic : c.muteMic}
|
||||
|
|
@ -294,6 +301,53 @@ function AutoSpeakButton({ active, disabled, onToggle }: { active: boolean; disa
|
|||
)
|
||||
}
|
||||
|
||||
// "Hey Hermes" wake-word toggle. ALWAYS rendered — the ear never hides. A
|
||||
// user must always be able to click it to turn passive listening on; if the
|
||||
// backend can't start (missing STT/TTS, deps still installing, no mic
|
||||
// permission, etc.) the click surfaces the reason in the tooltip and the
|
||||
// toggle stays off. States: listening (accent-highlighted), off (muted
|
||||
// ear-off), and paused-for-voice (disabled while a voice conversation holds
|
||||
// the mic — the one time wake genuinely must not listen). Backend refusals
|
||||
// ({started:false, reason}) keep the toggle off and put the reason/hint in
|
||||
// the tooltip.
|
||||
function WakeWordButton({ disabled, pausedForVoice = false }: { disabled: boolean; pausedForVoice?: boolean }) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const wake = useStore($wakeWord)
|
||||
|
||||
const phrase = wake.phrase || 'hey hermes'
|
||||
const label = pausedForVoice
|
||||
? c.wakeWordPausedVoice(phrase)
|
||||
: wake.listening
|
||||
? c.wakeWordListening(phrase)
|
||||
: c.wakeWordOff(phrase)
|
||||
const tooltip = !pausedForVoice && wake.notice ? `${label} — ${wake.notice}` : label
|
||||
|
||||
return (
|
||||
<Tip label={tooltip}>
|
||||
<Button
|
||||
aria-label={label}
|
||||
aria-pressed={wake.listening && !pausedForVoice}
|
||||
className={cn(
|
||||
GHOST_ICON_BTN,
|
||||
'p-0',
|
||||
wake.listening && !pausedForVoice && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary'
|
||||
)}
|
||||
disabled={disabled || pausedForVoice || wake.pending}
|
||||
onClick={() => {
|
||||
triggerHaptic(wake.listening ? 'close' : 'open')
|
||||
void toggleWakeWord()
|
||||
}}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{wake.listening && !pausedForVoice ? <Ear className={iconSize.sm} /> : <EarOff className={iconSize.sm} />}
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
function DictationButton({
|
||||
disabled,
|
||||
state,
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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<Promise<void> | 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), [])
|
||||
|
|
|
|||
|
|
@ -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> | void
|
||||
onTranscribeAudio?: (audio: Blob) => Promise<string>
|
||||
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> | 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<ConversationStatus>('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({
|
||||
|
|
|
|||
|
|
@ -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<typeof handleDesktopGatewayEvent>[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 =
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -1820,6 +1820,9 @@ export const ja = defineLocale({
|
|||
voiceDictation: '音声口述',
|
||||
speakReplies: '返信を読み上げる',
|
||||
stopSpeakingReplies: '返信の読み上げを停止',
|
||||
wakeWordListening: phrase => `ウェイクワード:「${phrase}」— 待機中`,
|
||||
wakeWordOff: phrase => `ウェイクワード:「${phrase}」— オフ`,
|
||||
wakeWordPausedVoice: phrase => `ウェイクワード:「${phrase}」— 音声チャット中は一時停止`,
|
||||
lookupLoading: '検索中…',
|
||||
lookupNoMatches: '一致なし。',
|
||||
lookupTry: '試す',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1763,6 +1763,9 @@ export const zhHant = defineLocale({
|
|||
voiceDictation: '語音聽寫',
|
||||
speakReplies: '朗讀回覆',
|
||||
stopSpeakingReplies: '停止朗讀回覆',
|
||||
wakeWordListening: phrase => `喚醒詞:「${phrase}」— 正在聆聽`,
|
||||
wakeWordOff: phrase => `喚醒詞:「${phrase}」— 已關閉`,
|
||||
wakeWordPausedVoice: phrase => `喚醒詞:「${phrase}」— 語音對話期間暫停`,
|
||||
lookupLoading: '查詢中…',
|
||||
lookupNoMatches: '沒有相符項目。',
|
||||
lookupTry: '試試',
|
||||
|
|
|
|||
|
|
@ -2156,6 +2156,9 @@ export const zh: Translations = {
|
|||
voiceDictation: '语音听写',
|
||||
speakReplies: '朗读回复',
|
||||
stopSpeakingReplies: '停止朗读回复',
|
||||
wakeWordListening: phrase => `唤醒词:"${phrase}" — 正在监听`,
|
||||
wakeWordOff: phrase => `唤醒词:"${phrase}" — 已关闭`,
|
||||
wakeWordPausedVoice: phrase => `唤醒词:"${phrase}" — 语音对话期间暂停`,
|
||||
lookupLoading: '查找中…',
|
||||
lookupNoMatches: '没有匹配项。',
|
||||
lookupTry: '试试',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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()) {
|
||||
|
|
|
|||
62
apps/desktop/src/lib/voice-stop-word.test.ts
Normal file
62
apps/desktop/src/lib/voice-stop-word.test.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
94
apps/desktop/src/lib/voice-stop-word.ts
Normal file
94
apps/desktop/src/lib/voice-stop-word.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
82
apps/desktop/src/lib/wake-sound.test.ts
Normal file
82
apps/desktop/src/lib/wake-sound.test.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
88
apps/desktop/src/lib/wake-sound.ts
Normal file
88
apps/desktop/src/lib/wake-sound.ts
Normal file
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
|
@ -2,17 +2,33 @@ import { afterEach, describe, expect, it } from 'vitest'
|
|||
|
||||
import {
|
||||
$composerAttachments,
|
||||
$voiceConversationStartRequest,
|
||||
addComposerAttachment,
|
||||
clearSessionDraft,
|
||||
type ComposerAttachment,
|
||||
migrateSessionDraft,
|
||||
removeComposerAttachment,
|
||||
requestVoiceConversationStart,
|
||||
SESSION_DRAFTS_STORAGE_KEY,
|
||||
stashSessionDraft,
|
||||
takeSessionDraft,
|
||||
takeVoiceConversationStart,
|
||||
updateComposerAttachment
|
||||
} from './composer'
|
||||
|
||||
describe('voice conversation start requests', () => {
|
||||
it('latches each request until the main composer consumes it once', () => {
|
||||
requestVoiceConversationStart()
|
||||
const first = $voiceConversationStartRequest.get()
|
||||
|
||||
expect(takeVoiceConversationStart(first)).toBe(true)
|
||||
expect(takeVoiceConversationStart(first)).toBe(false)
|
||||
|
||||
requestVoiceConversationStart()
|
||||
expect(takeVoiceConversationStart($voiceConversationStartRequest.get())).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
function attachment(overrides: Partial<ComposerAttachment> & Pick<ComposerAttachment, 'id'>): ComposerAttachment {
|
||||
return { kind: 'file', label: 'doc.pdf', ...overrides }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,24 @@ export const $composerDraft = atom('')
|
|||
export const $composerAttachments = atom<ComposerAttachment[]>([])
|
||||
export const $composerTerminalSelections = atom<Record<string, string>>({})
|
||||
|
||||
// Latched because opening a fresh session may remount the main composer before
|
||||
// it can start voice. Session-tile composers deliberately never consume this.
|
||||
export const $voiceConversationStartRequest = atom(0)
|
||||
let nextVoiceStartRequest = 0
|
||||
let handledVoiceStartRequest = 0
|
||||
|
||||
export const requestVoiceConversationStart = (): void => $voiceConversationStartRequest.set(++nextVoiceStartRequest)
|
||||
|
||||
export const takeVoiceConversationStart = (current: number): boolean => {
|
||||
if (current <= handledVoiceStartRequest) {
|
||||
return false
|
||||
}
|
||||
|
||||
handledVoiceStartRequest = current
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composer scopes — one live attachment set PER MOUNTED COMPOSER. The main
|
||||
// chat's scope wraps the module-level atom above (all existing readers keep
|
||||
|
|
|
|||
373
apps/desktop/src/store/wake-word.test.ts
Normal file
373
apps/desktop/src/store/wake-word.test.ts
Normal file
|
|
@ -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<string, unknown>) => unknown) =>
|
||||
vi.fn(async (method: string, params: Record<string, unknown> = {}) => 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<Record<string, unknown> | undefined> = []
|
||||
|
||||
const request = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
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 })
|
||||
})
|
||||
})
|
||||
290
apps/desktop/src/store/wake-word.ts
Normal file
290
apps/desktop/src/store/wake-word.ts
Normal file
|
|
@ -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<WakeWordState>(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 = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
|
||||
// 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 <T>(method: string, params: Record<string, unknown> = {}) => {
|
||||
const gateway = $gateway.get()
|
||||
|
||||
if (!gateway) {
|
||||
throw new Error('Hermes gateway unavailable')
|
||||
}
|
||||
|
||||
return method === 'wake.start'
|
||||
? gateway.request<T>(method, params, WAKE_START_TIMEOUT_MS)
|
||||
: gateway.request<T>(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<string, string> = {
|
||||
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<void> {
|
||||
try {
|
||||
const status = await request<WakeStatusResponse>('wake.status', {})
|
||||
applyWakeStatus(status)
|
||||
|
||||
if (!status?.available || status.listening) {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await request<WakeStartResponse>('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<void> {
|
||||
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<WakeStopResponse>('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<WakeStartResponse>('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<void>(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<void> {
|
||||
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<WakeStatusResponse>('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<WakeStartResponse>('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)
|
||||
}
|
||||
263
cli.py
263
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)
|
||||
|
||||
|
|
|
|||
1
contributors/emails/omid3098@gmail.com
Normal file
1
contributors/emails/omid3098@gmail.com
Normal file
|
|
@ -0,0 +1 @@
|
|||
omid3098
|
||||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1049
tests/tools/test_wake_word.py
Normal file
1049
tests/tools/test_wake_word.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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",),
|
||||
|
||||
|
|
|
|||
1122
tools/wake_word.py
Normal file
1122
tools/wake_word.py
Normal file
File diff suppressed because it is too large
Load diff
20
tools/wakewords/README.md
Normal file
20
tools/wakewords/README.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Bundled wake-word models
|
||||
|
||||
`hey_hermes.onnx` / `hey_hermes.tflite` — the on-device "Hey Hermes" hotword
|
||||
model. This is the default detector for the wake word feature (see
|
||||
`website/docs/user-guide/features/wake-word.md`); no training or setup is
|
||||
required to say "hey hermes".
|
||||
|
||||
- **Engine:** [openWakeWord](https://github.com/dscripka/openWakeWord) (Apache-2.0).
|
||||
- **Provenance:** trained with the openWakeWord training pipeline (synthetic
|
||||
TTS-generated speech), which produces both the `.onnx` and `.tflite` artifacts.
|
||||
Redistribution is permitted under the openWakeWord license.
|
||||
- **Label:** the model registers as `hey_hermes` (matches the filename).
|
||||
- **Runtime:** openWakeWord's shared feature-extraction models (melspectrogram +
|
||||
embedding) are NOT bundled here — they are fetched once on first use by
|
||||
`tools/wake_word.py` via `openwakeword.utils.download_models()`.
|
||||
|
||||
To use a different phrase, train your own model and point
|
||||
`wake_word.openwakeword.model` at its path, or set a built-in openWakeWord name
|
||||
(`hey_jarvis`, `alexa`, `hey_mycroft`, …). See the wake-word docs for the
|
||||
training guide.
|
||||
BIN
tools/wakewords/hey_hermes.onnx
Normal file
BIN
tools/wakewords/hey_hermes.onnx
Normal file
Binary file not shown.
BIN
tools/wakewords/hey_hermes.tflite
Normal file
BIN
tools/wakewords/hey_hermes.tflite
Normal file
Binary file not shown.
|
|
@ -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))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
208
ui-tui/src/__tests__/wakeCommand.test.ts
Normal file
208
ui-tui/src/__tests__/wakeCommand.test.ts
Normal file
|
|
@ -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 =
|
||||
<T>(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<string, unknown>) => {
|
||||
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<typeof vi.fn>) => 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]')
|
||||
})
|
||||
})
|
||||
|
|
@ -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<CommandsCatalogResponse>('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() : ''
|
||||
|
|
|
|||
132
ui-tui/src/app/slash/commands/wake.ts
Normal file
132
ui-tui/src/app/slash/commands/wake.ts
Normal file
|
|
@ -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<string, string> = {
|
||||
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<WakeStartResponse>('wake.start', { persist: true, surface: 'tui' })
|
||||
.then(
|
||||
ctx.guarded<WakeStartResponse>(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<WakeStopResponse>('wake.stop', { persist: true })
|
||||
.then(
|
||||
ctx.guarded<WakeStopResponse>(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<WakeStatusResponse>('wake.status', {})
|
||||
.then(ctx.guarded<WakeStatusResponse>(r => ctx.transcript.sys(statusLine(r))))
|
||||
.catch(ctx.guardedErr)
|
||||
}
|
||||
|
||||
const WAKE_RUNNERS: Record<WakeSub, (ctx: SlashRunCtx) => 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)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
15
ui-tui/src/app/wakeState.ts
Normal file
15
ui-tui/src/app/wakeState.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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' }
|
||||
| {
|
||||
|
|
|
|||
366
uv.lock
generated
366
uv.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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`) |
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
275
website/docs/user-guide/features/wake-word.md
Normal file
275
website/docs/user-guide/features/wake-word.md
Normal file
|
|
@ -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 \<profile\>" 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 <profile name>` 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 <profile>`) 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.
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue