mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-23 07:08:14 +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
@@ -86,6 +86,19 @@ export const VOICE_CATALOG: VoiceModelSpec[] = [
|
||||
],
|
||||
recommended: true
|
||||
},
|
||||
{
|
||||
id: 'silero-vad',
|
||||
kind: 'vad',
|
||||
engine: 'silero',
|
||||
name: 'Silero VAD (fin de phrase neuronale, 0,6 Mo)',
|
||||
description: 'Détecte précisément le début et la fin de la parole pendant l’écoute permanente ; moins de faux départs sur le bruit.',
|
||||
languages: ['multi'],
|
||||
sizeMb: 1,
|
||||
url: `${ASR}/silero_vad.onnx`,
|
||||
dir: 'silero-vad',
|
||||
files: ['silero_vad.onnx'],
|
||||
recommended: true
|
||||
},
|
||||
{
|
||||
id: 'kokoro-v1',
|
||||
kind: 'tts',
|
||||
|
||||
@@ -6,7 +6,7 @@ import { utilityProcess, type UtilityProcess, type WebContents } from 'electron'
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { VOICE_IPC, type KwsDetection, type KwsStartRequest, type SynthesizeRequest, type SynthesizeResult, type TranscribeRequest, type TranscribeResult, type VoiceEngineStatus } from '../../shared/voice';
|
||||
import { VOICE_IPC, type KwsDetection, type KwsStartRequest, type SynthesizeRequest, type SynthesizeResult, type TranscribeRequest, type TranscribeResult, type VadEvent, type VadStartRequest, type VoiceEngineStatus } from '../../shared/voice';
|
||||
import { buildKeywordsFile, parseTokens } from '../../shared/keywords';
|
||||
import { findModel } from './catalog';
|
||||
import { isInstalled, modelDir, modelsDir } from './models';
|
||||
@@ -16,6 +16,8 @@ import { log } from '../logger';
|
||||
let kwsSubscriber: WebContents | null = null;
|
||||
let kwsActive = false;
|
||||
let kwsRequest: KwsStartRequest | null = null;
|
||||
let vadSubscriber: WebContents | null = null;
|
||||
let vadActive = false;
|
||||
|
||||
interface Pending {
|
||||
resolve: (value: unknown) => void;
|
||||
@@ -42,7 +44,20 @@ 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; type?: string; ok?: boolean; result?: unknown; error?: string; keyword?: string; at?: number }) => {
|
||||
proc.on('message', (msg: { id?: number; type?: string; ok?: boolean; result?: unknown; error?: string; keyword?: string; at?: number; event?: { type: string; wav?: string; durationSec?: number } }) => {
|
||||
if (msg.type === 'vad.event' && msg.event) {
|
||||
if (vadSubscriber && !vadSubscriber.isDestroyed()) {
|
||||
const ev = msg.event;
|
||||
const payload: VadEvent =
|
||||
ev.type === 'segment' && ev.wav
|
||||
? { type: 'segment', wav: new Uint8Array(Buffer.from(ev.wav, 'base64')), durationSec: ev.durationSec ?? 0 }
|
||||
: ev.type === 'speech-start'
|
||||
? { type: 'speech-start' }
|
||||
: { type: 'error', message: 'événement VAD inconnu' };
|
||||
vadSubscriber.send(VOICE_IPC.vadEvent, payload);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'kws.detected') {
|
||||
if (kwsSubscriber && !kwsSubscriber.isDestroyed()) {
|
||||
kwsSubscriber.send(VOICE_IPC.kwsDetected, { keyword: msg.keyword ?? '', at: msg.at ?? Date.now() } satisfies KwsDetection);
|
||||
@@ -180,6 +195,35 @@ export function kwsFeed(pcm: Uint8Array, sampleRate: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Neural end-of-speech detection (Silero) on frames streamed by the renderer. */
|
||||
export async function vadStart(req: VadStartRequest, sender: WebContents): Promise<void> {
|
||||
const spec = findModel(req.modelId);
|
||||
if (!spec || spec.kind !== 'vad') throw new Error('Modèle VAD introuvable');
|
||||
if (!isInstalled(spec)) throw new Error('Silero VAD non installé (Paramètres → Modèles locaux).');
|
||||
vadSubscriber = sender;
|
||||
await request(
|
||||
{ type: 'vad.start', model: { id: spec.id, engine: spec.engine, dir: modelDir(spec), files: spec.files }, silenceMs: req.silenceMs, threshold: req.threshold, maxUtteranceSec: req.maxUtteranceSec },
|
||||
30_000
|
||||
);
|
||||
vadActive = true;
|
||||
}
|
||||
|
||||
export async function vadStop(): Promise<void> {
|
||||
vadActive = false;
|
||||
vadSubscriber = null;
|
||||
if (child) await request({ type: 'vad.stop' }, 10_000).catch(() => undefined);
|
||||
}
|
||||
|
||||
export function vadFeed(pcm: Uint8Array, sampleRate: number): void {
|
||||
if (!vadActive || !child) return;
|
||||
const b64 = Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength).toString('base64');
|
||||
try {
|
||||
child.postMessage({ id: -1, type: 'vad.audio', pcm: b64, sampleRate });
|
||||
} catch (err) {
|
||||
log('WARN', 'voice', `vad 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();
|
||||
|
||||
+11
-2
@@ -1,6 +1,6 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import { VOICE_IPC, type KwsStartRequest, type SynthesizeRequest, type TranscribeRequest } from '../../shared/voice';
|
||||
import { engineStatus, kwsFeed, kwsStart, kwsStop, synthesize, transcribe, unload } from './engine';
|
||||
import { VOICE_IPC, type KwsStartRequest, type SynthesizeRequest, type TranscribeRequest, type VadStartRequest } from '../../shared/voice';
|
||||
import { engineStatus, kwsFeed, kwsStart, kwsStop, synthesize, transcribe, unload, vadFeed, vadStart, vadStop } from './engine';
|
||||
import { cancelDownload, downloadModel, listModels, removeModel } from './models';
|
||||
|
||||
export function registerVoiceIpc(): void {
|
||||
@@ -32,6 +32,15 @@ export function registerVoiceIpc(): void {
|
||||
return kwsStart({ ...req, keywords: req.keywords.filter((k) => typeof k === 'string'), sensitivity: Number(req.sensitivity) || 3 }, event.sender);
|
||||
});
|
||||
ipcMain.handle(VOICE_IPC.kwsStop, () => kwsStop());
|
||||
ipcMain.handle(VOICE_IPC.vadStart, (event, req: VadStartRequest) => {
|
||||
if (!req || typeof req.modelId !== 'string') throw new Error('Requête invalide');
|
||||
return vadStart({ modelId: req.modelId, silenceMs: Number(req.silenceMs) || 700, threshold: Number(req.threshold) || 0.5, maxUtteranceSec: Number(req.maxUtteranceSec) || 30 }, event.sender);
|
||||
});
|
||||
ipcMain.handle(VOICE_IPC.vadStop, () => vadStop());
|
||||
ipcMain.on(VOICE_IPC.vadAudio, (_e, pcm: unknown, sampleRate: unknown) => {
|
||||
if (!(pcm instanceof Uint8Array) || pcm.byteLength === 0 || pcm.byteLength > 1024 * 1024) return;
|
||||
vadFeed(pcm, typeof sampleRate === 'number' && sampleRate > 0 ? sampleRate : 16000);
|
||||
});
|
||||
ipcMain.on(VOICE_IPC.kwsAudio, (_e, pcm: unknown, sampleRate: unknown) => {
|
||||
if (!(pcm instanceof Uint8Array) || pcm.byteLength === 0 || pcm.byteLength > 1024 * 1024) return;
|
||||
kwsFeed(pcm, typeof sampleRate === 'number' && sampleRate > 0 ? sampleRate : 16000);
|
||||
|
||||
@@ -21,7 +21,10 @@ type Request =
|
||||
| { id: number; type: 'unload'; modelId?: string }
|
||||
| { id: number; type: 'kws.start'; model: ModelRef; keywordsFile: string; threshold: number; score: number }
|
||||
| { id: number; type: 'kws.audio'; pcm: string; sampleRate: number }
|
||||
| { id: number; type: 'kws.stop' };
|
||||
| { id: number; type: 'kws.stop' }
|
||||
| { id: number; type: 'vad.start'; model: ModelRef; silenceMs: number; threshold: number; maxUtteranceSec: number }
|
||||
| { id: number; type: 'vad.audio'; pcm: string; sampleRate: number }
|
||||
| { id: number; type: 'vad.stop' };
|
||||
|
||||
type Response = { id: number; ok: true; result: unknown } | { id: number; ok: false; error: string };
|
||||
|
||||
@@ -32,6 +35,16 @@ type Sherpa = {
|
||||
decode: (s: unknown) => void;
|
||||
getResult: (s: unknown) => { text: string; lang?: string };
|
||||
};
|
||||
Vad: new (config: unknown, bufferSizeInSeconds: number) => {
|
||||
acceptWaveform: (samples: Float32Array) => void;
|
||||
isEmpty: () => boolean;
|
||||
isDetected: () => boolean;
|
||||
pop: () => void;
|
||||
clear: () => void;
|
||||
front: (enableExternalBuffer?: boolean) => { start: number; samples: Float32Array };
|
||||
reset: () => void;
|
||||
flush: () => void;
|
||||
};
|
||||
KeywordSpotter: new (config: unknown) => {
|
||||
createStream: () => KwsStream;
|
||||
isReady: (s: KwsStream) => boolean;
|
||||
@@ -50,6 +63,7 @@ type Sherpa = {
|
||||
type KwsStream = { acceptWaveform: (w: { sampleRate: number; samples: Float32Array }) => void };
|
||||
let sherpa: Sherpa | null = null;
|
||||
let kws: { spotter: InstanceType<Sherpa['KeywordSpotter']>; stream: KwsStream } | null = null;
|
||||
let vad: { detector: InstanceType<Sherpa['Vad']>; speaking: boolean; windowSize: number; carry: Float32Array } | null = null;
|
||||
let notify: ((message: unknown) => void) | null = null;
|
||||
let loadError: string | null = null;
|
||||
|
||||
@@ -275,9 +289,74 @@ function feedKws(pcmBase64: string, sampleRate: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
function startVad(model: ModelRef, silenceMs: number, threshold: number, maxUtteranceSec: number): void {
|
||||
const s = loadSherpa();
|
||||
const windowSize = 512;
|
||||
const detector = new s.Vad(
|
||||
{
|
||||
sileroVad: {
|
||||
model: path.join(model.dir, 'silero_vad.onnx'),
|
||||
threshold: Math.max(0.1, Math.min(0.95, threshold)),
|
||||
minSilenceDuration: Math.max(0.15, silenceMs / 1000),
|
||||
minSpeechDuration: 0.2,
|
||||
windowSize,
|
||||
maxSpeechDuration: Math.max(3, maxUtteranceSec)
|
||||
},
|
||||
sampleRate: 16000,
|
||||
numThreads: 1,
|
||||
provider: 'cpu',
|
||||
debug: 0
|
||||
},
|
||||
Math.max(10, maxUtteranceSec + 5)
|
||||
);
|
||||
vad = { detector, speaking: false, windowSize, carry: new Float32Array(0) };
|
||||
}
|
||||
|
||||
function feedVad(pcmBase64: string, sampleRate: number): void {
|
||||
if (!vad) return;
|
||||
const bytes = Buffer.from(pcmBase64, 'base64');
|
||||
const int16 = new Int16Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 2));
|
||||
let samples: Float32Array = new Float32Array(int16.length);
|
||||
for (let i = 0; i < int16.length; i++) samples[i] = int16[i] / 32768;
|
||||
if (sampleRate !== 16000) samples = resampleTo16k(samples, sampleRate);
|
||||
// Silero expects fixed windows: keep the remainder for the next frame.
|
||||
const merged = new Float32Array(vad.carry.length + samples.length);
|
||||
merged.set(vad.carry, 0);
|
||||
merged.set(samples, vad.carry.length);
|
||||
const usable = merged.length - (merged.length % vad.windowSize);
|
||||
for (let i = 0; i < usable; i += vad.windowSize) {
|
||||
vad.detector.acceptWaveform(merged.subarray(i, i + vad.windowSize));
|
||||
const detected = vad.detector.isDetected();
|
||||
if (detected && !vad.speaking) {
|
||||
vad.speaking = true;
|
||||
notify?.({ type: 'vad.event', event: { type: 'speech-start' } });
|
||||
}
|
||||
while (!vad.detector.isEmpty()) {
|
||||
const segment = vad.detector.front(false);
|
||||
vad.detector.pop();
|
||||
vad.speaking = false;
|
||||
const wav = encodeWav(segment.samples, 16000);
|
||||
notify?.({
|
||||
type: 'vad.event',
|
||||
event: { type: 'segment', wav: Buffer.from(wav.buffer, wav.byteOffset, wav.byteLength).toString('base64'), durationSec: segment.samples.length / 16000 }
|
||||
});
|
||||
}
|
||||
}
|
||||
vad.carry = merged.slice(usable);
|
||||
}
|
||||
|
||||
// ── request handling ───────────────────────────────────────────────────────
|
||||
function handle(req: Request): unknown {
|
||||
switch (req.type) {
|
||||
case 'vad.start':
|
||||
startVad(req.model, req.silenceMs, req.threshold, req.maxUtteranceSec);
|
||||
return { ok: true };
|
||||
case 'vad.audio':
|
||||
feedVad(req.pcm, req.sampleRate);
|
||||
return { ok: true };
|
||||
case 'vad.stop':
|
||||
vad = null;
|
||||
return { ok: true };
|
||||
case 'kws.start':
|
||||
startKws(req.model, req.keywordsFile, req.threshold, req.score);
|
||||
return { ok: true };
|
||||
@@ -331,6 +410,7 @@ function handle(req: Request): unknown {
|
||||
recognizers.clear();
|
||||
synthesizers.clear();
|
||||
kws = null;
|
||||
vad = null;
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user