mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-18 12:49:05 +02:00
feat: fin de phrase Silero, vision d'écran et actions locales (v2.3.0) (#13)
Écoute permanente - Silero VAD dans le worker sherpa-onnx (vad.start/audio/stop) ; le renderer envoie des trames de 128 ms pendant la commande et reçoit le segment WAV complet (speech-start / segment). Repli sur le VAD énergétique si le modèle n'est pas installé ou si l'option est désactivée. - Modèle « silero-vad » (0,6 Mo) dans le catalogue, section « Fin de phrase » dans Modèles locaux, réglage dans Paramètres → Micro. Vision d'écran - IPC system:screen-capture (desktopCapturer, JPEG 1600 px) ; bouton dans la barre de commande ; « regarde mon écran… » joint la capture à la question envoyée à Hermes (transport chat completions pour les images). Actions locales - IPC system:action à liste blanche : verrouiller la session, touches média (volume, mute, lecture, piste), ouvrir une application connue ou une URL http(s), presse-papiers, recherche de fichiers dans les dossiers utilisateur. - Routeur d'intentions FR/EN exécuté avant Hermes (« coupe le son », « ouvre Spotify », « verrouille la session »…), résultat affiché et lu ; désactivable. Correctif - Chat completions : les premiers fragments SSE arrivés avant l'événement de démarrage étaient perdus (premier mot manquant) ; ils sont maintenant rejoués. Tests : 26 tests unitaires (intentions locales ajoutées), e2e Electron sous Xvfb avec les vrais modèles (Silero : un segment par phrase, 6,7 s d'audio traités en 190 ms ; capture 109 ko reçue par Hermes ; intention locale traitée sans Hermes). Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
4f6074183f
commit
af8bdc5e9d
24 files changed
+691
-47
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Mic, Paperclip, Send, Square, X, Radio, Loader2, Volume2, VolumeX, Navigation } from 'lucide-react';
|
||||
import { sendMessage, steer, stopGeneration } from '../../services/conversation';
|
||||
import { Mic, Paperclip, Send, Square, X, Radio, Loader2, Volume2, VolumeX, Navigation, ScanEye } from 'lucide-react';
|
||||
import { sendMessage, steer, stopGeneration , captureScreen } from '../../services/conversation';
|
||||
import { speech } from '../../services/voice/speech';
|
||||
import { voiceController } from '../../services/voice/voiceController';
|
||||
import { useChat } from '../../state/chat';
|
||||
@@ -167,9 +167,20 @@ export function CommandBar({ compact }: Props) {
|
||||
spellCheck={false}
|
||||
/>
|
||||
{!compact && (
|
||||
<button className="icon-btn" title="Joindre une image" onClick={() => fileRef.current?.click()} style={{ height: 46, width: 40 }}>
|
||||
<Paperclip size={18} />
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
className="icon-btn"
|
||||
title="Joindre une capture de l’écran (Hermes la voit)"
|
||||
aria-label="Capturer l’écran"
|
||||
onClick={() => void captureScreen().then((shot) => { if (shot) setImages((prev) => [...prev, shot].slice(-4)); })}
|
||||
style={{ height: 46, width: 40 }}
|
||||
>
|
||||
<ScanEye size={18} />
|
||||
</button>
|
||||
<button className="icon-btn" title="Joindre une image" onClick={() => fileRef.current?.click()} style={{ height: 46, width: 40 }}>
|
||||
<Paperclip size={18} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<input ref={fileRef} type="file" accept="image/*" multiple hidden onChange={(e) => void onFiles(e.target.files)} />
|
||||
{isSending && !steerMode ? (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Download, Trash2, X, CheckCircle2, Cpu, Mic, Volume2, AlertTriangle, RefreshCw, Ear } from 'lucide-react';
|
||||
import { Download, Trash2, X, CheckCircle2, Cpu, Mic, Volume2, AlertTriangle, RefreshCw, Ear, Activity } from 'lucide-react';
|
||||
import type { VoiceModelStatus } from '../../../shared/voice';
|
||||
import { bridge } from '../../lib/bridge';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
@@ -23,9 +23,11 @@ function ModelRow({ model }: { model: VoiceModelStatus }) {
|
||||
const activate = () => {
|
||||
if (model.kind === 'stt') update({ voice: { localModel: model.id, provider: 'local' } });
|
||||
else if (model.kind === 'kws') update({ voice: { wakeMode: 'kws' } });
|
||||
else if (model.kind === 'vad') update({ voice: { neuralVad: true } });
|
||||
else update({ speech: { localModel: model.id, provider: 'local', localSpeaker: model.speakers?.[0]?.id ?? 0 } });
|
||||
};
|
||||
const activeNow = model.kind === 'kws' ? wakeMode === 'kws' : isActive;
|
||||
const neuralVad = useSettings((s) => s.settings.voice.neuralVad);
|
||||
const activeNow = model.kind === 'kws' ? wakeMode === 'kws' : model.kind === 'vad' ? neuralVad : isActive;
|
||||
|
||||
return (
|
||||
<div className="row-item" style={activeNow && model.installed ? { borderColor: 'var(--accent)' } : undefined}>
|
||||
@@ -76,6 +78,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');
|
||||
const vad = models.filter((m) => m.kind === 'vad');
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
@@ -99,6 +102,11 @@ export function ModelsSection() {
|
||||
<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"><Activity size={12} /> Fin de phrase</div>
|
||||
<div className="list">{vad.map((m) => <ModelRow key={m.id} model={m} />)}</div>
|
||||
<span className="hint" style={{ display: 'block', marginTop: 6 }}>Utilisé automatiquement en écoute permanente dès qu’il est installé ; sinon le VAD énergétique prend le relais.</span>
|
||||
</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>
|
||||
|
||||
@@ -336,8 +336,12 @@ export function SettingsDrawer({ onClose }: Props) {
|
||||
</div>
|
||||
)}
|
||||
{settings.voice.wakeMode === 'kws' && (
|
||||
<WakeStatus />
|
||||
<>
|
||||
<WakeStatus />
|
||||
<Toggle on={settings.voice.neuralVad} onChange={(v) => update({ voice: { neuralVad: v } })} label="Fin de phrase neuronale (Silero)" hint="Coupe l’écoute au bon moment, même avec du bruit de fond. Nécessite le modèle Silero VAD (0,6 Mo) dans Modèles locaux." />
|
||||
</>
|
||||
)}
|
||||
<Toggle on={settings.voice.localCommands} onChange={(v) => update({ voice: { localCommands: v } })} label="Commandes locales instantanées" hint="« Verrouille la session », « monte le son », « ouvre Spotify », « regarde mon écran »… exécutées sur ce PC sans passer par Hermes." />
|
||||
<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>
|
||||
|
||||
@@ -12,6 +12,8 @@ import { useSettings } from '../state/settings';
|
||||
import { executeLocalTool, LOCAL_TOOL_DEFINITIONS } from './hermes/localTools';
|
||||
import type { HermesStreamEvent, SendHandle } from './hermes/types';
|
||||
import { speech } from './voice/speech';
|
||||
import { bridge } from '../lib/bridge';
|
||||
import { parseLocalIntent, runLocalIntent } from './localCommands';
|
||||
|
||||
let active: SendHandle | null = null;
|
||||
let activeMessageId: string | null = null;
|
||||
@@ -165,9 +167,49 @@ function addPending(request: Omit<PendingRequest, 'id' | 'createdAt'>): void {
|
||||
speech.say(request.kind === 'approval' ? 'Autorisation requise.' : request.kind === 'clarify' ? request.description : 'Saisie requise.', { interrupt: false });
|
||||
}
|
||||
|
||||
/** Screenshot of the primary display as a data URL (Electron only). */
|
||||
export async function captureScreen(): Promise<string | null> {
|
||||
const api = bridge();
|
||||
if (!api) return null;
|
||||
try {
|
||||
return await api.system.captureScreen(1600);
|
||||
} catch (err) {
|
||||
useChat.getState().setError(`Capture d’écran : ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Short system intents handled on the machine without a Hermes round trip. Returns true when consumed. */
|
||||
async function tryLocalIntent(text: string, source: string): Promise<{ handled: boolean; images?: string[]; text?: string }> {
|
||||
const settings = useSettings.getState().settings;
|
||||
if (!settings.voice.localCommands || !bridge()) return { handled: false };
|
||||
const intent = parseLocalIntent(text);
|
||||
if (!intent) return { handled: false };
|
||||
if (intent.kind === 'screenshot') {
|
||||
const shot = await captureScreen();
|
||||
if (!shot) return { handled: false };
|
||||
return { handled: false, images: [shot], text: intent.question || text };
|
||||
}
|
||||
const chat = useChat.getState();
|
||||
chat.addMessage({ role: 'user', content: text, source, status: 'done' });
|
||||
const result = await runLocalIntent(intent);
|
||||
chat.addMessage({ role: 'assistant', content: result.message, source: 'local', status: result.ok ? 'done' : 'error' });
|
||||
chat.setHud(result.ok ? 'idle' : 'error');
|
||||
if (settings.speech.autoSpeak) speech.say(result.message, { interrupt: true });
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
export async function sendMessage(text: string, images: string[] = [], source = 'eveflow'): Promise<void> {
|
||||
const trimmed = text.trim();
|
||||
let trimmed = text.trim();
|
||||
if ((!trimmed && images.length === 0) || active) return;
|
||||
if (trimmed && images.length === 0) {
|
||||
const local = await tryLocalIntent(trimmed, source);
|
||||
if (local.handled) return;
|
||||
if (local.images) {
|
||||
images = local.images;
|
||||
trimmed = (local.text ?? trimmed).trim();
|
||||
}
|
||||
}
|
||||
const chat = useChat.getState();
|
||||
const hermes = useHermes.getState();
|
||||
const settings = useSettings.getState().settings;
|
||||
|
||||
@@ -467,6 +467,8 @@ export class HermesClient {
|
||||
throw new HttpError(handle.start.status, detail);
|
||||
}
|
||||
ready = true;
|
||||
// Chunks that raced ahead of the start event were buffered as potential error bodies: replay them.
|
||||
for (const chunk of errorChunks.splice(0)) parser.feed(chunk);
|
||||
const rotated = handle.start.headers['x-hermes-session-id'];
|
||||
if (rotated && rotated !== options.sessionId) onEvent({ kind: 'session', sessionId: rotated });
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Instant local commands: short French/English intents handled on the machine without a
|
||||
* round trip to Hermes (lock, open app/url, volume, media, screenshot to Hermes).
|
||||
* Anything unmatched goes to Hermes as usual.
|
||||
*/
|
||||
import type { SystemAction } from '../../shared/bridge';
|
||||
import { bridge } from '../lib/bridge';
|
||||
import { Log } from '../lib/log';
|
||||
|
||||
export interface LocalIntent {
|
||||
kind: 'action' | 'screenshot';
|
||||
action?: SystemAction;
|
||||
/** Spoken confirmation. */
|
||||
reply: string;
|
||||
/** For screenshot: the question to send to Hermes with the image. */
|
||||
question?: string;
|
||||
}
|
||||
|
||||
const norm = (t: string) =>
|
||||
t
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[’']/g, ' ')
|
||||
.replace(/[^a-z0-9 :/._-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
const LOCK = /^(verrouille|verrouiller|bloque|lock)( (la |ma )?(session|le pc|l ordinateur|the (pc|computer|screen)))?$/;
|
||||
const VOL_UP = /^(monte|augmente|hausse|plus fort|volume plus|turn up|raise)( (le|the)? ?(son|volume))?( de \d+)?$/;
|
||||
const VOL_DOWN = /^(baisse|diminue|moins fort|volume moins|turn down|lower)( (le|the)? ?(son|volume))?( de \d+)?$/;
|
||||
const MUTE = /^(coupe|couper|mute|silence|desactive)( (le|the)? ?(son|volume|audio))?$/;
|
||||
const PLAY = /^(pause|play|lecture|reprends|reprendre|mets en pause|met en pause|stop la musique|arrete la musique)$/;
|
||||
const NEXT = /^(suivant|suivante|piste suivante|musique suivante|next|skip)$/;
|
||||
const PREV = /^(precedent|precedente|piste precedente|previous)$/;
|
||||
const OPEN_APP = /^(ouvre|ouvrir|lance|lancer|demarre|open|launch|start) ((l application|l appli|le logiciel|le programme|the app|le|la|les|l|un|une|the|moi) )*(.+)$/;
|
||||
const OPEN_URL = /^(ouvre|ouvrir|va sur|open|go to) (https?:\/\/\S+|(www\.)?[a-z0-9-]+\.[a-z]{2,}(\/\S*)?)$/;
|
||||
const SCREEN = /(regarde|regardes|analyse|decris|decrit|lis|explique|qu est ce qu il y a sur|que vois tu sur|what is on|look at|read) (mon |l |the )?(ecran|screen)|capture (d )?ecran|screenshot/;
|
||||
|
||||
export function parseLocalIntent(text: string): LocalIntent | null {
|
||||
const t = norm(text).replace(/^(jarvis|hey jarvis|ok jarvis)[ ,]*/, '');
|
||||
if (!t) return null;
|
||||
if (LOCK.test(t)) return { kind: 'action', action: { type: 'lock' }, reply: 'Session verrouillée.' };
|
||||
if (MUTE.test(t)) return { kind: 'action', action: { type: 'media', key: 'mute' }, reply: 'Son coupé.' };
|
||||
if (VOL_UP.test(t)) return { kind: 'action', action: { type: 'media', key: 'volume-up' }, reply: 'Volume augmenté.' };
|
||||
if (VOL_DOWN.test(t)) return { kind: 'action', action: { type: 'media', key: 'volume-down' }, reply: 'Volume baissé.' };
|
||||
if (PLAY.test(t)) return { kind: 'action', action: { type: 'media', key: 'play-pause' }, reply: 'Lecture.' };
|
||||
if (NEXT.test(t)) return { kind: 'action', action: { type: 'media', key: 'next' }, reply: 'Piste suivante.' };
|
||||
if (PREV.test(t)) return { kind: 'action', action: { type: 'media', key: 'previous' }, reply: 'Piste précédente.' };
|
||||
if (SCREEN.test(t)) return { kind: 'screenshot', reply: 'Je regarde votre écran.', question: text.trim() };
|
||||
const url = OPEN_URL.exec(t);
|
||||
if (url) {
|
||||
const target = url[2].startsWith('http') ? url[2] : `https://${url[2]}`;
|
||||
return { kind: 'action', action: { type: 'open-url', url: target }, reply: `J’ouvre ${url[2]}.` };
|
||||
}
|
||||
const app = OPEN_APP.exec(t);
|
||||
if (app) {
|
||||
const name = app[app.length - 1].trim();
|
||||
if (name.length <= 40 && !/\s(et|puis|and)\s/.test(name)) return { kind: 'action', action: { type: 'open-app', name }, reply: `J’ouvre ${name}.` };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Execute an action intent through the bridge. Returns the spoken outcome. */
|
||||
export async function runLocalIntent(intent: LocalIntent): Promise<{ ok: boolean; message: string }> {
|
||||
const api = bridge();
|
||||
if (!api || !intent.action) return { ok: false, message: 'Actions locales indisponibles hors Electron.' };
|
||||
try {
|
||||
const result = await api.system.action(intent.action);
|
||||
Log.info('local', `${intent.action.type}: ${result.ok ? 'ok' : result.message}`);
|
||||
return { ok: result.ok, message: result.ok ? intent.reply : result.message || 'Échec de la commande locale.' };
|
||||
} catch (err) {
|
||||
return { ok: false, message: (err as Error).message };
|
||||
}
|
||||
}
|
||||
@@ -113,8 +113,21 @@ class VoiceController {
|
||||
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;
|
||||
let neuralVad = false;
|
||||
if (settings.neuralVad) {
|
||||
try {
|
||||
const installed = (await api.voice.listModels()).some((m) => m.id === 'silero-vad' && m.installed);
|
||||
if (installed) {
|
||||
await api.voice.vadStart({ modelId: 'silero-vad', silenceMs: Math.max(300, Math.min(1500, settings.silenceMs - 200)), threshold: 0.5, maxUtteranceSec: 25 });
|
||||
neuralVad = true;
|
||||
}
|
||||
} catch (err) {
|
||||
Log.warn('voice', `silero unavailable, energy VAD fallback: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
await this.wake.start({
|
||||
deviceId: settings.micDeviceId || undefined,
|
||||
neuralVad,
|
||||
vad: { silenceMs: settings.silenceMs, speechRatio: SENSITIVITY_RATIO[sensitivity], minRms: SENSITIVITY_MIN_RMS[sensitivity] },
|
||||
callbacks: {
|
||||
onPhase: (phase) => {
|
||||
@@ -141,7 +154,8 @@ class VoiceController {
|
||||
}
|
||||
});
|
||||
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(', ')})` : ''}`);
|
||||
voice.setNeuralVad(neuralVad);
|
||||
Log.info('voice', `wake mode on: ${result.accepted.join(', ')}${result.rejected.length ? ` (rejetés : ${result.rejected.join(', ')})` : ''}${neuralVad ? ' · Silero' : ''}`);
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
voice.setWake('error');
|
||||
@@ -149,6 +163,7 @@ class VoiceController {
|
||||
useChat.getState().setError(`Mot d’activation : ${message}`);
|
||||
Log.error('voice', `wake mode failed: ${message}`);
|
||||
await api.voice.kwsStop().catch(() => undefined);
|
||||
await api.voice.vadStop().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +172,10 @@ class VoiceController {
|
||||
this.unsubscribeKws = null;
|
||||
this.wake.stop();
|
||||
useVoice.getState().setWake('off');
|
||||
await bridge()?.voice.kwsStop().catch(() => undefined);
|
||||
useVoice.getState().setNeuralVad(false);
|
||||
const api = bridge();
|
||||
await api?.voice.kwsStop().catch(() => undefined);
|
||||
await api?.voice.vadStop().catch(() => undefined);
|
||||
}
|
||||
|
||||
/** Restart spotting with the current settings (wake word, sensitivity, microphone). */
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Log } from '../../lib/log';
|
||||
import { audioBus } from './audioBus';
|
||||
import { DEFAULT_VAD, EnergyVad, type VadOptions } from './vad';
|
||||
import { buildWav16k, rms, type WavResult } from './wav';
|
||||
import type { VadEvent } from '../../../shared/voice';
|
||||
|
||||
const WORKLET_SOURCE = `
|
||||
class EveFlowWakeProcessor extends AudioWorkletProcessor {
|
||||
@@ -58,6 +59,11 @@ export class WakeListener {
|
||||
private callbacks: WakeCallbacks | null = null;
|
||||
private vadOptions: Partial<VadOptions> = {};
|
||||
private noSpeechTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** Neural end-of-speech (Silero in the main process) instead of the energy VAD. */
|
||||
private neural = false;
|
||||
private unsubscribeVad: (() => void) | null = null;
|
||||
private vadPending: Float32Array[] = [];
|
||||
private vadPendingSamples = 0;
|
||||
|
||||
get isActive(): boolean {
|
||||
return this.phase !== 'off';
|
||||
@@ -67,10 +73,15 @@ export class WakeListener {
|
||||
return this.phase;
|
||||
}
|
||||
|
||||
async start(options: { deviceId?: string; vad?: Partial<VadOptions>; callbacks: WakeCallbacks }): Promise<void> {
|
||||
async start(options: { deviceId?: string; vad?: Partial<VadOptions>; neuralVad?: boolean; callbacks: WakeCallbacks }): Promise<void> {
|
||||
if (this.phase !== 'off') return;
|
||||
this.callbacks = options.callbacks;
|
||||
this.vadOptions = options.vad ?? {};
|
||||
this.neural = !!options.neuralVad;
|
||||
if (this.neural) {
|
||||
this.unsubscribeVad?.();
|
||||
this.unsubscribeVad = bridge()?.voice.onVadEvent((event) => this.onVadEvent(event)) ?? null;
|
||||
}
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
deviceId: options.deviceId ? { exact: options.deviceId } : undefined,
|
||||
@@ -98,13 +109,34 @@ export class WakeListener {
|
||||
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)`);
|
||||
Log.info('wake', `listener started (${this.sampleRate} Hz, fin de phrase ${this.neural ? 'Silero' : 'énergie'})`);
|
||||
}
|
||||
|
||||
private onVadEvent(event: VadEvent): void {
|
||||
if (!this.neural || (this.phase !== 'command' && this.phase !== 'speech')) return;
|
||||
if (event.type === 'speech-start') {
|
||||
if (this.phase === 'command') {
|
||||
this.setPhase('speech');
|
||||
if (this.noSpeechTimer) clearTimeout(this.noSpeechTimer);
|
||||
this.noSpeechTimer = null;
|
||||
}
|
||||
} else if (event.type === 'segment') {
|
||||
const wav: WavResult = { bytes: event.wav, sampleRate: 16_000, durationSec: event.durationSec };
|
||||
this.resumeSpotting();
|
||||
if (wav.durationSec >= 0.25) this.callbacks?.onUtterance(wav);
|
||||
else this.callbacks?.onNoSpeech();
|
||||
} else if (event.type === 'error') {
|
||||
this.callbacks?.onError(event.message);
|
||||
this.resumeSpotting();
|
||||
}
|
||||
}
|
||||
|
||||
/** 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.vad = this.neural ? null : new EnergyVad({ ...DEFAULT_VAD, ...this.vadOptions, noSpeechTimeoutMs: Number.POSITIVE_INFINITY });
|
||||
this.vadPending = [];
|
||||
this.vadPendingSamples = 0;
|
||||
this.chunks = [];
|
||||
this.totalSamples = 0;
|
||||
this.setPhase('command');
|
||||
@@ -126,6 +158,8 @@ export class WakeListener {
|
||||
if (this.phase === 'off') return;
|
||||
if (this.noSpeechTimer) clearTimeout(this.noSpeechTimer);
|
||||
this.noSpeechTimer = null;
|
||||
this.unsubscribeVad?.();
|
||||
this.unsubscribeVad = null;
|
||||
audioBus.setInputAnalyser(null);
|
||||
if (this.node) {
|
||||
this.node.port.onmessage = null;
|
||||
@@ -139,6 +173,8 @@ export class WakeListener {
|
||||
this.chunks = [];
|
||||
this.pending = [];
|
||||
this.pendingSamples = 0;
|
||||
this.vadPending = [];
|
||||
this.vadPendingSamples = 0;
|
||||
this.setPhase('off');
|
||||
Log.info('wake', 'listener stopped');
|
||||
}
|
||||
@@ -170,6 +206,14 @@ export class WakeListener {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.neural) {
|
||||
// command / speech with Silero: stream ~128 ms frames to the main process, which returns the segment.
|
||||
this.vadPending.push(samples);
|
||||
this.vadPendingSamples += samples.length;
|
||||
if (this.vadPendingSamples >= this.sampleRate * 0.128) this.flushToVad();
|
||||
return;
|
||||
}
|
||||
|
||||
// command / speech: collect the utterance
|
||||
this.chunks.push(samples);
|
||||
this.totalSamples += samples.length;
|
||||
@@ -196,21 +240,34 @@ export class WakeListener {
|
||||
}
|
||||
}
|
||||
|
||||
private flushToSpotter(): void {
|
||||
const api = bridge();
|
||||
if (!api) return;
|
||||
const total = this.pendingSamples;
|
||||
private static toInt16(chunks: Float32Array[], total: number): Uint8Array {
|
||||
const merged = new Int16Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of this.pending) {
|
||||
for (const chunk of chunks) {
|
||||
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;
|
||||
}
|
||||
return new Uint8Array(merged.buffer);
|
||||
}
|
||||
|
||||
private flushToSpotter(): void {
|
||||
const api = bridge();
|
||||
if (!api) return;
|
||||
const bytes = WakeListener.toInt16(this.pending, this.pendingSamples);
|
||||
this.pending = [];
|
||||
this.pendingSamples = 0;
|
||||
api.voice.kwsAudio(new Uint8Array(merged.buffer), this.sampleRate);
|
||||
api.voice.kwsAudio(bytes, this.sampleRate);
|
||||
}
|
||||
|
||||
private flushToVad(): void {
|
||||
const api = bridge();
|
||||
if (!api) return;
|
||||
const bytes = WakeListener.toInt16(this.vadPending, this.vadPendingSamples);
|
||||
this.vadPending = [];
|
||||
this.vadPendingSamples = 0;
|
||||
api.voice.vadAudio(bytes, this.sampleRate);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,10 @@ export interface VoiceSettings extends SttConfig {
|
||||
/** off = push-to-talk / hands-free; transcript = filter after transcription; kws = always-on keyword spotting. */
|
||||
wakeMode: 'off' | 'transcript' | 'kws';
|
||||
kwsSensitivity: number; // 1..5
|
||||
/** Use Silero VAD for end-of-speech in always-on mode when the model is installed. */
|
||||
neuralVad: boolean;
|
||||
/** Execute short system intents locally (lock, volume, open app…) instead of asking Hermes. */
|
||||
localCommands: boolean;
|
||||
}
|
||||
|
||||
export interface SpeechSettings extends TtsConfig {
|
||||
@@ -88,6 +92,8 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
wakeWord: 'jarvis',
|
||||
wakeMode: 'off',
|
||||
kwsSensitivity: 3,
|
||||
neuralVad: true,
|
||||
localCommands: true,
|
||||
localModel: 'whisper-base'
|
||||
},
|
||||
speech: {
|
||||
|
||||
@@ -15,6 +15,8 @@ interface VoiceStore {
|
||||
micDevices: Array<{ deviceId: string; label: string }>;
|
||||
wake: WakeState;
|
||||
wakeKeywords: string[];
|
||||
neuralVad: boolean;
|
||||
setNeuralVad: (on: boolean) => void;
|
||||
setWake: (state: WakeState, keywords?: string[]) => void;
|
||||
setPhase: (phase: ListenPhase) => void;
|
||||
setInputLevel: (level: number) => void;
|
||||
@@ -37,6 +39,8 @@ export const useVoice = create<VoiceStore>((set) => ({
|
||||
micDevices: [],
|
||||
wake: 'off',
|
||||
wakeKeywords: [],
|
||||
neuralVad: false,
|
||||
setNeuralVad: (neuralVad) => set({ neuralVad }),
|
||||
setWake: (wake, wakeKeywords) => set(wakeKeywords ? { wake, wakeKeywords } : { wake }),
|
||||
setPhase: (phase) => set({ phase }),
|
||||
setInputLevel: (inputLevel) => set({ inputLevel }),
|
||||
|
||||
Reference in new issue
Block a user