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

+75 -3
View File
@@ -2,13 +2,21 @@
* Host side of the voice worker: spawns the utility process on demand, correlates
* requests and responses, restarts the worker if it crashes.
*/
import { utilityProcess, type UtilityProcess } from 'electron';
import { utilityProcess, type UtilityProcess, type WebContents } from 'electron';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import type { SynthesizeRequest, SynthesizeResult, TranscribeRequest, TranscribeResult, VoiceEngineStatus } from '../../shared/voice';
import { VOICE_IPC, type KwsDetection, type KwsStartRequest, type SynthesizeRequest, type SynthesizeResult, type TranscribeRequest, type TranscribeResult, type VoiceEngineStatus } from '../../shared/voice';
import { buildKeywordsFile, parseTokens } from '../../shared/keywords';
import { findModel } from './catalog';
import { isInstalled, modelDir, modelsDir } from './models';
import { log } from '../logger';
/** Renderer that receives keyword detections while spotting is active. */
let kwsSubscriber: WebContents | null = null;
let kwsActive = false;
let kwsRequest: KwsStartRequest | null = null;
interface Pending {
resolve: (value: unknown) => void;
reject: (err: Error) => void;
@@ -34,7 +42,15 @@ function spawn(): UtilityProcess {
child = proc;
proc.stdout?.on('data', (d: Buffer) => log('DEBUG', 'voice-worker', d.toString().trim()));
proc.stderr?.on('data', (d: Buffer) => log('WARN', 'voice-worker', d.toString().trim()));
proc.on('message', (msg: { id: number; ok: boolean; result?: unknown; error?: string }) => {
proc.on('message', (msg: { id?: number; type?: string; ok?: boolean; result?: unknown; error?: string; keyword?: string; at?: number }) => {
if (msg.type === 'kws.detected') {
if (kwsSubscriber && !kwsSubscriber.isDestroyed()) {
kwsSubscriber.send(VOICE_IPC.kwsDetected, { keyword: msg.keyword ?? '', at: msg.at ?? Date.now() } satisfies KwsDetection);
}
log('INFO', 'voice', `wake word detected: ${msg.keyword}`);
return;
}
if (typeof msg.id !== 'number') return;
const p = pending.get(msg.id);
if (!p) return;
pending.delete(msg.id);
@@ -48,6 +64,8 @@ function spawn(): UtilityProcess {
if (child !== proc) return;
child = null;
rejectAll('Le moteur vocal local s’est arrêté de façon inattendue.');
// Keyword spotting survives a worker restart: re-arm on the next audio frame.
if (kwsActive) kwsArmed = false;
});
log('INFO', 'voice', 'voice worker started');
return proc;
@@ -108,6 +126,60 @@ export async function synthesize(req: SynthesizeRequest): Promise<SynthesizeResu
return { ...result, wav: new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength) };
}
let kwsArmed = false;
const SENSITIVITY_THRESHOLD: Record<number, number> = { 1: 0.45, 2: 0.35, 3: 0.25, 4: 0.18, 5: 0.12 };
const SENSITIVITY_SCORE: Record<number, number> = { 1: 1.0, 2: 1.0, 3: 1.2, 4: 1.5, 5: 2.0 };
/** Start keyword spotting; the keywords file is derived from the phrases and the model vocabulary. */
export async function kwsStart(req: KwsStartRequest, sender: WebContents): Promise<{ accepted: string[]; rejected: string[] }> {
const spec = findModel(req.modelId);
if (!spec || spec.kind !== 'kws') throw new Error('Modèle de détection introuvable');
if (!isInstalled(spec)) throw new Error('Détecteur de mot-clé non installé (Paramètres → Modèles locaux).');
const dir = modelDir(spec);
const vocab = parseTokens(fs.readFileSync(path.join(dir, 'tokens.txt'), 'utf8'));
const phrases = req.keywords.map((k) => String(k).slice(0, 40)).filter(Boolean).slice(0, 8);
const file = buildKeywordsFile(phrases, vocab);
if (file.accepted.length === 0) throw new Error(`Aucun mot d’activation encodable : ${file.rejected.join(', ')}`);
const hash = createHash('sha1').update(file.content).digest('hex').slice(0, 10);
const keywordsFile = path.join(modelsDir(), `keywords-${hash}.txt`);
fs.writeFileSync(keywordsFile, file.content, 'utf8');
const sensitivity = Math.min(5, Math.max(1, Math.round(req.sensitivity))) as 1 | 2 | 3 | 4 | 5;
kwsSubscriber = sender;
kwsRequest = req;
await request({ type: 'kws.start', model: { id: spec.id, engine: spec.engine, dir, files: spec.files }, keywordsFile, threshold: SENSITIVITY_THRESHOLD[sensitivity], score: SENSITIVITY_SCORE[sensitivity] }, 60_000);
kwsActive = true;
kwsArmed = true;
log('INFO', 'voice', `keyword spotting on: ${file.accepted.join(', ')} (threshold ${SENSITIVITY_THRESHOLD[sensitivity]})`);
return { accepted: file.accepted, rejected: file.rejected };
}
export async function kwsStop(): Promise<void> {
kwsActive = false;
kwsArmed = false;
kwsSubscriber = null;
if (child) await request({ type: 'kws.stop' }, 10_000).catch(() => undefined);
}
/** Feed 16-bit PCM from the renderer (fire-and-forget). */
export function kwsFeed(pcm: Uint8Array, sampleRate: number): void {
if (!kwsActive) return;
const proc = spawn();
if (!kwsArmed) {
// Worker restarted: re-create the spotter before feeding audio.
if (kwsRequest && kwsSubscriber) {
kwsArmed = true;
kwsStart(kwsRequest, kwsSubscriber).catch((err) => log('WARN', 'voice', `kws re-arm failed: ${(err as Error).message}`));
}
return;
}
const b64 = Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength).toString('base64');
try {
proc.postMessage({ id: -1, type: 'kws.audio', pcm: b64, sampleRate });
} catch (err) {
log('WARN', 'voice', `kws feed failed: ${(err as Error).message}`);
}
}
/** Native model memory is only released deterministically by restarting the worker. */
export function unload(_modelId?: string): Promise<unknown> {
stopEngine();