feat: voix Edge neuronales, Supertonic 3 en local et genre respecté par tous les moteurs (v2.5.0)

- Nouveau moteur « Microsoft Edge » (voix neuronales gratuites, sans clé) : Henri / Denise
  par défaut selon le genre, liste des voix fr-FR / fr-CA / fr-CH / fr-BE, WebSocket signé
  (Sec-MS-GEC) dans le processus principal, MP3 24 kHz. Moteur par défaut des nouvelles
  installations.
- Supertonic 3 ajouté au catalogue local (31 langues, 5 voix masculines + 5 féminines,
  44 kHz, 129 Mo) : langue transmise au worker, genres des voix vérifiés par mesure de F0.
- Kokoro déclassé pour le français (une seule voix féminine, accent) ; le choix
  masculin/féminin bascule sur le meilleur modèle installé et conserve le genre quand on
  change de modèle ; Google Translate signalé comme voix féminine uniquement.
- Deux préréglages JARVIS : en ligne (Edge Henri) et hors ligne (Supertonic 3).
- Traitement du micro par Chromium (écho, bruit, gain) débrayable pour de meilleures
  transcriptions au casque.
- Tests : protocole Edge (jeton, SSML, trames), sélection de voix ; README et feuille de
  route (mesures Supertonic, Parakeet vs Qwen3-ASR).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMpgFriwxiBgurUVb21oCE
This commit is contained in:
Claude committed 2026-09-04 15:37:00 +00:00
1 parent a95f91185f
commit 32551ccc0d
23 files changed
+735 -81

No files matched your search

+3 -1
View File
@@ -15,7 +15,7 @@ import {
} from '../shared/ipc';
import type { EveFlowBridge, SystemAction, SystemActionResult, Unsubscribe } from '../shared/bridge';
import type { McpToolRequest, McpToolResponse } from '../shared/ipc';
import { VOICE_IPC, type KwsDetection, type KwsStartRequest, type VadEvent, type VadStartRequest, type SynthesizeRequest, type SynthesizeResult, type TranscribeRequest, type TranscribeResult, type VoiceDownloadProgress, type VoiceEngineStatus, type VoiceModelStatus } from '../shared/voice';
import { VOICE_IPC, type EdgeSynthesizeRequest, type EdgeSynthesizeResult, type EdgeVoice, type KwsDetection, type KwsStartRequest, type VadEvent, type VadStartRequest, type SynthesizeRequest, type SynthesizeResult, type TranscribeRequest, type TranscribeResult, type VoiceDownloadProgress, type VoiceEngineStatus, type VoiceModelStatus } from '../shared/voice';
function subscribe<T>(channel: string, callback: (payload: T) => void): Unsubscribe {
const listener = (_event: Electron.IpcRendererEvent, payload: T) => callback(payload);
@@ -74,6 +74,8 @@ const api: EveFlowBridge = {
onProgress: (cb: (progress: VoiceDownloadProgress) => void) => subscribe<VoiceDownloadProgress>(VOICE_IPC.modelsProgress, cb),
transcribe: (req: TranscribeRequest) => ipcRenderer.invoke(VOICE_IPC.transcribe, req) as Promise<TranscribeResult>,
synthesize: (req: SynthesizeRequest) => ipcRenderer.invoke(VOICE_IPC.synthesize, req) as Promise<SynthesizeResult>,
edgeSynthesize: (req: EdgeSynthesizeRequest) => ipcRenderer.invoke(VOICE_IPC.edgeSynthesize, req) as Promise<EdgeSynthesizeResult>,
edgeVoices: () => ipcRenderer.invoke(VOICE_IPC.edgeVoices) as Promise<EdgeVoice[]>,
unload: (id?: string) => ipcRenderer.invoke(VOICE_IPC.unload, id) as Promise<unknown>,
kwsStart: (req: KwsStartRequest) => ipcRenderer.invoke(VOICE_IPC.kwsStart, req) as Promise<{ accepted: string[]; rejected: string[] }>,
kwsStop: () => ipcRenderer.invoke(VOICE_IPC.kwsStop) as Promise<void>,
+39 -4
View File
@@ -17,6 +17,22 @@ const KOKORO_SPEAKERS: VoiceSpeaker[] = [
{ id: 26, name: 'George (homme, anglais UK)', lang: 'en' }
];
// Supertonic 3 ships ten voice styles in voice.bin (five feminine, five masculine). The ordering
// was checked by measuring the fundamental frequency of French synthesis: sid 0-4 around
// 170-210 Hz, sid 5-9 around 90-140 Hz.
const SUPERTONIC_SPEAKERS: VoiceSpeaker[] = [
{ id: 6, name: 'Homme 2 (grave, posé)', lang: 'multi', gender: 'm' },
{ id: 9, name: 'Homme 5 (grave)', lang: 'multi', gender: 'm' },
{ id: 7, name: 'Homme 3', lang: 'multi', gender: 'm' },
{ id: 8, name: 'Homme 4', lang: 'multi', gender: 'm' },
{ id: 5, name: 'Homme 1 (clair)', lang: 'multi', gender: 'm' },
{ id: 0, name: 'Femme 1', lang: 'multi', gender: 'f' },
{ id: 1, name: 'Femme 2', lang: 'multi', gender: 'f' },
{ id: 2, name: 'Femme 3', lang: 'multi', gender: 'f' },
{ id: 3, name: 'Femme 4', lang: 'multi', gender: 'f' },
{ id: 4, name: 'Femme 5', lang: 'multi', gender: 'f' }
];
export const VOICE_CATALOG: VoiceModelSpec[] = [
{
id: 'whisper-base',
@@ -112,20 +128,36 @@ export const VOICE_CATALOG: VoiceModelSpec[] = [
files: ['silero_vad.onnx'],
recommended: true
},
{
id: 'supertonic-3',
kind: 'tts',
engine: 'supertonic',
name: 'Supertonic 3 (31 langues, 5 voix masculines et 5 féminines)',
description:
'La meilleure voix française locale : naturelle, sans accent, dix voix au choix, 44 kHz. Environ 7 fois plus rapide que le temps réel sur 4 cœurs, 100 M de paramètres.',
languages: ['fr', 'en', 'de', 'es', 'it', 'pt', 'multi'],
sizeMb: 129,
url: `${TTS}/sherpa-onnx-supertonic-3-tts-int8-2026-05-11.tar.bz2`,
dir: 'sherpa-onnx-supertonic-3-tts-int8-2026-05-11',
files: ['duration_predictor.int8.onnx', 'text_encoder.int8.onnx', 'vector_estimator.int8.onnx', 'vocoder.int8.onnx', 'tts.json', 'unicode_indexer.bin', 'voice.bin'],
speakers: SUPERTONIC_SPEAKERS,
sampleRate: 44100,
recommended: true
},
{
id: 'kokoro-v1',
kind: 'tts',
engine: 'kokoro',
name: 'Kokoro v1.0 multilingue',
description: 'Voix très naturelle, une voix française (Siwis) et de nombreuses voix anglaises. 24 kHz.',
languages: ['fr', 'en', 'multi'],
description:
'Excellent en anglais (nombreuses voix). En français : une seule voix, féminine (Siwis), avec un accent marqué (phonémisation espeak). Préférez Supertonic 3 pour le français. 24 kHz.',
languages: ['en', 'fr', 'multi'],
sizeMb: 349,
url: `${TTS}/kokoro-multi-lang-v1_0.tar.bz2`,
dir: 'kokoro-multi-lang-v1_0',
files: ['model.onnx', 'voices.bin', 'tokens.txt', 'lexicon-us-en.txt', 'lexicon-zh.txt', 'espeak-ng-data/phontab'],
speakers: KOKORO_SPEAKERS,
sampleRate: 24000,
recommended: true
sampleRate: 24000
},
{
id: 'piper-fr-siwis',
@@ -181,6 +213,9 @@ export function findModel(id: string): VoiceModelSpec | undefined {
// 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;
/** Languages accepted by the Supertonic 3 text front-end (2-letter codes). */
export const SUPERTONIC_LANGS = new Set(['ar', 'bg', 'hr', 'cs', 'da', 'nl', 'en', 'et', 'fi', 'fr', 'de', 'el', 'hi', 'hu', 'id', 'it', 'ja', 'ko', 'lv', 'lt', 'pl', 'pt', 'ro', 'ru', 'sk', 'sl', 'es', 'sv', 'tr', 'uk', 'vi']);
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;
+146
View File
@@ -0,0 +1,146 @@
/**
* Microsoft Edge "Read aloud" neural voices (the service behind the Edge browser's read-aloud
* feature): free, no key, very natural French voices with a real masculine/feminine choice
* (Henri, Denise, Rémy, Vivienne…). Runs in the main process: one WebSocket per sentence,
* MP3 back to the renderer.
*/
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import type { EdgeSynthesizeRequest, EdgeSynthesizeResult, EdgeVoice } from '../../shared/voice';
import {
EDGE_CHROMIUM_VERSION,
EDGE_VOICES_URL,
EDGE_WSS_URL,
edgeConfigMessage,
edgeConnectionId,
edgeHeaders,
edgeSsml,
edgeSsmlMessage,
edgeTextFramePath,
edgeTokenInput,
parseEdgeBinaryFrame
} from '../../shared/edgeTts';
import { log } from '../logger';
/** Seconds to add to the local clock so the signed token matches the server's time window. */
let clockSkewSec = 0;
let voicesCache: { at: number; voices: EdgeVoice[] } | null = null;
const VOICES_TTL_MS = 6 * 60 * 60 * 1000;
const SYNTH_TIMEOUT_MS = 20_000;
function token(): string {
return createHash('sha256').update(edgeTokenInput(Date.now(), clockSkewSec), 'ascii').digest('hex').toUpperCase();
}
function signedUrl(): string {
return `${EDGE_WSS_URL}&ConnectionId=${edgeConnectionId(randomUUID())}&Sec-MS-GEC=${token()}&Sec-MS-GEC-Version=1-${EDGE_CHROMIUM_VERSION}`;
}
function headers(): Record<string, string> {
return { ...edgeHeaders(), Cookie: `muid=${randomBytes(16).toString('hex')};` };
}
/** Learn the server clock from a plain HTTPS response (the WebSocket handshake hides its headers). */
async function syncClock(): Promise<void> {
try {
const res = await fetch(EDGE_VOICES_URL, { method: 'HEAD', headers: headers() });
const date = res.headers.get('date');
if (!date) return;
const server = Date.parse(date);
if (Number.isFinite(server)) {
clockSkewSec = (server - Date.now()) / 1000;
log('INFO', 'edge-tts', `clock skew ${clockSkewSec.toFixed(0)} s`);
}
} catch (err) {
log('WARN', 'edge-tts', `clock sync failed: ${(err as Error).message}`);
}
}
function synthesizeOnce(req: EdgeSynthesizeRequest): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
const chunks: Uint8Array[] = [];
let settled = false;
let ws: WebSocket;
try {
// Node's global WebSocket accepts extra handshake headers (undici), which the service checks.
ws = new (WebSocket as unknown as new (url: string, options: { headers: Record<string, string> }) => WebSocket)(signedUrl(), { headers: headers() });
} catch (err) {
reject(err as Error);
return;
}
ws.binaryType = 'arraybuffer';
const finish = (err?: Error) => {
if (settled) return;
settled = true;
clearTimeout(timer);
try {
ws.close();
} catch {
/* already closed */
}
if (err) reject(err);
else {
const total = chunks.reduce((n, c) => n + c.byteLength, 0);
const out = new Uint8Array(total);
let o = 0;
for (const c of chunks) {
out.set(c, o);
o += c.byteLength;
}
resolve(out);
}
};
const timer = setTimeout(() => finish(new Error('Edge TTS : délai dépassé')), SYNTH_TIMEOUT_MS);
ws.onopen = () => {
ws.send(edgeConfigMessage());
ws.send(edgeSsmlMessage(edgeConnectionId(randomUUID()), edgeSsml(req.text, req.voice, req.speed)));
};
ws.onmessage = (event: MessageEvent) => {
if (typeof event.data === 'string') {
if (edgeTextFramePath(event.data) === 'turn.end') finish();
return;
}
const frame = new Uint8Array(event.data as ArrayBuffer);
const { path, payload } = parseEdgeBinaryFrame(frame);
if (path === 'audio' && payload.byteLength) chunks.push(payload);
};
ws.onerror = (event: Event) => finish(new Error(`Edge TTS : connexion refusée (${(event as { message?: string }).message ?? 'erreur réseau'})`));
ws.onclose = (event: CloseEvent) => {
if (!settled) finish(chunks.length ? undefined : new Error(`Edge TTS : connexion fermée (${event.code}${event.reason ? ' ' + event.reason : ''})`));
};
});
}
export async function edgeSynthesize(req: EdgeSynthesizeRequest): Promise<EdgeSynthesizeResult> {
const started = Date.now();
let mp3: Uint8Array;
try {
mp3 = await synthesizeOnce(req);
} catch (err) {
// A refused handshake is almost always a stale signature: resync the clock and retry once.
log('WARN', 'edge-tts', `first attempt failed (${(err as Error).message}), resyncing clock`);
await syncClock();
mp3 = await synthesizeOnce(req);
}
if (!mp3.byteLength) throw new Error('Edge TTS : aucun audio reçu');
return { mp3, durationMs: Date.now() - started };
}
/** Voice list from the service (cached six hours); falls back to an empty list offline. */
export async function edgeVoices(): Promise<EdgeVoice[]> {
if (voicesCache && Date.now() - voicesCache.at < VOICES_TTL_MS) return voicesCache.voices;
const url = `${EDGE_VOICES_URL}&Sec-MS-GEC=${token()}&Sec-MS-GEC-Version=1-${EDGE_CHROMIUM_VERSION}`;
const res = await fetch(url, { headers: headers() });
if (!res.ok) throw new Error(`Edge TTS : liste des voix HTTP ${res.status}`);
const raw = (await res.json()) as Array<{ ShortName?: string; FriendlyName?: string; Locale?: string; Gender?: string }>;
const voices: EdgeVoice[] = raw
.filter((v) => typeof v.ShortName === 'string' && typeof v.Locale === 'string')
.map((v) => ({
shortName: v.ShortName!,
name: v.ShortName!.split('-')[2]?.replace(/(Multilingual)?Neural$/, '') || v.FriendlyName || v.ShortName!,
locale: v.Locale!,
gender: v.Gender === 'Male' ? ('m' as const) : ('f' as const)
}))
.sort((a, b) => a.locale.localeCompare(b.locale) || a.name.localeCompare(b.name));
voicesCache = { at: Date.now(), voices };
return voices;
}
+1 -1
View File
@@ -134,7 +134,7 @@ export function transcribe(req: TranscribeRequest): Promise<TranscribeResult> {
export async function synthesize(req: SynthesizeRequest): Promise<SynthesizeResult> {
const result = await request<Omit<SynthesizeResult, 'wav'> & { wav: string }>(
{ type: 'synthesize', model: modelRef(req.modelId), text: req.text, speaker: req.speaker, speed: req.speed },
{ type: 'synthesize', model: modelRef(req.modelId), text: req.text, speaker: req.speaker, speed: req.speed, language: req.language },
180_000
);
const buffer = Buffer.from(result.wav, 'base64');
+14 -2
View File
@@ -1,5 +1,6 @@
import { ipcMain } from 'electron';
import { VOICE_IPC, type KwsStartRequest, type SynthesizeRequest, type TranscribeRequest, type VadStartRequest } from '../../shared/voice';
import { VOICE_IPC, type EdgeSynthesizeRequest, type KwsStartRequest, type SynthesizeRequest, type TranscribeRequest, type VadStartRequest } from '../../shared/voice';
import { edgeSynthesize, edgeVoices } from './edgeTts';
import { engineStatus, kwsFeed, kwsStart, kwsStop, synthesize, transcribe, unload, vadFeed, vadStart, vadStop } from './engine';
import { cancelDownload, downloadModel, listModels, removeModel } from './models';
@@ -24,8 +25,19 @@ export function registerVoiceIpc(): void {
ipcMain.handle(VOICE_IPC.synthesize, (_e, req: SynthesizeRequest) => {
if (!req || typeof req.text !== 'string' || !req.text.trim() || req.text.length > 5000) throw new Error('Texte invalide');
if (typeof req.modelId !== 'string') throw new Error('Modèle invalide');
return synthesize({ ...req, speaker: Number.isFinite(req.speaker) ? req.speaker : 0, speed: Number.isFinite(req.speed) ? req.speed : 1 });
return synthesize({
...req,
speaker: Number.isFinite(req.speaker) ? req.speaker : 0,
speed: Number.isFinite(req.speed) ? req.speed : 1,
language: typeof req.language === 'string' ? req.language.slice(0, 8) : undefined
});
});
ipcMain.handle(VOICE_IPC.edgeSynthesize, (_e, req: EdgeSynthesizeRequest) => {
if (!req || typeof req.text !== 'string' || !req.text.trim() || req.text.length > 5000) throw new Error('Texte invalide');
if (typeof req.voice !== 'string' || !/^[a-z]{2,3}-[A-Za-z]{2,4}-[A-Za-z0-9]+$/.test(req.voice)) throw new Error('Voix Edge invalide');
return edgeSynthesize({ text: req.text, voice: req.voice, speed: Number.isFinite(req.speed) ? req.speed : 1 });
});
ipcMain.handle(VOICE_IPC.edgeVoices, () => edgeVoices());
ipcMain.handle(VOICE_IPC.unload, (_e, id?: string) => unload(id));
ipcMain.handle(VOICE_IPC.kwsStart, (event, req: KwsStartRequest) => {
if (!req || !Array.isArray(req.keywords) || typeof req.modelId !== 'string') throw new Error('Requête invalide');
+36 -3
View File
@@ -6,6 +6,7 @@
import os from 'node:os';
import path from 'node:path';
import type { VoiceEngineKind } from '../../shared/voice';
import { SUPERTONIC_LANGS } from './catalog';
interface ModelRef {
id: string;
@@ -17,7 +18,7 @@ interface ModelRef {
type Request =
| { id: number; type: 'status' }
| { id: number; type: 'transcribe'; model: ModelRef; wav: Uint8Array | string; language: string }
| { id: number; type: 'synthesize'; model: ModelRef; text: string; speaker: number; speed: number }
| { id: number; type: 'synthesize'; model: ModelRef; text: string; speaker: number; speed: number; language?: string }
| { 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 }
@@ -55,8 +56,10 @@ type Sherpa = {
OfflineTts: new (config: unknown) => {
numSpeakers: number;
sampleRate: number;
generate: (req: { text: string; sid: number; speed: number; enableExternalBuffer?: boolean }) => { samples: Float32Array; sampleRate: number };
generate: (req: { text: string; sid: number; speed: number; enableExternalBuffer?: boolean; generationConfig?: unknown }) => { samples: Float32Array; sampleRate: number };
};
/** Per-request options for the newer engines (Supertonic reads `extra.lang`). */
GenerationConfig: new (opts: { sid: number; speed: number; numSteps?: number; extra?: Record<string, string | number> }) => unknown;
version: string;
};
@@ -152,6 +155,19 @@ function getSynthesizer(model: ModelRef) {
ttsModel = { vits: { model: p(onnx), tokens: p('tokens.txt'), dataDir: p('espeak-ng-data') } };
break;
}
case 'supertonic':
ttsModel = {
supertonic: {
durationPredictor: p('duration_predictor.int8.onnx'),
textEncoder: p('text_encoder.int8.onnx'),
vectorEstimator: p('vector_estimator.int8.onnx'),
vocoder: p('vocoder.int8.onnx'),
ttsJson: p('tts.json'),
unicodeIndexer: p('unicode_indexer.bin'),
voiceStyle: p('voice.bin')
}
};
break;
default:
throw new Error(`Moteur TTS non supporté : ${model.engine}`);
}
@@ -160,6 +176,12 @@ function getSynthesizer(model: ModelRef) {
return tts;
}
/** 2-letter code accepted by Supertonic 3 ("fr-FR" → "fr"); English when unknown, as upstream does. */
function supertonicLang(language: string | undefined): string {
const code = (language ?? '').toLowerCase().split(/[-_]/)[0];
return SUPERTONIC_LANGS.has(code) ? code : 'en';
}
// ── audio helpers ──────────────────────────────────────────────────────────
function decodeWav(bytes: Uint8Array): { samples: Float32Array; sampleRate: number } {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
@@ -397,8 +419,19 @@ function handle(req: Request): unknown {
const started = Date.now();
const tts = getSynthesizer(req.model);
const sid = Math.max(0, Math.min(tts.numSpeakers - 1, Math.floor(req.speaker)));
const speed = Math.max(0.5, Math.min(2, req.speed || 1));
// Electron forbids N-API external buffers: ask sherpa-onnx to copy the samples into a V8 buffer.
const audio = tts.generate({ text: req.text, sid, speed: Math.max(0.5, Math.min(2, req.speed || 1)), enableExternalBuffer: false });
const audio =
req.model.engine === 'supertonic'
? tts.generate({
text: req.text,
sid,
speed,
enableExternalBuffer: false,
// Supertonic needs the language of the text; 5 denoising steps is the quality/speed sweet spot.
generationConfig: new (loadSherpa().GenerationConfig)({ sid, speed, numSteps: 5, extra: { lang: supertonicLang(req.language) } })
})
: tts.generate({ text: req.text, sid, speed, enableExternalBuffer: false });
const wav = encodeWav(audio.samples, audio.sampleRate);
return {
wav: Buffer.from(wav.buffer, wav.byteOffset, wav.byteLength).toString('base64'),