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

+3
View File
@@ -35,6 +35,8 @@ export function CoreStage({ compact }: Props) {
const transcript = useVoice((s) => s.lastTranscript);
const interim = useVoice((s) => s.interim);
const voiceError = useVoice((s) => s.error);
const wake = useVoice((s) => s.wake);
const wakeKeywords = useVoice((s) => s.wakeKeywords);
const assistantName = useSettings((s) => s.settings.assistantName);
const reduceMotion = useSettings((s) => s.settings.ui.reduceMotion);
const transport = useHermes((s) => s.transport);
@@ -57,6 +59,7 @@ export function CoreStage({ compact }: Props) {
else if (hud === 'error') sub = previewText(error ?? voiceError ?? 'erreur', 90);
else if (hud === 'speaking') sub = 'synthèse vocale';
else if (transcript) sub = `« ${previewText(transcript, 80)} »`;
else if (wake === 'spotting') sub = `dites « ${wakeKeywords[0] ?? 'jarvis'} »${link === 'online' ? ` · ${transport}` : link === 'offline' ? ' · Hermes hors ligne' : ''}`;
else sub = link === 'online' ? `liaison Hermes · ${transport}` : link === 'offline' ? 'Hermes hors ligne' : '';
return (
+12 -4
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { Download, Trash2, X, CheckCircle2, Cpu, Mic, Volume2, AlertTriangle, RefreshCw } from 'lucide-react';
import { Download, Trash2, X, CheckCircle2, Cpu, Mic, Volume2, AlertTriangle, RefreshCw, Ear } from 'lucide-react';
import type { VoiceModelStatus } from '../../../shared/voice';
import { bridge } from '../../lib/bridge';
import { useShallow } from 'zustand/react/shallow';
@@ -19,20 +19,23 @@ function ModelRow({ model }: { model: VoiceModelStatus }) {
const isActive = model.kind === 'stt' ? activeStt === model.id : activeTts === model.id;
const busy = !!progress && (progress.phase === 'download' || progress.phase === 'extract');
const wakeMode = useSettings((s) => s.settings.voice.wakeMode);
const activate = () => {
if (model.kind === 'stt') update({ voice: { localModel: model.id, provider: 'local' } });
else if (model.kind === 'kws') update({ voice: { wakeMode: 'kws' } });
else update({ speech: { localModel: model.id, provider: 'local', localSpeaker: model.speakers?.[0]?.id ?? 0 } });
};
const activeNow = model.kind === 'kws' ? wakeMode === 'kws' : isActive;
return (
<div className="row-item" style={isActive && model.installed ? { borderColor: 'var(--accent)' } : undefined}>
<div className="row-item" style={activeNow && model.installed ? { borderColor: 'var(--accent)' } : undefined}>
<div className="main">
<div className="row wrap" style={{ gap: 6 }}>
<span className="name">{model.name}</span>
{model.recommended && <span className="status-pill ok">recommandé</span>}
{model.installed && <span className="status-pill online">installé · {formatMb(model.installedBytes)}</span>}
{!model.installed && !busy && <span className="status-pill">{model.sizeMb} Mo</span>}
{isActive && model.installed && <span className="status-pill active">actif</span>}
{activeNow && model.installed && <span className="status-pill active">actif</span>}
</div>
<span className="desc">{model.description}</span>
<span className="meta">langues : {model.languages.join(', ')}{model.speakers ? ` · ${model.speakers.length} voix` : ''}</span>
@@ -49,7 +52,7 @@ function ModelRow({ model }: { model: VoiceModelStatus }) {
<button className="icon-btn danger" title="Annuler" onClick={() => void cancel(model.id)}><X size={13} /></button>
) : model.installed ? (
<>
{!isActive && <button className="btn small" onClick={activate}>Utiliser</button>}
{!activeNow && <button className="btn small" onClick={activate}>Utiliser</button>}
<button className="icon-btn danger" title="Supprimer du disque" onClick={() => { if (confirm(`Supprimer « ${model.name} » ?`)) void remove(model.id); }}><Trash2 size={13} /></button>
</>
) : (
@@ -72,6 +75,7 @@ export function ModelsSection() {
const stt = models.filter((m) => m.kind === 'stt');
const tts = models.filter((m) => m.kind === 'tts');
const kws = models.filter((m) => m.kind === 'kws');
return (
<>
<div className="card">
@@ -91,6 +95,10 @@ export function ModelsSection() {
<button className="btn small ghost" onClick={() => { void refresh(); void checkEngine(); }}><RefreshCw size={13} /> Actualiser</button>
</div>
</div>
<div className="card">
<div className="section-title"><Ear size={12} /> Mot d’activation</div>
<div className="list">{kws.map((m) => <ModelRow key={m.id} model={m} />)}</div>
</div>
<div className="card">
<div className="section-title"><Mic size={12} /> Reconnaissance vocale</div>
<div className="list">{stt.map((m) => <ModelRow key={m.id} model={m} />)}</div>
+58 -5
View File
@@ -33,6 +33,33 @@ function Toggle({ on, onChange, label, hint }: { on: boolean; onChange: (v: bool
type TestState = { status: 'idle' | 'running' | 'ok' | 'fail'; message: string };
function WakeStatus() {
const wake = useVoice((s) => s.wake);
const keywords = useVoice((s) => s.wakeKeywords);
const error = useVoice((s) => s.error);
const models = useVoiceModels((s) => s.models);
const download = useVoiceModels((s) => s.download);
const progress = useVoiceModels((s) => s.progress['kws-en']);
const installed = models.find((m) => m.id === 'kws-en')?.installed;
if (installed === false) {
return (
<div className="test-result fail" style={{ marginTop: 8 }}>
<XCircle size={13} />
<span>Détecteur non installé.</span>
<button className="btn small primary" style={{ marginLeft: 'auto' }} disabled={!!progress} onClick={() => void download('kws-en')}>
{progress ? `${progress.percent}%` : 'Télécharger (4 Mo)'}
</button>
</div>
);
}
return (
<div className={`test-result ${wake === 'spotting' ? 'ok' : wake === 'error' ? 'fail' : ''}`} style={{ marginTop: 8 }}>
{wake === 'spotting' ? <CheckCircle2 size={13} /> : wake === 'error' ? <XCircle size={13} /> : <Loader2 size={13} className="spin" />}
<span>{wake === 'spotting' ? `à l’écoute de « ${keywords.join(' », « ')} »` : wake === 'error' ? error ?? 'erreur' : 'démarrage…'}</span>
</div>
);
}
function TestResult({ t }: { t: TestState }) {
if (t.status === 'idle') return null;
return (
@@ -278,13 +305,39 @@ export function SettingsDrawer({ onClose }: Props) {
</div>
<Toggle on={settings.voice.handsFree} onChange={(v) => { update({ voice: { handsFree: v } }); useVoice.getState().setHandsFree(v); }} label="Mains libres au démarrage" hint="Le micro se réactive automatiquement après chaque réponse." />
<Toggle on={settings.voice.wakeChime} onChange={(v) => update({ voice: { wakeChime: v } })} label="Signal sonore d’écoute" />
<Toggle on={settings.voice.wakeWordEnabled} onChange={(v) => update({ voice: { wakeWordEnabled: v } })} label="Mot d’activation en mains libres" hint="Seules les phrases commençant par ce mot sont envoyées à Hermes ; le reste est ignoré. Recommandé avec la reconnaissance locale." />
{settings.voice.wakeWordEnabled && (
<div className="field" style={{ marginTop: 8 }}>
<label>Mot d’activation</label>
<input className="input" value={settings.voice.wakeWord} placeholder="jarvis" onChange={(e) => update({ voice: { wakeWord: e.target.value.toLowerCase() } })} />
<div className="field" style={{ marginTop: 10 }}>
<label>Mot d’activation</label>
<select className="select" value={settings.voice.wakeMode} onChange={(e) => update({ voice: { wakeMode: e.target.value as typeof settings.voice.wakeMode, wakeWordEnabled: e.target.value === 'transcript' } })}>
<option value="off">Désactivé (bouton, raccourci ou mains libres)</option>
<option value="kws">Écoute permanente : le micro reste ouvert et réagit au mot-clé (recommandé)</option>
<option value="transcript">Filtre après transcription (mains libres) : les phrases sans le mot sont ignorées</option>
</select>
<span className="hint">
{settings.voice.wakeMode === 'kws'
? 'Détection locale par un modèle de 3 Mo, quasi gratuite en CPU. Dire le mot seul ouvre l’écoute ; dire le mot puis la commande envoie directement. Le mot coupe aussi la voix en cours.'
: settings.voice.wakeMode === 'transcript'
? 'Chaque phrase est transcrite puis filtrée : plus coûteux, à réserver à la reconnaissance locale.'
: 'Le micro s’active avec le bouton, Ctrl+Shift+Espace ou la boucle mains libres.'}
</span>
</div>
{settings.voice.wakeMode !== 'off' && (
<div className="grid-2">
<div className="field">
<label>Mot-clé</label>
<input className="input" value={settings.voice.wakeWord} placeholder="jarvis" onChange={(e) => update({ voice: { wakeWord: e.target.value.toLowerCase().slice(0, 40) } })} />
<span className="hint">Prononciation anglaise conseillée (« jarvis », « hey jarvis », « computer », « friday »…).</span>
</div>
{settings.voice.wakeMode === 'kws' && (
<div className="field">
<label>Sensibilité du mot-clé : {settings.voice.kwsSensitivity}/5</label>
<input className="range" type="range" min={1} max={5} step={1} value={settings.voice.kwsSensitivity} onChange={(e) => update({ voice: { kwsSensitivity: Number(e.target.value) } })} />
</div>
)}
</div>
)}
{settings.voice.wakeMode === 'kws' && (
<WakeStatus />
)}
<div className="row" style={{ marginTop: 10 }}>
<button className="btn small" onClick={() => void testStt()} disabled={settings.voice.provider === 'browser'}><Mic size={13} /> Tester la reconnaissance</button>
</div>