mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-12 01:39:13 +02:00
- 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
120 lines
6.1 KiB
TypeScript
120 lines
6.1 KiB
TypeScript
/**
|
|
* Pure helpers for the Microsoft Edge "Read aloud" speech service (the endpoint used by the
|
|
* Edge browser, no API key): request signing input, SSML building, frame parsing and the
|
|
* default French voices. No Node or DOM dependency so it is shared by main and tests.
|
|
*/
|
|
|
|
export const EDGE_TRUSTED_CLIENT_TOKEN = '6A5AA1D4EAFF4E9FB37E23D68491D6F4';
|
|
export const EDGE_CHROMIUM_VERSION = '143.0.3650.75';
|
|
export const EDGE_WSS_URL = `wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1?TrustedClientToken=${EDGE_TRUSTED_CLIENT_TOKEN}`;
|
|
export const EDGE_VOICES_URL = `https://speech.platform.bing.com/consumer/speech/synthesize/readaloud/voices/list?trustedclienttoken=${EDGE_TRUSTED_CLIENT_TOKEN}`;
|
|
export const EDGE_OUTPUT_FORMAT = 'audio-24khz-48kbitrate-mono-mp3';
|
|
|
|
const WIN_EPOCH_SEC = 11644473600;
|
|
|
|
/**
|
|
* String whose SHA-256 (upper-case hex) is the `Sec-MS-GEC` value: Windows file time (100 ns
|
|
* ticks since 1601) rounded down to 5 minutes, followed by the trusted client token.
|
|
* `nowMs` is the client clock, `skewSec` the correction learned from the server's Date header.
|
|
*/
|
|
export function edgeTokenInput(nowMs: number, skewSec = 0): string {
|
|
let seconds = Math.floor(nowMs / 1000 + skewSec) + WIN_EPOCH_SEC;
|
|
seconds -= seconds % 300;
|
|
// 10 million ticks per second; BigInt keeps the 18-digit value exact.
|
|
const ticks = BigInt(seconds) * 10_000_000n;
|
|
return `${ticks}${EDGE_TRUSTED_CLIENT_TOKEN}`;
|
|
}
|
|
|
|
export function edgeHeaders(): Record<string, string> {
|
|
const major = EDGE_CHROMIUM_VERSION.split('.')[0];
|
|
return {
|
|
'User-Agent': `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36 Edg/${major}.0.0.0`,
|
|
'Accept-Language': 'en-US,en;q=0.9',
|
|
Pragma: 'no-cache',
|
|
'Cache-Control': 'no-cache',
|
|
Origin: 'chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold'
|
|
};
|
|
}
|
|
|
|
export function escapeXml(text: string): string {
|
|
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
|
|
/** Prosody rate attribute for a playback speed multiplier (1 = "+0%", 1.25 = "+25%"). */
|
|
export function edgeRate(speed: number): string {
|
|
const pct = Math.round((Math.max(0.5, Math.min(2, speed || 1)) - 1) * 100);
|
|
return `${pct >= 0 ? '+' : ''}${pct}%`;
|
|
}
|
|
|
|
export function edgeSsml(text: string, voice: string, speed: number): string {
|
|
const lang = voice.split('-').slice(0, 2).join('-') || 'fr-FR';
|
|
return (
|
|
`<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='${lang}'>` +
|
|
`<voice name='${escapeXml(voice)}'><prosody pitch='+0Hz' rate='${edgeRate(speed)}' volume='+0%'>${escapeXml(text)}</prosody></voice></speak>`
|
|
);
|
|
}
|
|
|
|
/** Timestamp header the service expects ("JavaScript date string"). */
|
|
export function edgeTimestamp(date = new Date()): string {
|
|
return date.toUTCString().replace('GMT', 'GMT+0000 (Coordinated Universal Time)');
|
|
}
|
|
|
|
export function edgeConfigMessage(date = new Date()): string {
|
|
return (
|
|
`X-Timestamp:${edgeTimestamp(date)}\r\nContent-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n` +
|
|
`{"context":{"synthesis":{"audio":{"metadataoptions":{"sentenceBoundaryEnabled":"false","wordBoundaryEnabled":"false"},"outputFormat":"${EDGE_OUTPUT_FORMAT}"}}}}`
|
|
);
|
|
}
|
|
|
|
export function edgeSsmlMessage(requestId: string, ssml: string, date = new Date()): string {
|
|
return `X-RequestId:${requestId}\r\nContent-Type:application/ssml+xml\r\nX-Timestamp:${edgeTimestamp(date)}Z\r\nPath:ssml\r\n\r\n${ssml}`;
|
|
}
|
|
|
|
/** Split a binary frame into its text headers and payload (2-byte big-endian header length). */
|
|
export function parseEdgeBinaryFrame(frame: Uint8Array): { path: string; payload: Uint8Array } {
|
|
if (frame.byteLength < 2) return { path: '', payload: new Uint8Array(0) };
|
|
const headerLength = (frame[0] << 8) | frame[1];
|
|
const end = Math.min(frame.byteLength, 2 + headerLength);
|
|
let header = '';
|
|
for (let i = 2; i < end; i++) header += String.fromCharCode(frame[i]);
|
|
const path = /Path:\s*([^\r\n]+)/i.exec(header)?.[1]?.trim() ?? '';
|
|
return { path, payload: frame.subarray(end) };
|
|
}
|
|
|
|
/** Path of a text frame ("turn.start", "response", "audio.metadata", "turn.end"). */
|
|
export function edgeTextFramePath(message: string): string {
|
|
return /Path:\s*([^\r\n]+)/i.exec(message)?.[1]?.trim() ?? '';
|
|
}
|
|
|
|
export function edgeConnectionId(hex32: string): string {
|
|
return hex32.replace(/-/g, '').toLowerCase();
|
|
}
|
|
|
|
/** Well-known voices per language so the choice works before the voice list is fetched. */
|
|
export const EDGE_DEFAULT_VOICES: Record<string, { male: string; female: string }> = {
|
|
fr: { male: 'fr-FR-HenriNeural', female: 'fr-FR-DeniseNeural' },
|
|
en: { male: 'en-US-AndrewMultilingualNeural', female: 'en-US-AvaMultilingualNeural' },
|
|
de: { male: 'de-DE-ConradNeural', female: 'de-DE-KatjaNeural' },
|
|
es: { male: 'es-ES-AlvaroNeural', female: 'es-ES-ElviraNeural' },
|
|
it: { male: 'it-IT-DiegoNeural', female: 'it-IT-ElsaNeural' },
|
|
pt: { male: 'pt-BR-AntonioNeural', female: 'pt-BR-FranciscaNeural' }
|
|
};
|
|
|
|
/** Default Edge voice for a language ("fr-FR", "fr", "en-GB") and gender; French when unknown. */
|
|
export function defaultEdgeVoice(language: string, gender: 'male' | 'female'): string {
|
|
const lang = (language || 'fr').toLowerCase().split(/[-_]/)[0];
|
|
return (EDGE_DEFAULT_VOICES[lang] ?? EDGE_DEFAULT_VOICES.fr)[gender];
|
|
}
|
|
|
|
/** Gender of an Edge voice from its short name, using the built-in table then common first names. */
|
|
export function edgeVoiceGender(shortName: string): 'male' | 'female' | undefined {
|
|
for (const pair of Object.values(EDGE_DEFAULT_VOICES)) {
|
|
if (pair.male === shortName) return 'male';
|
|
if (pair.female === shortName) return 'female';
|
|
}
|
|
const name = shortName.split('-')[2]?.replace(/(Multilingual)?Neural$/i, '') ?? '';
|
|
if (/^(Henri|Remy|Rémy|Gerard|Antoine|Jean|Thierry|Fabrice|Claude|Andrew|Brian|Guy|Christopher|Eric|Roger|Steffan|Ryan|Thomas|Conrad|Alvaro|Diego|Antonio)$/i.test(name)) return 'male';
|
|
if (/^(Denise|Eloise|Vivienne|Charline|Sylvie|Ariane|Ava|Emma|Jenny|Aria|Michelle|Ana|Sonia|Libby|Katja|Elvira|Elsa|Francisca)$/i.test(name)) return 'female';
|
|
return undefined;
|
|
}
|