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:
LogiFlowandClaude authored and GitHub committed 2026-09-03 20:46:26 +02:00
1 parent 4f6074183f
commit af8bdc5e9d
24 files changed
+691 -47

No files matched your search

+16 -5
View File
@@ -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 ? (
+10 -2
View File
@@ -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>
+5 -1
View File
@@ -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>