Files
EveFlow/electron/voice/edgeTts.ts
T
Claude 32551ccc0d 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
2026-09-04 15:37:00 +00:00

147 lines
5.8 KiB
TypeScript

/**
* 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;
}