mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-11 17:29:03 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ef440e8cf | ||
|
|
d1b12c16c5 | ||
|
|
df25dd591c |
No files matched your search
@@ -1,7 +1,7 @@
|
||||
# EveFlow 2 — Interface vocale JARVIS pour Hermes Agent
|
||||
|
||||
[](https://github.com/R0m1k3/EveFlow/actions)
|
||||
[](https://github.com/R0m1k3/EveFlow/releases)
|
||||
[](https://github.com/R0m1k3/EveFlow/releases)
|
||||
[](LICENSE)
|
||||
|
||||
**EveFlow** est un compagnon de bureau Windows qui transforme [Hermes Agent](https://hermes-agent.nousresearch.com/) en assistant vocal à la JARVIS : un noyau holographique réactif au son, une conversation en streaming, les outils, sous-agents, approbations, crons, skills et sessions d'Hermes pilotés depuis un seul HUD.
|
||||
@@ -30,6 +30,8 @@ La version 2 est une réécriture complète : plus de robot 3D, un pipeline voca
|
||||
* **Heures calmes et priorités** : plage horaire pendant laquelle les messages poussés s'affichent sans être lus ni faire clignoter le noyau (badge « non lus » à la place), thème nuit automatique, mots prioritaires lus quand même, résumé vocal des rapports longs (les premières phrases seulement).
|
||||
* **Mode mission** : un bouton dans la barre de commande bascule sur un second modèle Hermes (plus puissant) pour les tâches longues ; le modèle rapide reste utilisé pour la conversation courante.
|
||||
* **Widget compact « glanceable »** : état (veille, écoute, réflexion, parle), dernière phrase de l'assistant, badge de non-lus, indicateurs heures calmes et mission.
|
||||
* **Voix JARVIS** : préréglage en un clic (Paramètres → Voix) : voix française masculine locale (Piper Tom, téléchargée automatiquement), timbre « JARVIS » (légèrement plus grave et posé, chaleur, présence, courte réverbération d'intercom), débit calme. Kokoro n'a pas de voix française masculine.
|
||||
* **Reconnaissance française de référence** : Parakeet TDT 0.6B v3 (NVIDIA NeMo, 25 langues européennes) dans le catalogue, plus précis et bien plus rapide que Whisper sur processeur, avec ponctuation. Whisper base/small/turbo restent disponibles.
|
||||
* **Voix masculine ou féminine** : un réglage unique (Paramètres → Voix) appliqué à tous les moteurs. En local, Piper Tom ou Pierre (UPMC) pour le masculin, téléchargé automatiquement si aucune voix masculine n'est installée ; onyx / nova pour les API compatibles OpenAI ; Paul / Hortense pour les voix Windows.
|
||||
* **Barge-in** : en mains libres, parler par-dessus l'assistant coupe sa voix ; le seuil est relevé pendant qu'il parle pour ignorer l'écho du haut-parleur.
|
||||
* **Écoute permanente** : un détecteur de mot-clé de 3 Mo (sherpa-onnx, keyword spotting) tourne en continu sur le micro, quasi gratuit en CPU. « Jarvis » (ou n'importe quel mot-clé) ouvre l'écoute, « Jarvis, allume… » envoie directement la commande, et le mot coupe la voix en cours. Alternative : filtre du mot après transcription en mains libres.
|
||||
@@ -112,6 +114,10 @@ mcp_servers:
|
||||
|
||||
Sans secret, EveFlow n'écoute qu'en local (`127.0.0.1`) ; définissez un secret dans Paramètres → Webhook pour un Hermes distant. Outils exposés : `capture_screen`, `lock_session`, `open_app`, `open_url`, `media_key`, `clipboard_get`, `clipboard_set`, `find_files`, `speak_text`, `notify_user`, `set_hud_state`, `get_app_status`, `get_conversation_history`, `show_message`.
|
||||
|
||||
### L'URL répond par une page web ?
|
||||
|
||||
Si « Tester la liaison » signale « Le serveur renvoie une page web … au lieu de l'API Hermes », l'URL saisie pointe vers un portail (page de connexion, tableau de bord) et non vers le serveur API. EveFlow cherche alors automatiquement l'API sur le même hôte (port 8642, chemins `/api` et `/v1`, sous-domaines `api.` ou `hermes.`) et corrige l'URL s'il la trouve. Sinon, ouvrez le port 8642 du serveur Hermes ou exposez-le sur un chemin dédié de votre proxy, sans authentification web devant lui (la clé `API_SERVER_KEY` suffit).
|
||||
|
||||
## Développement
|
||||
|
||||
```bash
|
||||
|
||||
@@ -77,7 +77,12 @@ Sources : [jarvis-desktop-ai](https://github.com/ccarloshenri/jarvis-desktop-ai)
|
||||
| Silero VAD sur phrase Kokoro de 2,3 s (2.3.0) | un seul segment, début et fin détectés, 6,7 s d'audio traités en 190 ms |
|
||||
| Capture d'écran → Hermes (2.3.0) | JPEG de 107 ko reçu côté Hermes (mock chat completions) |
|
||||
| Intention locale « coupe le son » (2.3.0) | traitée sans Hermes, résultat affiché dans le fil |
|
||||
| Voix JARVIS et Parakeet (2.4.1) | timbre JARVIS (Web Audio : pitch, EQ, compression, réverbération courte), préréglage voix masculine française, Parakeet TDT v3 pour le français, worklets audio livrés en fichiers statiques (l'écoute permanente échouait sous la CSP stricte en version installée), rembourrage de silence avant la reconnaissance |
|
||||
| Correctif 2.4.0.3 | un portail qui laisse passer /health mais renvoie une page web sur /v1 est traité comme une liaison en échec et déclenche la recherche de l'API (sondes /v1/capabilities, /v1/models) |
|
||||
| Correctif 2.4.0.2 | une page web (portail de connexion) reçue à la place de l'API Hermes est reconnue et expliquée ; l'API est recherchée automatiquement sur le même hôte (port 8642, /api, hermes.…) et l'URL corrigée |
|
||||
| Correctifs 2.4.0.1 | réponse vide en chat completions désormais expliquée (JSON non streamé ou erreur HTTP 200), la liaison ne passe plus en « dégradé » quand seule l'API des crons échoue, transcriptions parasites (« (cliquant) », « *Claire* ») ignorées, préférence de voix masculine/féminine |
|
||||
| Parakeet v3 vs Whisper base sur trois phrases Piper (fr) (2.4.1) | Parakeet : 3/3 exactes avec ponctuation, 0,4 à 0,5 s à chaud (6,7 s au premier appel) ; Whisper base : erreurs sur « Jarvis », « Peux-tu », 0,7 à 0,9 s |
|
||||
| Worklets audio sous CSP stricte (2.4.1) | chargement des modules statiques OK dans l'application empaquetée |
|
||||
| Serveur MCP (2.4.0) | initialize, tools/list (14 outils), tools/call côté principal (presse-papiers, capture image) et côté renderer (état, message dans le fil) |
|
||||
|
||||
Sur un PC à 28 cœurs les temps sont nettement plus courts. Whisper small est maintenant recommandé pour le français.
|
||||
@@ -44,12 +44,25 @@ export const VOICE_CATALOG: VoiceModelSpec[] = [
|
||||
files: ['small-encoder.int8.onnx', 'small-decoder.int8.onnx', 'small-tokens.txt'],
|
||||
recommended: true
|
||||
},
|
||||
{
|
||||
id: 'parakeet-v3',
|
||||
kind: 'stt',
|
||||
engine: 'nemo-transducer',
|
||||
name: 'Parakeet TDT 0.6B v3 (25 langues européennes, français)',
|
||||
description: 'Le plus précis en français et nettement plus rapide que Whisper sur processeur (NVIDIA NeMo, int8). Ponctuation et majuscules incluses.',
|
||||
languages: ['fr', 'en', 'de', 'es', 'it', 'multi'],
|
||||
sizeMb: 640,
|
||||
url: `${ASR}/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2`,
|
||||
dir: 'sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8',
|
||||
files: ['encoder.int8.onnx', 'decoder.int8.onnx', 'joiner.int8.onnx', 'tokens.txt'],
|
||||
recommended: true
|
||||
},
|
||||
{
|
||||
id: 'whisper-turbo',
|
||||
kind: 'stt',
|
||||
engine: 'whisper',
|
||||
name: 'Whisper large-v3 turbo (multilingue)',
|
||||
description: 'La meilleure précision ; demande un processeur puissant.',
|
||||
description: 'Très bonne précision multilingue ; 2 à 4 s par phrase sur un processeur récent. Parakeet est plus rapide en français.',
|
||||
languages: ['fr', 'en', 'multi'],
|
||||
sizeMb: 564,
|
||||
url: `${ASR}/sherpa-onnx-whisper-turbo.tar.bz2`,
|
||||
|
||||
@@ -379,8 +379,13 @@ function handle(req: Request): unknown {
|
||||
// Audio crosses the process boundary as base64: V8 refuses to serialize external buffers.
|
||||
const bytes = typeof req.wav === 'string' ? new Uint8Array(Buffer.from(req.wav, 'base64')) : req.wav;
|
||||
const { samples, sampleRate } = decodeWav(bytes);
|
||||
const pcm = resampleTo16k(samples, sampleRate);
|
||||
if (pcm.length < 1600) throw new Error('Audio trop court');
|
||||
const raw = resampleTo16k(samples, sampleRate);
|
||||
if (raw.length < 1600) throw new Error('Audio trop court');
|
||||
// 0.4 s of silence on both sides: utterances cut close to the words lose the first/last syllable
|
||||
// and short clips make Whisper hallucinate.
|
||||
const pad = 6400;
|
||||
const pcm = new Float32Array(raw.length + 2 * pad);
|
||||
pcm.set(raw, pad);
|
||||
const recognizer = getRecognizer(req.model, req.language);
|
||||
const stream = recognizer.createStream();
|
||||
stream.acceptWaveform({ sampleRate: 16000, samples: pcm });
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "eveflow",
|
||||
"version": "2.4.0",
|
||||
"releaseVersion": "2.4.0.1",
|
||||
"version": "2.4.1",
|
||||
"releaseVersion": "2.4.1",
|
||||
"description": "JARVIS-style desktop HUD for Hermes Agent: voice, streaming runs, scheduled jobs, skills and telemetry",
|
||||
"main": "dist-electron/main.js",
|
||||
"private": true,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
class EveFlowCaptureProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.buffer = new Float32Array(2048);
|
||||
this.offset = 0;
|
||||
}
|
||||
process(inputs) {
|
||||
const input = inputs[0];
|
||||
if (!input || input.length === 0) return true;
|
||||
const channel = input[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-capture', EveFlowCaptureProcessor);
|
||||
@@ -0,0 +1,19 @@
|
||||
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);
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { X, Settings, CheckCircle2, XCircle, Loader2, Mic, Volume2, RotateCcw, Play } from 'lucide-react';
|
||||
import { bridge } from '../../lib/bridge';
|
||||
import { HermesClient } from '../../services/hermes/client';
|
||||
import { HermesClient, discoverHermesUrl, hermesUrlCandidates } from '../../services/hermes/client';
|
||||
import { listSystemVoices } from '../../services/voice/tts';
|
||||
import { speech } from '../../services/voice/speech';
|
||||
import { ensurePreferredVoice } from '../../services/voice/voicePreference';
|
||||
@@ -108,6 +108,7 @@ export function SettingsDrawer({ onClose }: Props) {
|
||||
caps = ` · modèle ${String(c.model ?? '?')} · ${Object.entries(c.features ?? {}).filter(([, v]) => v === true).length} fonctions`;
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
if (/page web/.test(message)) throw err;
|
||||
caps = /404/.test(message)
|
||||
? ' · /v1/capabilities absent (Hermes ancien : transport chat completions)'
|
||||
: ` · capabilities : ${message}`;
|
||||
@@ -116,7 +117,16 @@ export function SettingsDrawer({ onClose }: Props) {
|
||||
setHermesTest({ status: 'ok', message: `${health.status} (${via})${caps}` });
|
||||
void hermesConnect();
|
||||
} catch (err) {
|
||||
setHermesTest({ status: 'fail', message: (err as Error).message });
|
||||
const message = (err as Error).message;
|
||||
setHermesTest({ status: 'running', message: `${message} — recherche de l’API sur le même hôte…` });
|
||||
const found = await discoverHermesUrl(settings.hermes, (u) => setHermesTest({ status: 'running', message: `essai ${u}…` })).catch(() => null);
|
||||
if (found) {
|
||||
update({ hermes: { url: found } });
|
||||
setHermesTest({ status: 'ok', message: `API Hermes trouvée : ${found} (URL corrigée automatiquement). Relancez le test.` });
|
||||
void hermesConnect();
|
||||
} else {
|
||||
setHermesTest({ status: 'fail', message: `${message} Aucune API trouvée sur ${hermesUrlCandidates(settings.hermes.url).slice(0, 4).join(', ')}…` });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -364,7 +374,32 @@ export function SettingsDrawer({ onClose }: Props) {
|
||||
<button className={(settings.speech.voiceGender ?? 'male') === 'male' ? 'active' : ''} onClick={() => { update({ speech: { voiceGender: 'male' } }); void ensurePreferredVoice().then((m) => m && setTtsTest({ status: 'ok', message: m })); }}>Masculine</button>
|
||||
<button className={settings.speech.voiceGender === 'female' ? 'active' : ''} onClick={() => { update({ speech: { voiceGender: 'female' } }); void ensurePreferredVoice().then((m) => m && setTtsTest({ status: 'ok', message: m })); }}>Féminine</button>
|
||||
</div>
|
||||
<span className="hint">S’applique à tous les moteurs : voix locale (Piper Tom ou Pierre pour le masculin, téléchargée automatiquement si besoin), voix OpenAI (onyx / nova), voix système Windows (Paul / Hortense).</span>
|
||||
<span className="hint">S’applique à tous les moteurs : voix locale (Piper Tom ou Pierre pour le masculin, téléchargée automatiquement si besoin), voix OpenAI (onyx / nova), voix système Windows (Paul / Hortense). Kokoro n’a pas de voix française masculine.</span>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Timbre</label>
|
||||
<div className="segmented">
|
||||
<button className={settings.speech.timbre === 'jarvis' ? 'active' : ''} onClick={() => update({ speech: { timbre: 'jarvis' } })}>JARVIS</button>
|
||||
<button className={settings.speech.timbre !== 'jarvis' ? 'active' : ''} onClick={() => update({ speech: { timbre: 'natural' } })}>Naturel</button>
|
||||
</div>
|
||||
<span className="hint">JARVIS : voix légèrement plus grave et posée, chaleur dans les basses, présence, courte réverbération d’intercom, comme dans le film.</span>
|
||||
</div>
|
||||
<div className="row" style={{ marginBottom: 10 }}>
|
||||
<button
|
||||
className="btn small primary"
|
||||
onClick={() => {
|
||||
update({ speech: { provider: 'local', voiceGender: 'male', timbre: 'jarvis', speed: 0.97, autoSpeak: true } });
|
||||
setTtsTest({ status: 'running', message: 'préparation de la voix JARVIS (téléchargement de Piper Tom si nécessaire)…' });
|
||||
void ensurePreferredVoice().then((m) => setTtsTest({ status: 'ok', message: m || 'Voix JARVIS prête.' })).catch((e: Error) => setTtsTest({ status: 'fail', message: e.message }));
|
||||
}}
|
||||
>
|
||||
<Volume2 size={13} /> Préréglage voix JARVIS (français, masculine, locale)
|
||||
</button>
|
||||
<span className="status-pill">
|
||||
{settings.speech.provider === 'local'
|
||||
? `voix active : ${voiceModels.find((m) => m.id === settings.speech.localModel)?.speakers?.find((s) => s.id === settings.speech.localSpeaker)?.name ?? settings.speech.localModel}`
|
||||
: `moteur : ${settings.speech.provider}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Moteur de synthèse</label>
|
||||
|
||||
@@ -31,6 +31,75 @@ const isRec = (v: unknown): v is Rec => !!v && typeof v === 'object' && !Array.i
|
||||
|
||||
export type ResolvedTransport = Exclude<HermesTransport, 'auto'>;
|
||||
|
||||
/**
|
||||
* A web page (login portal, dashboard, reverse-proxy error) instead of JSON means the URL does not
|
||||
* point at the Hermes API. Returns a human explanation, or null when the body is not HTML.
|
||||
*/
|
||||
export function describeHtml(body: string): string | null {
|
||||
const head = body.slice(0, 600).trimStart().toLowerCase();
|
||||
if (!head.startsWith('<!doctype html') && !head.startsWith('<html') && !/^<\?xml[^>]*>\s*<html/.test(head)) return null;
|
||||
const title = /<title[^>]*>([^<]{1,120})<\/title>/i.exec(body)?.[1]?.trim();
|
||||
const login = /connecter|login|sign in|authentif|mot de passe|password/i.test(body.slice(0, 20_000));
|
||||
return `Le serveur renvoie une page web${title ? ` « ${title} »` : ''} au lieu de l'API Hermes${login ? ' (page de connexion : l’URL passe par un portail web)' : ''}. Utilisez l'URL directe du serveur API Hermes (port 8642 par défaut, ou le chemin /v1 exposé par votre proxy).`;
|
||||
}
|
||||
|
||||
/** Candidate API URLs derived from what the user typed (same host, other port or path). */
|
||||
export function hermesUrlCandidates(url: string): string[] {
|
||||
const base = hermesBaseUrl(url);
|
||||
if (!base) return [];
|
||||
const out = new Set<string>();
|
||||
const add = (u: string) => out.add(u.replace(/\/+$/, ''));
|
||||
try {
|
||||
const u = new URL(base.includes('://') ? base : `http://${base}`);
|
||||
const host = u.hostname;
|
||||
const scheme = u.protocol.replace(':', '');
|
||||
const path = u.pathname.replace(/\/+$/, '');
|
||||
if (path) add(`${scheme}://${u.host}`);
|
||||
for (const suffix of ['/api', '/hermes', '/hermes/api', '/v1', '/api/v1']) add(`${scheme}://${u.host}${path}${suffix}`);
|
||||
if (!u.port) {
|
||||
add(`${scheme}://${host}:8642`);
|
||||
add(`http://${host}:8642`);
|
||||
add(`https://${host}:8642`);
|
||||
}
|
||||
for (const sub of ['api', 'hermes-api']) {
|
||||
if (!host.startsWith(`${sub}.`) && host.includes('.')) add(`${scheme}://${sub}.${host}`);
|
||||
}
|
||||
if (host.startsWith('jarvis.')) add(`${scheme}://hermes.${host.slice('jarvis.'.length)}`);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
out.delete(base);
|
||||
return [...out];
|
||||
}
|
||||
|
||||
/** True when this base URL answers like the Hermes API (JSON on /v1/*, or 401/403 = key required). */
|
||||
export async function probeHermesUrl(config: HermesConfig, url: string): Promise<boolean> {
|
||||
const client = new HermesClient({ ...config, url });
|
||||
for (const path of ['/v1/capabilities', '/v1/models', '/health']) {
|
||||
try {
|
||||
const payload = await client.request<unknown>(path, { timeoutMs: 4000 });
|
||||
if (payload && typeof payload === 'object') return true;
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
if (err.status === 401 || err.status === 403) return true; // the API is there, only the key is missing
|
||||
if (/page web/.test(err.message)) return false; // a portal answers on this host: not the API
|
||||
if (err.status === 404) continue; // older Hermes without this endpoint
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Probe candidate URLs until one answers like the Hermes API. */
|
||||
export async function discoverHermesUrl(config: HermesConfig, onProgress?: (url: string) => void): Promise<string | null> {
|
||||
for (const candidate of hermesUrlCandidates(config.url)) {
|
||||
onProgress?.(candidate);
|
||||
if (await probeHermesUrl(config, candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hermesBaseUrl(url: string): string {
|
||||
let base = url.trim().replace(/\/+$/, '');
|
||||
base = base.replace(/\/chat\/completions$/, '');
|
||||
@@ -93,7 +162,7 @@ export class HermesClient {
|
||||
return h;
|
||||
}
|
||||
|
||||
private async request<T>(path: string, init: { method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; body?: unknown; timeoutMs?: number } = {}): Promise<T> {
|
||||
async request<T>(path: string, init: { method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; body?: unknown; timeoutMs?: number } = {}): Promise<T> {
|
||||
if (!this.config.url.trim()) throw new Error("URL Hermes non configurée.");
|
||||
const url = `${this.base}${path}`;
|
||||
const res = await httpFetch({
|
||||
@@ -103,11 +172,13 @@ export class HermesClient {
|
||||
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
|
||||
timeoutMs: init.timeoutMs ?? 20_000
|
||||
});
|
||||
if (!res.ok) throw new HttpError(res.status, errorMessage(res.status, res.text ?? ''), res.text);
|
||||
const text = res.text ?? '';
|
||||
const html = describeHtml(text);
|
||||
if (html) throw new HttpError(res.status, html, text);
|
||||
if (!res.ok) throw new HttpError(res.status, errorMessage(res.status, res.text ?? ''), res.text);
|
||||
if (!text.trim()) return null as T;
|
||||
const parsed = tryParseJson<T>(text);
|
||||
if (parsed === null) throw new Error(`Réponse Hermes illisible depuis ${path}`);
|
||||
if (parsed === null) throw new Error(`Réponse Hermes illisible depuis ${path} : ${text.slice(0, 120).replace(/\s+/g, ' ')}`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
@@ -520,6 +591,8 @@ export class HermesClient {
|
||||
export function recoverCompletion(raw: string): { text?: string; error?: string } {
|
||||
const body = raw.trim();
|
||||
if (!body) return {};
|
||||
const html = describeHtml(body);
|
||||
if (html) return { error: html };
|
||||
const candidates = body.startsWith('data:') || body.startsWith('event:')
|
||||
? body.split(/\n+/).filter((l) => l.startsWith('data:')).map((l) => l.replace(/^data:\s*/, '')).filter((l) => l && l !== '[DONE]')
|
||||
: [body];
|
||||
|
||||
@@ -66,6 +66,21 @@ function getWorkletUrl(): string {
|
||||
return workletUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an AudioWorklet module: first the static file shipped with the app (allowed by the strict
|
||||
* CSP, script-src 'self'), then a blob URL for dev servers that do not serve /worklets.
|
||||
*/
|
||||
export async function loadWorklet(ctx: AudioContext, name: string, blobUrl: () => string): Promise<void> {
|
||||
const staticUrl = new URL(`worklets/${name}.js`, document.baseURI).href;
|
||||
try {
|
||||
await ctx.audioWorklet.addModule(staticUrl);
|
||||
return;
|
||||
} catch (err) {
|
||||
Log.debug('audio', `static worklet ${name} unavailable (${(err as Error).message}), trying blob`);
|
||||
}
|
||||
await ctx.audioWorklet.addModule(blobUrl());
|
||||
}
|
||||
|
||||
export async function listMicrophones(): Promise<MicDevice[]> {
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
@@ -147,7 +162,7 @@ export class MicCapture {
|
||||
audioBus.setInputAnalyser(analyser);
|
||||
|
||||
try {
|
||||
await this.ctx.audioWorklet.addModule(getWorkletUrl());
|
||||
await loadWorklet(this.ctx, 'eveflow-capture', getWorkletUrl);
|
||||
this.worklet = new AudioWorkletNode(this.ctx, 'eveflow-capture', { numberOfInputs: 1, numberOfOutputs: 0, channelCount: 1 });
|
||||
this.worklet.port.onmessage = (event: MessageEvent<Float32Array>) => this.onSamples(event.data);
|
||||
source.connect(this.worklet);
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface TtsConfig {
|
||||
localSpeaker: number;
|
||||
/** Preferred voice gender, applied to every provider's default voice. */
|
||||
voiceGender?: 'male' | 'female';
|
||||
/** 'jarvis' adds a subtle AI timbre: slightly lower pitch, warm/crisp EQ, short room reverb. */
|
||||
timbre?: 'natural' | 'jarvis';
|
||||
}
|
||||
|
||||
export type TtsState = 'idle' | 'loading' | 'speaking';
|
||||
@@ -255,7 +257,13 @@ export class TtsEngine {
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
if (this.config.provider === 'google-free') source.playbackRate.value = Math.max(0.5, Math.min(2, this.config.speed || 1));
|
||||
source.connect(audioBus.output);
|
||||
if (this.config.timbre === 'jarvis') {
|
||||
// Slightly lower and calmer, warm low end, crisp presence, a touch of room: the film's intercom feel.
|
||||
source.playbackRate.value *= 0.94;
|
||||
source.connect(jarvisChain(ctx));
|
||||
} else {
|
||||
source.connect(audioBus.output);
|
||||
}
|
||||
const finish = () => {
|
||||
if (this.cancelCurrent === cancel) this.cancelCurrent = null;
|
||||
resolve();
|
||||
@@ -334,3 +342,60 @@ export function listSystemVoices(): SpeechSynthesisVoice[] {
|
||||
if (typeof speechSynthesis === 'undefined') return [];
|
||||
return [...speechSynthesis.getVoices()].sort((a, b) => a.lang.localeCompare(b.lang) || scoreVoice(b) - scoreVoice(a));
|
||||
}
|
||||
|
||||
let jarvisInput: AudioNode | null = null;
|
||||
let jarvisCtx: AudioContext | null = null;
|
||||
|
||||
/** Shared effect chain for the JARVIS timbre (built once per AudioContext). */
|
||||
function jarvisChain(ctx: AudioContext): AudioNode {
|
||||
if (jarvisInput && jarvisCtx === ctx) return jarvisInput;
|
||||
const input = ctx.createGain();
|
||||
const warmth = ctx.createBiquadFilter();
|
||||
warmth.type = 'lowshelf';
|
||||
warmth.frequency.value = 180;
|
||||
warmth.gain.value = 3.5;
|
||||
const presence = ctx.createBiquadFilter();
|
||||
presence.type = 'peaking';
|
||||
presence.frequency.value = 3200;
|
||||
presence.Q.value = 0.9;
|
||||
presence.gain.value = 2.5;
|
||||
const air = ctx.createBiquadFilter();
|
||||
air.type = 'highshelf';
|
||||
air.frequency.value = 7000;
|
||||
air.gain.value = -2;
|
||||
const comp = ctx.createDynamicsCompressor();
|
||||
comp.threshold.value = -20;
|
||||
comp.ratio.value = 3;
|
||||
comp.attack.value = 0.005;
|
||||
comp.release.value = 0.12;
|
||||
const dry = ctx.createGain();
|
||||
dry.gain.value = 0.86;
|
||||
const wet = ctx.createGain();
|
||||
wet.gain.value = 0.14;
|
||||
const reverb = ctx.createConvolver();
|
||||
reverb.buffer = impulse(ctx, 0.22, 3.2);
|
||||
input.connect(warmth);
|
||||
warmth.connect(presence);
|
||||
presence.connect(air);
|
||||
air.connect(comp);
|
||||
comp.connect(dry);
|
||||
comp.connect(reverb);
|
||||
reverb.connect(wet);
|
||||
dry.connect(audioBus.output);
|
||||
wet.connect(audioBus.output);
|
||||
jarvisInput = input;
|
||||
jarvisCtx = ctx;
|
||||
return input;
|
||||
}
|
||||
|
||||
/** Short synthetic room impulse (exponentially decaying noise). */
|
||||
function impulse(ctx: AudioContext, seconds: number, decay: number): AudioBuffer {
|
||||
const rate = ctx.sampleRate;
|
||||
const length = Math.max(1, Math.floor(rate * seconds));
|
||||
const buffer = ctx.createBuffer(2, length, rate);
|
||||
for (let ch = 0; ch < 2; ch++) {
|
||||
const data = buffer.getChannelData(ch);
|
||||
for (let i = 0; i < length; i++) data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, decay);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
import { bridge } from '../../lib/bridge';
|
||||
import { Log } from '../../lib/log';
|
||||
import { audioBus } from './audioBus';
|
||||
import { loadWorklet } from './capture';
|
||||
import { DEFAULT_VAD, EnergyVad, type VadOptions } from './vad';
|
||||
import { buildWav16k, rms, type WavResult } from './wav';
|
||||
import type { VadEvent } from '../../../shared/voice';
|
||||
@@ -103,8 +104,10 @@ export class WakeListener {
|
||||
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);
|
||||
await loadWorklet(this.ctx, 'eveflow-wake', () => {
|
||||
if (!workletUrl) workletUrl = URL.createObjectURL(new Blob([WORKLET_SOURCE], { type: 'application/javascript' }));
|
||||
return 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);
|
||||
|
||||
+22
-1
@@ -2,7 +2,7 @@ import { create } from 'zustand';
|
||||
import type { WebhookStatus } from '../../shared/ipc';
|
||||
import { Log } from '../lib/log';
|
||||
import { persistGet, persistSet } from '../lib/persist';
|
||||
import { HermesClient, jobOutput, jobStatus, resolveTransport, type ResolvedTransport } from '../services/hermes/client';
|
||||
import { HermesClient, jobOutput, jobStatus, resolveTransport, type ResolvedTransport, discoverHermesUrl } from '../services/hermes/client';
|
||||
import type {
|
||||
HermesCapabilities,
|
||||
HermesHealth,
|
||||
@@ -40,6 +40,8 @@ interface HermesStore {
|
||||
jobRuns: JobRun[];
|
||||
/** Last cron sync failure (the chat link stays independent of it). */
|
||||
jobsError: string | null;
|
||||
/** True while probing alternative API URLs after a failed connection. */
|
||||
discovering: boolean;
|
||||
transport: ResolvedTransport;
|
||||
lastSyncAt: number | null;
|
||||
webhook: WebhookStatus | null;
|
||||
@@ -91,6 +93,7 @@ export const useHermes = create<HermesStore>((set, get) => ({
|
||||
jobs: [],
|
||||
jobRuns: [],
|
||||
jobsError: null,
|
||||
discovering: false,
|
||||
transport: 'completions',
|
||||
lastSyncAt: null,
|
||||
webhook: null,
|
||||
@@ -119,6 +122,8 @@ export const useHermes = create<HermesStore>((set, get) => ({
|
||||
try {
|
||||
capabilities = await client.capabilities();
|
||||
} catch (err) {
|
||||
// A web page on /v1/* means the whole API is behind a portal even if /health passed through.
|
||||
if (/page web/.test((err as Error).message)) throw err;
|
||||
Log.warn('hermes', `capabilities unavailable: ${(err as Error).message}`);
|
||||
}
|
||||
const transport = resolveTransport(config, capabilities);
|
||||
@@ -130,6 +135,22 @@ export const useHermes = create<HermesStore>((set, get) => ({
|
||||
void get().refreshSessions();
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
// The URL answers with a web page (portal, dashboard) or nothing: look for the API on the same host.
|
||||
if (!get().discovering && /page web|illisible|fetch failed|ECONNREFUSED|404/i.test(message)) {
|
||||
set({ discovering: true, linkDetail: 'recherche de l’API Hermes…' });
|
||||
try {
|
||||
const found = await discoverHermesUrl(config);
|
||||
if (found) {
|
||||
Log.info('hermes', `API found at ${found} (was ${config.url})`);
|
||||
useSettings.getState().update({ hermes: { url: found } });
|
||||
set({ discovering: false, linkDetail: `URL corrigée automatiquement : ${found}` });
|
||||
await get().connect();
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
set({ discovering: false });
|
||||
}
|
||||
}
|
||||
set({ link: 'offline', linkDetail: message, transport: resolveTransport(config, null) });
|
||||
Log.warn('hermes', `connection failed: ${message}`);
|
||||
}
|
||||
|
||||
@@ -126,6 +126,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
localModel: 'kokoro-v1',
|
||||
localSpeaker: 30,
|
||||
voiceGender: 'male',
|
||||
timbre: 'jarvis',
|
||||
autoSpeak: true,
|
||||
speakIncoming: true
|
||||
},
|
||||
|
||||
@@ -115,3 +115,35 @@
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* Segmented choice (voice gender, capture mode…) */
|
||||
.segmented {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.segmented button {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ink-1);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.segmented button + button {
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
.segmented button:hover {
|
||||
color: var(--ink-0);
|
||||
background: rgba(var(--accent-rgb), 0.08);
|
||||
}
|
||||
.segmented button.active {
|
||||
color: #03101c;
|
||||
background: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describeHtml, hermesUrlCandidates, recoverCompletion } from '../src/services/hermes/client';
|
||||
|
||||
describe('describeHtml', () => {
|
||||
it('explains a login page instead of the API', () => {
|
||||
const msg = describeHtml('<!doctype html><html lang="fr-FR"><head><title>Jarvis – Se connecter</title></head><body>Mot de passe</body></html>');
|
||||
expect(msg).toContain('Jarvis – Se connecter');
|
||||
expect(msg).toContain('page de connexion');
|
||||
expect(msg).toContain('8642');
|
||||
});
|
||||
it('ignores JSON and SSE', () => {
|
||||
expect(describeHtml('{"status":"ok"}')).toBeNull();
|
||||
expect(describeHtml('data: {"choices":[]}')).toBeNull();
|
||||
});
|
||||
it('is used by recoverCompletion', () => {
|
||||
expect(recoverCompletion('<html><head><title>Portal</title></head></html>').error).toContain('Portal');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hermesUrlCandidates', () => {
|
||||
it('tries the API port, common paths and sibling hosts', () => {
|
||||
const c = hermesUrlCandidates('http://jarvis.vonrodbox.eu');
|
||||
expect(c).toContain('http://jarvis.vonrodbox.eu:8642');
|
||||
expect(c).toContain('http://jarvis.vonrodbox.eu/api');
|
||||
expect(c).toContain('http://api.jarvis.vonrodbox.eu');
|
||||
expect(c).toContain('http://hermes.vonrodbox.eu');
|
||||
expect(c).not.toContain('http://jarvis.vonrodbox.eu');
|
||||
});
|
||||
it('keeps an explicit port and handles garbage', () => {
|
||||
expect(hermesUrlCandidates('http://10.0.0.5:8642').some((u) => u.includes(':8642:'))).toBe(false);
|
||||
expect(hermesUrlCandidates('')).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user