mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-19 05:09:30 +02:00
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:
20 files changed
+760
-34
No files matched your search
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user