mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-19 05:09:30 +02:00
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:
23 files changed
+735
-81
No files matched your search
+3
-1
@@ -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>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in new issue
Block a user