feat: always-on wake word with sherpa-onnx keyword spotting (v2.2.0)

- Catalog: 3.3 MB zipformer keyword-spotting model (kws-en).
- shared/keywords: BPE encoding of wake phrases (SentencePiece table for common words,
  greedy longest-match fallback over the model vocabulary) and keywords file builder.
- Worker: KeywordSpotter stream fed with 16-bit PCM, detections pushed as unsolicited
  messages; engine derives the keywords file, maps sensitivity to threshold/score,
  forwards detections to the renderer and re-arms after a worker restart.
- Renderer: WakeListener keeps one microphone stream, batches 256 ms frames to the
  spotter and captures the command on the same stream after detection (pre-roll, VAD),
  then resumes spotting; the wake word also interrupts speech. Settings: wake mode
  (off / always-on / transcript filter), keyword, sensitivity, status and one-click
  model download; HUD caption shows the active keyword.
- Validated: detection in the worker (fork) and through the real Electron IPC path on
  Kokoro audio, no false positive on an English recording; 23 unit tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y
This commit is contained in:
Claude committed 2026-09-03 18:21:35 +00:00
1 parent 6d2f8f4ec9
commit 0d284fea9e
20 files changed
+760 -34

No files matched your search

+6
View File
@@ -19,6 +19,9 @@ export interface VoiceSettings extends SttConfig {
/** Hands-free: only react to utterances starting with this word (local STT recommended). */
wakeWordEnabled: boolean;
wakeWord: string;
/** off = push-to-talk / hands-free; transcript = filter after transcription; kws = always-on keyword spotting. */
wakeMode: 'off' | 'transcript' | 'kws';
kwsSensitivity: number; // 1..5
}
export interface SpeechSettings extends TtsConfig {
@@ -83,6 +86,8 @@ export const DEFAULT_SETTINGS: Settings = {
wakeChime: true,
wakeWordEnabled: false,
wakeWord: 'jarvis',
wakeMode: 'off',
kwsSensitivity: 3,
localModel: 'whisper-base'
},
speech: {
@@ -181,6 +186,7 @@ export const useSettings = create<SettingsStore>((set, get) => ({
}
const merged = merge(DEFAULT_SETTINGS, saved);
if (!merged.hermesSessionId) merged.hermesSessionId = uid('eveflow');
if (saved && saved.voice && saved.voice.wakeMode === undefined && saved.voice.wakeWordEnabled) merged.voice.wakeMode = 'transcript';
set({ settings: merged, loaded: true });
persistSet(STORAGE_KEY, merged);
},
+7
View File
@@ -2,6 +2,7 @@ import { create } from 'zustand';
import type { TtsState } from '../services/voice/tts';
export type ListenPhase = 'off' | 'arming' | 'listening' | 'speech' | 'transcribing';
export type WakeState = 'off' | 'starting' | 'spotting' | 'error';
interface VoiceStore {
phase: ListenPhase;
@@ -12,6 +13,9 @@ interface VoiceStore {
interim: string;
error: string | null;
micDevices: Array<{ deviceId: string; label: string }>;
wake: WakeState;
wakeKeywords: string[];
setWake: (state: WakeState, keywords?: string[]) => void;
setPhase: (phase: ListenPhase) => void;
setInputLevel: (level: number) => void;
setTts: (state: TtsState) => void;
@@ -31,6 +35,9 @@ export const useVoice = create<VoiceStore>((set) => ({
interim: '',
error: null,
micDevices: [],
wake: 'off',
wakeKeywords: [],
setWake: (wake, wakeKeywords) => set(wakeKeywords ? { wake, wakeKeywords } : { wake }),
setPhase: (phase) => set({ phase }),
setInputLevel: (inputLevel) => set({ inputLevel }),
setTts: (tts) => set({ tts }),