mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-18 04:38:54 +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
+12
@@ -44,6 +44,18 @@ function useBoot(): boolean {
|
||||
if (api) {
|
||||
disposers.push(useVoiceModels.getState().subscribe());
|
||||
void useVoiceModels.getState().refresh();
|
||||
if (settings.voice.wakeMode === 'kws') void voiceController.startWakeMode();
|
||||
let lastWake = JSON.stringify([settings.voice.wakeMode, settings.voice.wakeWord, settings.voice.kwsSensitivity, settings.voice.micDeviceId]);
|
||||
disposers.push(
|
||||
useSettings.subscribe((s) => {
|
||||
const v = s.settings.voice;
|
||||
const key = JSON.stringify([v.wakeMode, v.wakeWord, v.kwsSensitivity, v.micDeviceId]);
|
||||
if (key === lastWake) return;
|
||||
lastWake = key;
|
||||
if (v.wakeMode === 'kws') void voiceController.restartWakeMode();
|
||||
else void voiceController.stopWakeMode();
|
||||
})
|
||||
);
|
||||
disposers.push(api.hermes.onPush(handlePush));
|
||||
disposers.push(
|
||||
api.hotkeys.on((event) => {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,7 +10,9 @@ import { audioBus } from './audioBus';
|
||||
import { listMicrophones, MicCapture } from './capture';
|
||||
import { speech } from './speech';
|
||||
import { BrowserRecognizer, transcribeWav } from './stt';
|
||||
import { WakeListener } from './wakeListener';
|
||||
import type { WavResult } from './wav';
|
||||
import { bridge } from '../../lib/bridge';
|
||||
|
||||
const SENSITIVITY_RATIO: Record<number, number> = { 1: 4.5, 2: 3.4, 3: 2.6, 4: 2.0, 5: 1.6 };
|
||||
const SENSITIVITY_MIN_RMS: Record<number, number> = { 1: 0.03, 2: 0.02, 3: 0.012, 4: 0.008, 5: 0.005 };
|
||||
@@ -46,6 +48,8 @@ export function matchWakeWord(text: string, wakeWord: string): { matched: boolea
|
||||
|
||||
class VoiceController {
|
||||
private capture = new MicCapture();
|
||||
private wake = new WakeListener();
|
||||
private unsubscribeKws: (() => void) | null = null;
|
||||
/** After a bare wake word, the next utterance is accepted without the wake word. */
|
||||
private attentionUntil = 0;
|
||||
private browser = new BrowserRecognizer();
|
||||
@@ -74,17 +78,104 @@ class VoiceController {
|
||||
dispose(): void {
|
||||
this.unsubscribeVoice?.();
|
||||
this.stop();
|
||||
void this.stopWakeMode();
|
||||
}
|
||||
|
||||
get isListening(): boolean {
|
||||
return useVoice.getState().phase !== 'off';
|
||||
}
|
||||
|
||||
get wakeActive(): boolean {
|
||||
return this.wake.isActive;
|
||||
}
|
||||
|
||||
toggle(): void {
|
||||
if (this.wake.isActive) {
|
||||
// Always-on mode: the button starts/cancels a command capture on the shared microphone.
|
||||
if (this.wake.currentPhase === 'command' || this.wake.currentPhase === 'speech') this.wake.cancelCommand();
|
||||
else this.onWakeDetected('manuel');
|
||||
return;
|
||||
}
|
||||
if (this.isListening) this.stop();
|
||||
else void this.start();
|
||||
}
|
||||
|
||||
/** Always-on keyword spotting: the microphone stays open and the main process spots the wake word. */
|
||||
async startWakeMode(): Promise<void> {
|
||||
const api = bridge();
|
||||
const voice = useVoice.getState();
|
||||
if (!api || this.wake.isActive) return;
|
||||
const settings = useSettings.getState().settings.voice;
|
||||
voice.setWake('starting');
|
||||
try {
|
||||
const phrases = [settings.wakeWord || 'jarvis', `hey ${settings.wakeWord || 'jarvis'}`];
|
||||
const result = await api.voice.kwsStart({ modelId: 'kws-en', keywords: phrases, sensitivity: settings.kwsSensitivity });
|
||||
this.unsubscribeKws?.();
|
||||
this.unsubscribeKws = api.voice.onKwsDetected((d) => this.onWakeDetected(d.keyword));
|
||||
const sensitivity = Math.min(5, Math.max(1, Math.round(settings.sensitivity))) as 1 | 2 | 3 | 4 | 5;
|
||||
await this.wake.start({
|
||||
deviceId: settings.micDeviceId || undefined,
|
||||
vad: { silenceMs: settings.silenceMs, speechRatio: SENSITIVITY_RATIO[sensitivity], minRms: SENSITIVITY_MIN_RMS[sensitivity] },
|
||||
callbacks: {
|
||||
onPhase: (phase) => {
|
||||
const v = useVoice.getState();
|
||||
if (phase === 'spotting') {
|
||||
v.setPhase('off');
|
||||
const chat = useChat.getState();
|
||||
if (chat.hud === 'listening') chat.setHud(chat.isSending ? 'thinking' : 'idle');
|
||||
} else if (phase === 'command') {
|
||||
v.setPhase('listening');
|
||||
useChat.getState().setHud('listening');
|
||||
} else if (phase === 'speech') {
|
||||
v.setPhase('speech');
|
||||
useChat.getState().ping();
|
||||
}
|
||||
},
|
||||
onLevel: (level) => {
|
||||
const q = Math.round(level * 20) / 20;
|
||||
if (q !== useVoice.getState().inputLevel) useVoice.getState().setInputLevel(q);
|
||||
},
|
||||
onUtterance: (wav) => void this.transcribe(wav, true),
|
||||
onNoSpeech: () => useVoice.getState().setInputLevel(0),
|
||||
onError: (message) => useVoice.getState().setError(message)
|
||||
}
|
||||
});
|
||||
voice.setWake('spotting', result.accepted.map((k) => k.replace(/_/g, ' ')));
|
||||
Log.info('voice', `wake mode on: ${result.accepted.join(', ')}${result.rejected.length ? ` (rejetés : ${result.rejected.join(', ')})` : ''}`);
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
voice.setWake('error');
|
||||
voice.setError(message);
|
||||
useChat.getState().setError(`Mot d’activation : ${message}`);
|
||||
Log.error('voice', `wake mode failed: ${message}`);
|
||||
await api.voice.kwsStop().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async stopWakeMode(): Promise<void> {
|
||||
this.unsubscribeKws?.();
|
||||
this.unsubscribeKws = null;
|
||||
this.wake.stop();
|
||||
useVoice.getState().setWake('off');
|
||||
await bridge()?.voice.kwsStop().catch(() => undefined);
|
||||
}
|
||||
|
||||
/** Restart spotting with the current settings (wake word, sensitivity, microphone). */
|
||||
async restartWakeMode(): Promise<void> {
|
||||
await this.stopWakeMode();
|
||||
if (useSettings.getState().settings.voice.wakeMode === 'kws') await this.startWakeMode();
|
||||
}
|
||||
|
||||
private onWakeDetected(keyword: string): void {
|
||||
if (!this.wake.isActive || this.wake.currentPhase !== 'spotting') return;
|
||||
if (useChat.getState().isSending) return;
|
||||
Log.info('voice', `wake: ${keyword}`);
|
||||
speech.stop();
|
||||
useChat.getState().ping();
|
||||
this.playChime(true);
|
||||
this.wake.beginCommand();
|
||||
}
|
||||
|
||||
setHandsFree(on: boolean): void {
|
||||
useVoice.getState().setHandsFree(on);
|
||||
useSettings.getState().update({ voice: { handsFree: on } });
|
||||
@@ -113,6 +204,11 @@ class VoiceController {
|
||||
async start(auto = false): Promise<void> {
|
||||
const voice = useVoice.getState();
|
||||
if (voice.phase !== 'off') return;
|
||||
if (this.wake.isActive) {
|
||||
// The always-on listener owns the microphone: open a command capture on it instead.
|
||||
if (!auto) this.onWakeDetected('manuel');
|
||||
return;
|
||||
}
|
||||
const settings = useSettings.getState().settings.voice;
|
||||
const seq = ++this.startSeq;
|
||||
voice.setError(null);
|
||||
@@ -218,7 +314,7 @@ class VoiceController {
|
||||
}
|
||||
}
|
||||
|
||||
private async transcribe(wav: WavResult): Promise<void> {
|
||||
private async transcribe(wav: WavResult, fromWake = false): Promise<void> {
|
||||
const voice = useVoice.getState();
|
||||
voice.setPhase('transcribing');
|
||||
useChat.getState().setHud('thinking');
|
||||
@@ -229,7 +325,8 @@ class VoiceController {
|
||||
voice.setTranscript(text);
|
||||
voice.setPhase('off');
|
||||
Log.info('voice', `transcript (${wav.durationSec.toFixed(1)}s): ${text}`);
|
||||
if (voice.handsFree && settings.wakeWordEnabled && Date.now() > this.attentionUntil) {
|
||||
const filterByTranscript = settings.wakeMode === 'transcript' || (settings.wakeMode === 'off' && settings.wakeWordEnabled);
|
||||
if (!fromWake && voice.handsFree && filterByTranscript && Date.now() > this.attentionUntil) {
|
||||
const { matched, rest } = matchWakeWord(text, settings.wakeWord || 'jarvis');
|
||||
if (!matched) {
|
||||
Log.debug('voice', 'utterance ignored: no wake word');
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Always-on microphone for the keyword spotter. Streams 16 kHz PCM to the main process
|
||||
* (which feeds the sherpa-onnx spotter); once the wake word is detected it captures the
|
||||
* following utterance with the energy VAD and hands back a WAV, then resumes spotting.
|
||||
* One microphone stream, no re-opening between phrases.
|
||||
*/
|
||||
import { bridge } from '../../lib/bridge';
|
||||
import { Log } from '../../lib/log';
|
||||
import { audioBus } from './audioBus';
|
||||
import { DEFAULT_VAD, EnergyVad, type VadOptions } from './vad';
|
||||
import { buildWav16k, rms, type WavResult } from './wav';
|
||||
|
||||
const WORKLET_SOURCE = `
|
||||
class EveFlowWakeProcessor extends AudioWorkletProcessor {
|
||||
constructor() { super(); this.buffer = new Float32Array(2048); this.offset = 0; }
|
||||
process(inputs) {
|
||||
const channel = inputs[0] && inputs[0][0];
|
||||
if (!channel) return true;
|
||||
let i = 0;
|
||||
while (i < channel.length) {
|
||||
const n = Math.min(channel.length - i, this.buffer.length - this.offset);
|
||||
this.buffer.set(channel.subarray(i, i + n), this.offset);
|
||||
this.offset += n; i += n;
|
||||
if (this.offset === this.buffer.length) {
|
||||
this.port.postMessage(this.buffer, [this.buffer.buffer]);
|
||||
this.buffer = new Float32Array(2048); this.offset = 0;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('eveflow-wake', EveFlowWakeProcessor);
|
||||
`;
|
||||
|
||||
export type WakePhase = 'off' | 'spotting' | 'command' | 'speech';
|
||||
|
||||
export interface WakeCallbacks {
|
||||
onPhase: (phase: WakePhase) => void;
|
||||
onLevel?: (level: number) => void;
|
||||
onUtterance: (wav: WavResult) => void;
|
||||
onNoSpeech: () => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
let workletUrl: string | null = null;
|
||||
|
||||
export class WakeListener {
|
||||
private ctx: AudioContext | null = null;
|
||||
private stream: MediaStream | null = null;
|
||||
private node: AudioWorkletNode | null = null;
|
||||
private phase: WakePhase = 'off';
|
||||
private vad: EnergyVad | null = null;
|
||||
private chunks: Float32Array[] = [];
|
||||
private totalSamples = 0;
|
||||
private sampleRate = 16_000;
|
||||
private pending: Float32Array[] = [];
|
||||
private pendingSamples = 0;
|
||||
private callbacks: WakeCallbacks | null = null;
|
||||
private vadOptions: Partial<VadOptions> = {};
|
||||
private noSpeechTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
get isActive(): boolean {
|
||||
return this.phase !== 'off';
|
||||
}
|
||||
|
||||
get currentPhase(): WakePhase {
|
||||
return this.phase;
|
||||
}
|
||||
|
||||
async start(options: { deviceId?: string; vad?: Partial<VadOptions>; callbacks: WakeCallbacks }): Promise<void> {
|
||||
if (this.phase !== 'off') return;
|
||||
this.callbacks = options.callbacks;
|
||||
this.vadOptions = options.vad ?? {};
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
deviceId: options.deviceId ? { exact: options.deviceId } : undefined,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
channelCount: 1
|
||||
}
|
||||
});
|
||||
try {
|
||||
this.ctx = new AudioContext({ sampleRate: 16_000, latencyHint: 'playback' });
|
||||
} catch {
|
||||
this.ctx = new AudioContext({ latencyHint: 'playback' });
|
||||
}
|
||||
if (this.ctx.state === 'suspended') await this.ctx.resume().catch(() => undefined);
|
||||
this.sampleRate = this.ctx.sampleRate;
|
||||
const source = this.ctx.createMediaStreamSource(this.stream);
|
||||
const analyser = this.ctx.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
source.connect(analyser);
|
||||
audioBus.setInputAnalyser(analyser);
|
||||
if (!workletUrl) workletUrl = URL.createObjectURL(new Blob([WORKLET_SOURCE], { type: 'application/javascript' }));
|
||||
await this.ctx.audioWorklet.addModule(workletUrl);
|
||||
this.node = new AudioWorkletNode(this.ctx, 'eveflow-wake', { numberOfInputs: 1, numberOfOutputs: 0, channelCount: 1 });
|
||||
this.node.port.onmessage = (event: MessageEvent<Float32Array>) => this.onSamples(event.data);
|
||||
source.connect(this.node);
|
||||
this.setPhase('spotting');
|
||||
Log.info('wake', `listener started (${this.sampleRate} Hz)`);
|
||||
}
|
||||
|
||||
/** Called by the controller when the main process reports the wake word (or on manual trigger). */
|
||||
beginCommand(): void {
|
||||
if (this.phase === 'off' || this.phase === 'command' || this.phase === 'speech') return;
|
||||
this.vad = new EnergyVad({ ...DEFAULT_VAD, ...this.vadOptions, noSpeechTimeoutMs: Number.POSITIVE_INFINITY });
|
||||
this.chunks = [];
|
||||
this.totalSamples = 0;
|
||||
this.setPhase('command');
|
||||
if (this.noSpeechTimer) clearTimeout(this.noSpeechTimer);
|
||||
this.noSpeechTimer = setTimeout(() => {
|
||||
if (this.phase === 'command') {
|
||||
this.resumeSpotting();
|
||||
this.callbacks?.onNoSpeech();
|
||||
}
|
||||
}, 8000);
|
||||
}
|
||||
|
||||
/** Abort a command capture and go back to spotting. */
|
||||
cancelCommand(): void {
|
||||
if (this.phase === 'command' || this.phase === 'speech') this.resumeSpotting();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.phase === 'off') return;
|
||||
if (this.noSpeechTimer) clearTimeout(this.noSpeechTimer);
|
||||
this.noSpeechTimer = null;
|
||||
audioBus.setInputAnalyser(null);
|
||||
if (this.node) {
|
||||
this.node.port.onmessage = null;
|
||||
this.node.disconnect();
|
||||
this.node = null;
|
||||
}
|
||||
this.stream?.getTracks().forEach((t) => t.stop());
|
||||
this.stream = null;
|
||||
void this.ctx?.close().catch(() => undefined);
|
||||
this.ctx = null;
|
||||
this.chunks = [];
|
||||
this.pending = [];
|
||||
this.pendingSamples = 0;
|
||||
this.setPhase('off');
|
||||
Log.info('wake', 'listener stopped');
|
||||
}
|
||||
|
||||
private setPhase(phase: WakePhase): void {
|
||||
this.phase = phase;
|
||||
this.callbacks?.onPhase(phase);
|
||||
}
|
||||
|
||||
private resumeSpotting(): void {
|
||||
if (this.noSpeechTimer) clearTimeout(this.noSpeechTimer);
|
||||
this.noSpeechTimer = null;
|
||||
this.vad = null;
|
||||
this.chunks = [];
|
||||
this.totalSamples = 0;
|
||||
this.setPhase('spotting');
|
||||
}
|
||||
|
||||
private onSamples(samples: Float32Array): void {
|
||||
if (this.phase === 'off') return;
|
||||
const level = rms(samples);
|
||||
this.callbacks?.onLevel?.(Math.min(1, level * 6));
|
||||
|
||||
if (this.phase === 'spotting') {
|
||||
// Batch ~256 ms of audio per IPC message for the keyword spotter.
|
||||
this.pending.push(samples);
|
||||
this.pendingSamples += samples.length;
|
||||
if (this.pendingSamples >= this.sampleRate * 0.25) this.flushToSpotter();
|
||||
return;
|
||||
}
|
||||
|
||||
// command / speech: collect the utterance
|
||||
this.chunks.push(samples);
|
||||
this.totalSamples += samples.length;
|
||||
if (this.vad && this.phase === 'command') {
|
||||
// keep only a short pre-roll before speech starts
|
||||
while (this.chunks.length > 1 && this.totalSamples - this.chunks[0].length > this.sampleRate * 0.4) {
|
||||
this.totalSamples -= this.chunks.shift()!.length;
|
||||
}
|
||||
}
|
||||
if (!this.vad) return;
|
||||
const signal = this.vad.feed(level, (samples.length / this.sampleRate) * 1000);
|
||||
if (signal === 'speech-start') {
|
||||
this.setPhase('speech');
|
||||
if (this.noSpeechTimer) clearTimeout(this.noSpeechTimer);
|
||||
this.noSpeechTimer = null;
|
||||
} else if (signal === 'speech-end' || signal === 'max-length') {
|
||||
const wav = buildWav16k(this.chunks, this.sampleRate);
|
||||
this.resumeSpotting();
|
||||
if (wav.durationSec >= 0.25) this.callbacks?.onUtterance(wav);
|
||||
else this.callbacks?.onNoSpeech();
|
||||
} else if (signal === 'too-short') {
|
||||
this.resumeSpotting();
|
||||
this.callbacks?.onNoSpeech();
|
||||
}
|
||||
}
|
||||
|
||||
private flushToSpotter(): void {
|
||||
const api = bridge();
|
||||
if (!api) return;
|
||||
const total = this.pendingSamples;
|
||||
const merged = new Int16Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of this.pending) {
|
||||
for (let i = 0; i < chunk.length; i++) {
|
||||
const s = Math.max(-1, Math.min(1, chunk[i]));
|
||||
merged[offset + i] = s < 0 ? s * 0x8000 : s * 0x7fff;
|
||||
}
|
||||
offset += chunk.length;
|
||||
}
|
||||
this.pending = [];
|
||||
this.pendingSamples = 0;
|
||||
api.voice.kwsAudio(new Uint8Array(merged.buffer), this.sampleRate);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
Reference in new issue
Block a user