mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-11 17:29:03 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df25dd591c | ||
|
|
c75c1d674c | ||
|
|
6d3073b083 | ||
|
|
31659500ab |
No files matched your search
@@ -30,6 +30,7 @@ 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 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.
|
||||
* **STT externe** : n'importe quelle API `/v1/audio/transcriptions` compatible OpenAI (Qwen3-ASR, Whisper, Speaches, faster-whisper-server, LocalAI, OpenAI). Repli sur la reconnaissance Chromium.
|
||||
@@ -111,6 +112,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,6 +77,8 @@ 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 |
|
||||
| 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 |
|
||||
| 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.
|
||||
@@ -164,3 +164,12 @@ export const VOICE_CATALOG: VoiceModelSpec[] = [
|
||||
export function findModel(id: string): VoiceModelSpec | undefined {
|
||||
return VOICE_CATALOG.find((m) => m.id === id);
|
||||
}
|
||||
|
||||
// Speaker gender from the label ("(homme, …)", "(femme, …)", known first names) so the UI and the
|
||||
// voice preference can pick a masculine or feminine voice without a lookup table per model.
|
||||
const MALE = /\b(homme|tom|pierre|adam|michael|eric|liam|george|lewis|daniel|fenrir|puck|onyx|echo|santa)\b/i;
|
||||
for (const spec of VOICE_CATALOG) {
|
||||
for (const sp of spec.speakers ?? []) {
|
||||
if (!sp.gender) sp.gender = /\b(femme|female)\b/i.test(sp.name) ? 'f' : MALE.test(sp.name) ? 'm' : /\bfemme\b/i.test(spec.name) ? 'f' : /\bhomme\b/i.test(spec.name) ? 'm' : undefined;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "eveflow",
|
||||
"version": "2.4.0",
|
||||
"releaseVersion": "2.4.0",
|
||||
"releaseVersion": "2.4.0.2",
|
||||
"description": "JARVIS-style desktop HUD for Hermes Agent: voice, streaming runs, scheduled jobs, skills and telemetry",
|
||||
"main": "dist-electron/main.js",
|
||||
"private": true,
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface VoiceSpeaker {
|
||||
id: number;
|
||||
name: string;
|
||||
lang: string;
|
||||
/** m = masculine, f = feminine (from the catalog label). */
|
||||
gender?: 'm' | 'f';
|
||||
}
|
||||
|
||||
export interface VoiceModelSpec {
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ import { bridge } from './lib/bridge';
|
||||
import { Log } from './lib/log';
|
||||
import { handlePush, localToolContext, stopGeneration } from './services/conversation';
|
||||
import { initMcpBridge } from './services/mcpBridge';
|
||||
import { ensurePreferredVoice } from './services/voice/voicePreference';
|
||||
import { isQuietTime } from './lib/quietHours';
|
||||
import { speech } from './services/voice/speech';
|
||||
import { voiceController } from './services/voice/voiceController';
|
||||
@@ -45,7 +46,7 @@ function useBoot(): boolean {
|
||||
}
|
||||
if (api) {
|
||||
disposers.push(useVoiceModels.getState().subscribe());
|
||||
void useVoiceModels.getState().refresh();
|
||||
void useVoiceModels.getState().refresh().then(() => ensurePreferredVoice());
|
||||
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(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Fragment, useEffect, useState } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Wrench, CalendarClock, Sparkles, Layers, History, Play, Pause, Trash2, Pencil, Plus, Save, X, RefreshCw,
|
||||
CheckCircle2, XCircle, Loader2, Bot, Cpu, GitBranch, Webhook, FolderOpen
|
||||
} from 'lucide-react';
|
||||
@@ -41,6 +42,7 @@ function ActivityTab() {
|
||||
|
||||
function JobsTab() {
|
||||
const jobs = useHermes((s) => s.jobs);
|
||||
const jobsError = useHermes((s) => s.jobsError);
|
||||
const busy = useHermes((s) => s.busy);
|
||||
const lastSyncAt = useHermes((s) => s.lastSyncAt);
|
||||
const refreshJobs = useHermes((s) => s.refreshJobs);
|
||||
@@ -95,8 +97,9 @@ function JobsTab() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{jobsError && <div className="test-result fail" style={{ marginBottom: 8 }}><AlertTriangle size={13} /> Crons indisponibles : {jobsError}</div>}
|
||||
{jobs.length === 0 ? (
|
||||
<div className="empty">Aucun cron Hermes. Créez une mission planifiée en langage naturel.</div>
|
||||
<div className="empty">{jobsError ? 'La liaison Hermes reste utilisable ; seule la planification est indisponible.' : 'Aucun cron Hermes. Créez une mission planifiée en langage naturel.'}</div>
|
||||
) : (
|
||||
<div className="list">
|
||||
{jobs.map((job) => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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';
|
||||
import { listMicrophones } from '../../services/voice/capture';
|
||||
import { transcribeWav } from '../../services/voice/stt';
|
||||
import { encodeWav } from '../../services/voice/wav';
|
||||
@@ -115,7 +116,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(', ')}…` });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -357,6 +367,14 @@ export function SettingsDrawer({ onClose }: Props) {
|
||||
|
||||
{section === 'speech' && (
|
||||
<div className="card">
|
||||
<div className="field">
|
||||
<label>Voix</label>
|
||||
<div className="segmented">
|
||||
<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>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Moteur de synthèse</label>
|
||||
<select className="select" value={settings.speech.provider} onChange={(e) => update({ speech: { provider: e.target.value as typeof settings.speech.provider } })}>
|
||||
|
||||
@@ -154,3 +154,27 @@ export function formatDuration(seconds: number): string {
|
||||
if (h > 0) return `${h}h ${m.toString().padStart(2, '0')}m`;
|
||||
return `${m}m ${(s % 60).toString().padStart(2, '0')}s`;
|
||||
}
|
||||
|
||||
const NOISE_PHRASES = [
|
||||
'sous-titres réalisés par la communauté d\'amara.org',
|
||||
'sous-titrage société radio-canada',
|
||||
'merci d\'avoir regardé',
|
||||
'abonnez-vous',
|
||||
'thank you for watching',
|
||||
'thanks for watching',
|
||||
'...'
|
||||
];
|
||||
|
||||
/**
|
||||
* Whisper-style hallucinations on silence or clicks: "(cliquant)", "*Claire*", "[Musique]",
|
||||
* "Sous-titres réalisés par…". Such transcripts must not be sent to Hermes.
|
||||
*/
|
||||
export function isTranscriptNoise(text: string): boolean {
|
||||
const t = text.trim();
|
||||
if (!t) return true;
|
||||
if (/^[\s\p{P}\p{S}]*$/u.test(t)) return true;
|
||||
// whole transcript wrapped in brackets/asterisks: a sound description
|
||||
if (/^[(\[*«"'\s]+[^()\[\]*]{0,60}[)\]*»"'\s]+$/u.test(t) && !/[a-zà-ÿ]{3,}\s+[a-zà-ÿ]{3,}\s+[a-zà-ÿ]{3,}/i.test(t)) return true;
|
||||
const lower = t.toLowerCase().replace(/[.!?…\s]+$/u, '');
|
||||
return NOISE_PHRASES.some((p) => lower === p.replace(/[.!?…\s]+$/u, ''));
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/** Voice gender preference applied to every TTS provider (pure helpers, unit tested). */
|
||||
import type { VoiceModelStatus } from '../../shared/voice';
|
||||
|
||||
export type VoiceGender = 'male' | 'female';
|
||||
|
||||
const MALE_HINTS = /\b(homme|male|masculin|paul|thomas|claude|henri|guillaume|mathieu|antoine|nicolas|denis|pierre|tom|adam|michael|eric|liam|george|lewis|daniel|fenrir|puck|onyx|echo|david|mark|richard|james|ryan|guy)\b/i;
|
||||
const FEMALE_HINTS = /\b(femme|female|f[ée]minin|hortense|julie|denise|eloise|am[ée]lie|audrey|siwis|jessica|heart|bella|sarah|nicole|sky|alloy|nova|shimmer|zira|aria|jenny|emma|isabella|sophie|charlotte|vivienne|coral|sage)\b/i;
|
||||
|
||||
/** Best-effort gender from a voice or speaker label ("Piper Tom (homme, français)", "Microsoft Paul", "am_adam"). */
|
||||
export function inferGender(name: string): VoiceGender | undefined {
|
||||
const n = name.replace(/_/g, ' ');
|
||||
if (/^(am|bm|em|hm|im|jm|pm|zm)\b/i.test(n) || MALE_HINTS.test(n)) return 'male';
|
||||
if (/^(af|bf|ef|ff|hf|if|jf|pf|zf)\b/i.test(n) || FEMALE_HINTS.test(n)) return 'female';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** OpenAI-compatible default voice for the preferred gender (used when the user left the field empty). */
|
||||
export function defaultOpenAiVoice(gender: VoiceGender): string {
|
||||
return gender === 'male' ? 'onyx' : 'nova';
|
||||
}
|
||||
|
||||
/** Rank system voices: language first, then gender, then quality hints. */
|
||||
export function rankSystemVoice(v: { name: string; lang: string; localService?: boolean }, lang: string, gender: VoiceGender): number {
|
||||
const name = v.name.toLowerCase();
|
||||
let score = v.lang.toLowerCase().startsWith(lang) ? 100 : 0;
|
||||
const g = inferGender(v.name);
|
||||
if (g === gender) score += 50;
|
||||
else if (g && g !== gender) score -= 30;
|
||||
if (name.includes('natural') || name.includes('neural') || name.includes('online')) score += 20;
|
||||
if (name.includes('google')) score += 10;
|
||||
if (name.includes('microsoft')) score += 5;
|
||||
if (v.localService) score += 2;
|
||||
return score;
|
||||
}
|
||||
|
||||
export interface LocalVoiceChoice {
|
||||
modelId: string;
|
||||
speaker: number;
|
||||
}
|
||||
|
||||
/** Installed local voice matching language + gender, or null. */
|
||||
export function findLocalVoice(models: VoiceModelStatus[], lang: string, gender: VoiceGender): LocalVoiceChoice | null {
|
||||
const l = lang.toLowerCase().split('-')[0];
|
||||
const installed = models.filter((m) => m.kind === 'tts' && m.installed);
|
||||
for (const model of installed) {
|
||||
for (const sp of model.speakers ?? []) {
|
||||
const spLang = (sp.lang || '').toLowerCase();
|
||||
if (spLang && spLang !== l && spLang !== 'multi') continue;
|
||||
const g = (sp as { gender?: string }).gender === 'm' ? 'male' : (sp as { gender?: string }).gender === 'f' ? 'female' : inferGender(sp.name);
|
||||
if (g === gender) return { modelId: model.id, speaker: sp.id };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Catalog model to download when nothing installed offers the preferred gender (French). */
|
||||
export function suggestedDownload(models: VoiceModelStatus[], lang: string, gender: VoiceGender): string | null {
|
||||
const l = lang.toLowerCase().split('-')[0];
|
||||
if (l !== 'fr') return null;
|
||||
const wanted = gender === 'male' ? ['piper-fr-tom', 'piper-fr-upmc'] : ['kokoro-v1', 'piper-fr-siwis'];
|
||||
for (const id of wanted) {
|
||||
const m = models.find((x) => x.id === id);
|
||||
if (m && !m.installed) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -31,6 +31,63 @@ 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];
|
||||
}
|
||||
|
||||
/** Probe candidate URLs until one answers Hermes JSON on /health or /v1/capabilities. */
|
||||
export async function discoverHermesUrl(config: HermesConfig, onProgress?: (url: string) => void): Promise<string | null> {
|
||||
for (const candidate of hermesUrlCandidates(config.url)) {
|
||||
onProgress?.(candidate);
|
||||
const client = new HermesClient({ ...config, url: candidate });
|
||||
try {
|
||||
await client.request<unknown>('/health', { timeoutMs: 4000 });
|
||||
return candidate;
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError && (err.status === 401 || err.status === 403)) return candidate; // API found, key missing
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hermesBaseUrl(url: string): string {
|
||||
let base = url.trim().replace(/\/+$/, '');
|
||||
base = base.replace(/\/chat\/completions$/, '');
|
||||
@@ -93,7 +150,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 +160,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;
|
||||
}
|
||||
|
||||
@@ -430,6 +489,7 @@ export class HermesClient {
|
||||
let iterationText = '';
|
||||
const errorChunks: string[] = [];
|
||||
let ready = false;
|
||||
let rawBody = '';
|
||||
const parser = new SseParser((message) => {
|
||||
if (message.data === '[DONE]') return;
|
||||
const data = tryParseJson<unknown>(message.data);
|
||||
@@ -451,7 +511,13 @@ export class HermesClient {
|
||||
if (sessionId) headers['X-Hermes-Session-Id'] = sessionId;
|
||||
const handle = await httpStream(
|
||||
{ url: `${this.base}/v1/chat/completions`, method: 'POST', headers, body: JSON.stringify(payload), timeoutMs: 10 * 60_000 },
|
||||
{ onChunk: (text) => (ready ? parser.feed(text) : errorChunks.push(text)) }
|
||||
{
|
||||
onChunk: (text) => {
|
||||
if (rawBody.length < 512_000) rawBody += text;
|
||||
if (ready) parser.feed(text);
|
||||
else errorChunks.push(text);
|
||||
}
|
||||
}
|
||||
);
|
||||
currentHandle = handle;
|
||||
if (!handle.start.ok) {
|
||||
@@ -474,6 +540,17 @@ export class HermesClient {
|
||||
|
||||
await handle.done;
|
||||
parser.end();
|
||||
if (!iterationText && toolCalls.size === 0 && !aborted()) {
|
||||
// Nothing streamed: the server may have answered with a plain JSON completion (stream ignored)
|
||||
// or with a 200 carrying an error object. Surface it instead of an empty bubble.
|
||||
const recovered = recoverCompletion(rawBody);
|
||||
if (recovered.text) {
|
||||
iterationText = recovered.text;
|
||||
onEvent({ kind: 'delta', text: recovered.text });
|
||||
} else {
|
||||
throw new Error(recovered.error ?? `Réponse vide de Hermes (${rawBody.length} octets reçus${rawBody ? ` : ${rawBody.slice(0, 160).replace(/\s+/g, ' ')}` : ''})`);
|
||||
}
|
||||
}
|
||||
fullText += iterationText;
|
||||
|
||||
if (finishReason === 'tool_calls' && toolsAllowed && toolCalls.size > 0 && !aborted()) {
|
||||
@@ -498,6 +575,32 @@ export class HermesClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract text or an error message from a non-streamed chat completion body. */
|
||||
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];
|
||||
let text = '';
|
||||
for (const c of candidates) {
|
||||
const data = tryParseJson<Rec>(c);
|
||||
if (!data || !isRec(data)) continue;
|
||||
if (data.error) {
|
||||
const e = data.error as Rec | string;
|
||||
return { error: `Hermes : ${typeof e === 'string' ? e : String((e as Rec).message ?? JSON.stringify(e))}` };
|
||||
}
|
||||
const choice = Array.isArray(data.choices) && isRec(data.choices[0]) ? (data.choices[0] as Rec) : null;
|
||||
const msg = choice && isRec(choice.message) ? (choice.message as Rec) : choice && isRec(choice.delta) ? (choice.delta as Rec) : null;
|
||||
if (msg && typeof msg.content === 'string') text += msg.content;
|
||||
else if (typeof data.output === 'string') text += data.output;
|
||||
else if (typeof data.text === 'string') text += data.text;
|
||||
}
|
||||
return text ? { text } : {};
|
||||
}
|
||||
|
||||
/** Session ids created by the sessions transport carry an `hs:` prefix; other endpoints get the bare id. */
|
||||
export function plainSession(id: string): string {
|
||||
return id.startsWith('hs:') ? id.slice(3) : id;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* OpenAI-compatible /v1/audio/speech, system voices, and the legacy Google Translate endpoint.
|
||||
*/
|
||||
import { Log } from '../../lib/log';
|
||||
import { defaultOpenAiVoice, rankSystemVoice } from '../../lib/voicePreference';
|
||||
import { bridge } from '../../lib/bridge';
|
||||
import { chunkForSpeech, cleanForSpeech, extractSentences } from '../../lib/text';
|
||||
import { httpFetch } from '../../lib/transport';
|
||||
@@ -26,6 +27,8 @@ export interface TtsConfig {
|
||||
localModel: string;
|
||||
/** Speaker id inside the local model. */
|
||||
localSpeaker: number;
|
||||
/** Preferred voice gender, applied to every provider's default voice. */
|
||||
voiceGender?: 'male' | 'female';
|
||||
}
|
||||
|
||||
export type TtsState = 'idle' | 'loading' | 'speaking';
|
||||
@@ -201,7 +204,7 @@ export class TtsEngine {
|
||||
body: JSON.stringify({
|
||||
model: model || 'tts-1',
|
||||
input: text,
|
||||
voice: voice || 'alloy',
|
||||
voice: voice || defaultOpenAiVoice(this.config.voiceGender ?? 'male'),
|
||||
speed: Math.max(0.5, Math.min(2, speed || 1)),
|
||||
response_format: format || 'mp3'
|
||||
}),
|
||||
@@ -312,8 +315,8 @@ export class TtsEngine {
|
||||
if (exact) return exact;
|
||||
}
|
||||
const lang = (this.config.language || 'fr').toLowerCase().split('-')[0];
|
||||
const candidates = voices.filter((v) => v.lang.toLowerCase().startsWith(lang));
|
||||
return candidates.sort((a, b) => scoreVoice(b) - scoreVoice(a))[0] ?? voices[0];
|
||||
const gender = this.config.voiceGender ?? 'male';
|
||||
return [...voices].sort((a, b) => rankSystemVoice(b, lang, gender) - rankSystemVoice(a, lang, gender))[0];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Voice controller: microphone → VAD → STT → conversation, plus hands-free loop and barge-in.
|
||||
*/
|
||||
import { Log } from '../../lib/log';
|
||||
import { isTranscriptNoise } from '../../lib/text';
|
||||
import { useChat } from '../../state/chat';
|
||||
import { useSettings } from '../../state/settings';
|
||||
import { useVoice } from '../../state/voice';
|
||||
@@ -344,6 +345,10 @@ class VoiceController {
|
||||
const settings = useSettings.getState().settings.voice;
|
||||
try {
|
||||
let text = await transcribeWav(wav.bytes, settings);
|
||||
if (isTranscriptNoise(text)) {
|
||||
Log.debug('voice', `transcript ignored as noise: ${text}`);
|
||||
text = '';
|
||||
}
|
||||
voice.setTranscript(text);
|
||||
voice.setPhase('off');
|
||||
Log.info('voice', `transcript (${wav.durationSec.toFixed(1)}s): ${text}`);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Applies the preferred voice gender to the active TTS provider: switches the local model/speaker,
|
||||
* downloads a matching French voice when none is installed, and logs what it did.
|
||||
*/
|
||||
import { Log } from '../../lib/log';
|
||||
import { findLocalVoice, inferGender, suggestedDownload } from '../../lib/voicePreference';
|
||||
import { useSettings } from '../../state/settings';
|
||||
import { useVoiceModels } from '../../state/voiceModels';
|
||||
|
||||
let downloading: string | null = null;
|
||||
|
||||
/** Make the local voice match `speech.voiceGender`. Returns a short status message. */
|
||||
export async function ensurePreferredVoice(): Promise<string> {
|
||||
const { settings, update } = useSettings.getState();
|
||||
const { speech } = settings;
|
||||
const gender = speech.voiceGender ?? 'male';
|
||||
if (speech.provider !== 'local') return '';
|
||||
const models = useVoiceModels.getState().models;
|
||||
if (!models.length) return '';
|
||||
const lang = speech.language || 'fr-FR';
|
||||
const current = models.find((m) => m.id === speech.localModel);
|
||||
const currentSpeaker = current?.speakers?.find((s) => s.id === speech.localSpeaker);
|
||||
const currentGender = currentSpeaker ? (currentSpeaker as { gender?: string }).gender === 'm' ? 'male' : (currentSpeaker as { gender?: string }).gender === 'f' ? 'female' : inferGender(currentSpeaker.name) : undefined;
|
||||
if (current?.installed && currentGender === gender) return '';
|
||||
|
||||
const choice = findLocalVoice(models, lang, gender);
|
||||
if (choice) {
|
||||
update({ speech: { localModel: choice.modelId, localSpeaker: choice.speaker } });
|
||||
const name = models.find((m) => m.id === choice.modelId)?.speakers?.find((s) => s.id === choice.speaker)?.name ?? choice.modelId;
|
||||
Log.info('tts', `voice preference ${gender}: ${name}`);
|
||||
return `Voix ${gender === 'male' ? 'masculine' : 'féminine'} : ${name}`;
|
||||
}
|
||||
const download = suggestedDownload(models, lang, gender);
|
||||
if (!download || downloading === download) return download ? 'Téléchargement de la voix en cours…' : '';
|
||||
downloading = download;
|
||||
Log.info('tts', `no ${gender} voice installed, downloading ${download}`);
|
||||
try {
|
||||
await useVoiceModels.getState().download(download);
|
||||
const after = findLocalVoice(useVoiceModels.getState().models, lang, gender);
|
||||
if (after) update({ speech: { localModel: after.modelId, localSpeaker: after.speaker } });
|
||||
return after ? `Voix ${gender === 'male' ? 'masculine' : 'féminine'} installée.` : 'Voix téléchargée.';
|
||||
} catch (err) {
|
||||
Log.warn('tts', `voice download failed: ${(err as Error).message}`);
|
||||
return `Téléchargement impossible : ${(err as Error).message}`;
|
||||
} finally {
|
||||
downloading = null;
|
||||
}
|
||||
}
|
||||
+56
-7
@@ -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,
|
||||
@@ -38,6 +38,10 @@ interface HermesStore {
|
||||
sessions: HermesSession[];
|
||||
jobs: HermesJob[];
|
||||
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;
|
||||
@@ -88,6 +92,8 @@ export const useHermes = create<HermesStore>((set, get) => ({
|
||||
sessions: [],
|
||||
jobs: [],
|
||||
jobRuns: [],
|
||||
jobsError: null,
|
||||
discovering: false,
|
||||
transport: 'completions',
|
||||
lastSyncAt: null,
|
||||
webhook: null,
|
||||
@@ -119,14 +125,30 @@ export const useHermes = create<HermesStore>((set, get) => ({
|
||||
Log.warn('hermes', `capabilities unavailable: ${(err as Error).message}`);
|
||||
}
|
||||
const transport = resolveTransport(config, capabilities);
|
||||
const degraded = String(health.status ?? 'ok').toLowerCase() !== 'ok';
|
||||
set({ health, capabilities, transport, link: degraded ? 'degraded' : 'online', linkDetail: degraded ? `état ${health.status}` : '' });
|
||||
const degraded = !isHealthyStatus(health.status);
|
||||
set({ health, capabilities, transport, link: degraded ? 'degraded' : 'online', linkDetail: degraded ? describeHealth(health) : '' });
|
||||
Log.info('hermes', `connected (${transport})`, { status: health.status, model: capabilities?.model });
|
||||
void get().refreshCatalog();
|
||||
void get().refreshJobs();
|
||||
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}`);
|
||||
}
|
||||
@@ -165,7 +187,7 @@ export const useHermes = create<HermesStore>((set, get) => ({
|
||||
const merged = [...incoming, ...previous.filter((r) => !incoming.some((i) => i.id === r.id))]
|
||||
.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime())
|
||||
.slice(0, 100);
|
||||
set({ jobs, jobRuns: merged, lastSyncAt: Date.now(), link: get().link === 'offline' || get().link === 'degraded' ? 'online' : get().link });
|
||||
set({ jobs, jobRuns: merged, jobsError: null, lastSyncAt: Date.now(), link: get().link === 'offline' ? 'online' : get().link });
|
||||
const snapshot = JSON.stringify({ jobs, jobRuns: merged });
|
||||
if (snapshot !== lastCacheSnapshot) {
|
||||
lastCacheSnapshot = snapshot;
|
||||
@@ -174,9 +196,8 @@ export const useHermes = create<HermesStore>((set, get) => ({
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
Log.warn('hermes', `jobs sync failed: ${message}`);
|
||||
if (/HTTP 404/.test(message)) return; // jobs API disabled on this server
|
||||
// A failing jobs poll does not mean chat is down: degrade, and let the next health probe decide.
|
||||
set({ linkDetail: message, link: get().link === 'online' ? 'degraded' : get().link });
|
||||
// The cron API can be absent or restricted on a given Hermes: the chat link is unaffected.
|
||||
set({ jobsError: /HTTP 404/.test(message) ? 'API des crons absente sur ce serveur Hermes' : message });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -233,3 +254,31 @@ export const useHermes = create<HermesStore>((set, get) => ({
|
||||
}));
|
||||
|
||||
export { jobStatus };
|
||||
|
||||
const HEALTHY = new Set(['ok', 'healthy', 'up', 'alive', 'pass', 'ready', 'running', 'online', 'true']);
|
||||
|
||||
/** Hermes /health reports "ok"; /health/detailed may report "healthy", "degraded" or "unhealthy". */
|
||||
export function isHealthyStatus(status: unknown): boolean {
|
||||
if (status === undefined || status === null || status === '') return true;
|
||||
return HEALTHY.has(String(status).toLowerCase());
|
||||
}
|
||||
|
||||
/** Human summary of a degraded health payload: failing checks by name. */
|
||||
export function describeHealth(health: Record<string, unknown>): string {
|
||||
const failing: string[] = [];
|
||||
const visit = (node: unknown, prefix: string, depth: number) => {
|
||||
if (!node || typeof node !== 'object' || depth > 3) return;
|
||||
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
|
||||
if (key === 'status') continue;
|
||||
if (value && typeof value === 'object') {
|
||||
const rec = value as Record<string, unknown>;
|
||||
const st = rec.status ?? rec.ok ?? rec.healthy;
|
||||
if (st !== undefined && !isHealthyStatus(st)) failing.push(prefix + key);
|
||||
else visit(value, `${prefix}${key}.`, depth + 1);
|
||||
} else if (typeof value === 'boolean' && !value && /ok|healthy|ready|connected|available/i.test(key)) failing.push(prefix + key);
|
||||
}
|
||||
};
|
||||
visit(health, '', 0);
|
||||
const base = `état ${String(health.status)}`;
|
||||
return failing.length ? `${base} · ${failing.slice(0, 4).join(', ')}` : base;
|
||||
}
|
||||
@@ -125,6 +125,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
volume: 1,
|
||||
localModel: 'kokoro-v1',
|
||||
localSpeaker: 30,
|
||||
voiceGender: 'male',
|
||||
autoSpeak: true,
|
||||
speakIncoming: true
|
||||
},
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { recoverCompletion } from '../src/services/hermes/client';
|
||||
import { describeHealth, isHealthyStatus } from '../src/state/hermes';
|
||||
|
||||
describe('health status', () => {
|
||||
it('accepts the usual healthy words', () => {
|
||||
for (const s of ['ok', 'OK', 'healthy', 'ready', undefined]) expect(isHealthyStatus(s)).toBe(true);
|
||||
for (const s of ['degraded', 'unhealthy', 'error']) expect(isHealthyStatus(s)).toBe(false);
|
||||
});
|
||||
it('names the failing checks', () => {
|
||||
expect(describeHealth({ status: 'degraded', checks: { memory: { status: 'ok' }, sessions_db: { status: 'error' } } })).toBe('état degraded · checks.sessions_db');
|
||||
expect(describeHealth({ status: 'degraded' })).toBe('état degraded');
|
||||
});
|
||||
});
|
||||
|
||||
describe('recoverCompletion', () => {
|
||||
it('reads a plain JSON completion when the server ignored streaming', () => {
|
||||
expect(recoverCompletion(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'Bonjour.' } }] }))).toEqual({ text: 'Bonjour.' });
|
||||
});
|
||||
it('surfaces an error object returned with HTTP 200', () => {
|
||||
expect(recoverCompletion(JSON.stringify({ error: { message: 'model not found' } })).error).toContain('model not found');
|
||||
});
|
||||
it('handles SSE bodies and empty input', () => {
|
||||
expect(recoverCompletion('data: {"choices":[{"delta":{"content":"A"}}]}\n\ndata: {"choices":[{"delta":{"content":"B"}}]}\n\ndata: [DONE]\n\n')).toEqual({ text: 'AB' });
|
||||
expect(recoverCompletion('')).toEqual({});
|
||||
});
|
||||
});
|
||||
+16
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { chunkForSpeech, cleanForSpeech, extractSentences, preprocessMedia } from '../src/lib/text';
|
||||
import { chunkForSpeech, cleanForSpeech, extractSentences, isTranscriptNoise, preprocessMedia } from '../src/lib/text';
|
||||
|
||||
describe('text', () => {
|
||||
it('cleans markdown for speech', () => {
|
||||
@@ -25,3 +25,18 @@ describe('text', () => {
|
||||
expect(preprocessMedia('MEDIA:/tmp/a.png')).toContain('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTranscriptNoise', () => {
|
||||
it('drops Whisper hallucinations on silence', () => {
|
||||
expect(isTranscriptNoise('(cliquant)')).toBe(true);
|
||||
expect(isTranscriptNoise('*Claire*')).toBe(true);
|
||||
expect(isTranscriptNoise('[Musique]')).toBe(true);
|
||||
expect(isTranscriptNoise('...')).toBe(true);
|
||||
expect(isTranscriptNoise("Sous-titres réalisés par la communauté d'Amara.org")).toBe(true);
|
||||
});
|
||||
it('keeps real sentences', () => {
|
||||
expect(isTranscriptNoise('Jarvis, allume la lumière du salon.')).toBe(false);
|
||||
expect(isTranscriptNoise('(Jarvis) quelle heure est-il maintenant ?')).toBe(false);
|
||||
expect(isTranscriptNoise('Oui')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { defaultOpenAiVoice, findLocalVoice, inferGender, rankSystemVoice, suggestedDownload } from '../src/lib/voicePreference';
|
||||
import type { VoiceModelStatus } from '../shared/voice';
|
||||
|
||||
const model = (id: string, installed: boolean, speakers: Array<{ id: number; name: string; lang: string }>): VoiceModelStatus =>
|
||||
({ id, kind: 'tts', engine: 'piper', name: id, description: '', languages: ['fr'], sizeMb: 1, url: '', dir: id, files: [], speakers, installed, installedBytes: 0 }) as unknown as VoiceModelStatus;
|
||||
|
||||
describe('inferGender', () => {
|
||||
it('reads catalog labels, system voices and kokoro ids', () => {
|
||||
expect(inferGender('Piper Tom (homme, français)')).toBe('male');
|
||||
expect(inferGender('Siwis (femme, français)')).toBe('female');
|
||||
expect(inferGender('Microsoft Paul - French (France)')).toBe('male');
|
||||
expect(inferGender('Microsoft Hortense - French (France)')).toBe('female');
|
||||
expect(inferGender('am_adam')).toBe('male');
|
||||
expect(inferGender('Voix 3')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('local voice selection', () => {
|
||||
const models = [
|
||||
model('kokoro-v1', true, [{ id: 30, name: 'Siwis (femme, français)', lang: 'fr' }, { id: 4, name: 'Adam (homme, anglais US)', lang: 'en' }]),
|
||||
model('piper-fr-tom', false, [{ id: 0, name: 'Tom', lang: 'fr' }]),
|
||||
model('piper-fr-upmc', true, [{ id: 0, name: 'Jessica (femme)', lang: 'fr' }, { id: 1, name: 'Pierre (homme)', lang: 'fr' }])
|
||||
];
|
||||
it('prefers an installed speaker of the wanted gender in the right language', () => {
|
||||
expect(findLocalVoice(models, 'fr-FR', 'male')).toEqual({ modelId: 'piper-fr-upmc', speaker: 1 });
|
||||
expect(findLocalVoice(models, 'fr-FR', 'female')).toEqual({ modelId: 'kokoro-v1', speaker: 30 });
|
||||
expect(findLocalVoice(models.slice(0, 2), 'fr', 'male')).toBeNull();
|
||||
});
|
||||
it('suggests a French download when nothing matches', () => {
|
||||
expect(suggestedDownload(models, 'fr', 'male')).toBe('piper-fr-tom');
|
||||
expect(suggestedDownload(models, 'en', 'male')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider defaults', () => {
|
||||
it('picks onyx for male and nova for female on OpenAI-compatible APIs', () => {
|
||||
expect(defaultOpenAiVoice('male')).toBe('onyx');
|
||||
expect(defaultOpenAiVoice('female')).toBe('nova');
|
||||
});
|
||||
it('ranks system voices by language then gender', () => {
|
||||
const paul = { name: 'Microsoft Paul - French (France)', lang: 'fr-FR', localService: true };
|
||||
const hortense = { name: 'Microsoft Hortense - French (France)', lang: 'fr-FR', localService: true };
|
||||
const david = { name: 'Microsoft David - English (US)', lang: 'en-US', localService: true };
|
||||
expect(rankSystemVoice(paul, 'fr', 'male')).toBeGreaterThan(rankSystemVoice(hortense, 'fr', 'male'));
|
||||
expect(rankSystemVoice(hortense, 'fr', 'female')).toBeGreaterThan(rankSystemVoice(paul, 'fr', 'female'));
|
||||
expect(rankSystemVoice(paul, 'fr', 'male')).toBeGreaterThan(rankSystemVoice(david, 'fr', 'male'));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user