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

+5 -3
View File
@@ -49,6 +49,8 @@ export interface CaptureCallbacks {
export interface CaptureOptions {
deviceId?: string;
/** Chromium's echo cancellation / noise suppression / auto gain (default on). Off keeps the raw signal for the recogniser. */
micProcessing?: boolean;
mode: CaptureMode;
vad?: Partial<VadOptions>;
callbacks: CaptureCallbacks;
@@ -124,9 +126,9 @@ export class MicCapture {
const constraints: MediaStreamConstraints = {
audio: {
deviceId: options.deviceId ? { exact: options.deviceId } : undefined,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
echoCancellation: options.micProcessing !== false,
noiseSuppression: options.micProcessing !== false,
autoGainControl: options.micProcessing !== false,
channelCount: 1
}
};
+21 -6
View File
@@ -1,16 +1,17 @@
/**
* Text-to-speech engine with a sentence queue, bounded prefetching and Web Audio playback so
* the HUD reacts to the actual waveform. Providers: in-app sherpa-onnx (Kokoro / Piper),
* OpenAI-compatible /v1/audio/speech, system voices, and the legacy Google Translate endpoint.
* the HUD reacts to the actual waveform. Providers: Microsoft Edge neural voices (online, free),
* in-app sherpa-onnx (Supertonic / Kokoro / Piper), OpenAI-compatible /v1/audio/speech, system
* voices, and the legacy Google Translate endpoint (one feminine voice per language).
*/
import { Log } from '../../lib/log';
import { defaultOpenAiVoice, rankSystemVoice } from '../../lib/voicePreference';
import { defaultOpenAiVoice, rankSystemVoice, resolveEdgeVoice } from '../../lib/voicePreference';
import { bridge } from '../../lib/bridge';
import { chunkForSpeech, cleanForSpeech, extractSentences } from '../../lib/text';
import { httpFetch } from '../../lib/transport';
import { audioBus } from './audioBus';
export type TtsProvider = 'openai-compatible' | 'system' | 'google-free' | 'local' | 'off';
export type TtsProvider = 'edge' | 'openai-compatible' | 'system' | 'google-free' | 'local' | 'off';
export interface TtsConfig {
provider: TtsProvider;
@@ -27,6 +28,8 @@ export interface TtsConfig {
localModel: string;
/** Speaker id inside the local model. */
localSpeaker: number;
/** Microsoft Edge voice short name (provider 'edge'); empty = automatic from language + gender. */
edgeVoice?: string;
/** Preferred voice gender, applied to every provider's default voice. */
voiceGender?: 'male' | 'female';
/** 'jarvis' adds a subtle AI timbre: slightly lower pitch, warm/crisp EQ, short room reverb. */
@@ -179,8 +182,9 @@ export class TtsEngine {
}
private prefetch(text: string, gen: number): Promise<AudioBuffer | null> {
const provider = this.config.provider;
const task =
this.config.provider === 'google-free' ? this.fetchGoogle(text) : this.config.provider === 'local' ? this.fetchLocal(text) : this.fetchOpenAi(text);
provider === 'google-free' ? this.fetchGoogle(text) : provider === 'local' ? this.fetchLocal(text) : provider === 'edge' ? this.fetchEdge(text) : this.fetchOpenAi(text);
return task
.then(async (bytes) => {
if (gen !== this.generation || !bytes) return null;
@@ -228,12 +232,23 @@ export class TtsEngine {
modelId: this.config.localModel,
text,
speaker: this.config.localSpeaker,
speed: Math.max(0.5, Math.min(2, this.config.speed || 1))
speed: Math.max(0.5, Math.min(2, this.config.speed || 1)),
language: this.config.language || 'fr-FR'
});
Log.debug('tts', `local synthesis ${result.durationMs} ms for ${result.audioSec.toFixed(1)}s of audio`);
return result.wav;
}
/** Microsoft Edge neural voices through the main process (no key, online). */
private async fetchEdge(text: string): Promise<Uint8Array> {
const api = bridge();
if (!api) throw new Error('Les voix Edge nécessitent l’application Electron.');
const voice = resolveEdgeVoice(this.config.edgeVoice ?? '', this.config.language || 'fr-FR', this.config.voiceGender ?? 'male');
const result = await api.voice.edgeSynthesize({ text, voice, speed: Math.max(0.5, Math.min(2, this.config.speed || 1)) });
Log.debug('tts', `edge synthesis (${voice}) ${result.durationMs} ms, ${result.mp3.byteLength} bytes`);
return result.mp3;
}
private async fetchGoogle(text: string): Promise<Uint8Array> {
const url = `https://translate.google.com/translate_tts?ie=UTF-8&tl=${encodeURIComponent(this.config.language.split('-')[0] || 'fr')}&client=tw-ob&q=${encodeURIComponent(text.slice(0, 200))}`;
const res = await httpFetch({
+2
View File
@@ -128,6 +128,7 @@ class VoiceController {
}
await this.wake.start({
deviceId: settings.micDeviceId || undefined,
micProcessing: settings.micProcessing ?? true,
neuralVad,
vad: { silenceMs: settings.silenceMs, speechRatio: SENSITIVITY_RATIO[sensitivity], minRms: SENSITIVITY_MIN_RMS[sensitivity] },
callbacks: {
@@ -249,6 +250,7 @@ class VoiceController {
await audioBus.resume();
await this.capture.start({
deviceId: settings.micDeviceId || undefined,
micProcessing: settings.micProcessing ?? true,
mode: settings.captureMode,
vad: {
silenceMs: settings.silenceMs,
+32 -12
View File
@@ -1,44 +1,64 @@
/**
* 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.
* downloads a matching French voice when none is installed, resets an Edge voice of the other
* gender, and explains providers that cannot honour the choice (Google Translate).
*/
import { Log } from '../../lib/log';
import { findLocalVoice, inferGender, suggestedDownload } from '../../lib/voicePreference';
import { bestLocalUpgrade, findLocalVoice, resolveEdgeVoice, 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 genderLabel = (g: 'male' | 'female') => (g === 'male' ? 'masculine' : 'féminine');
/**
* Make the active provider match `speech.voiceGender`. Returns a short status message.
* With `upgrade`, the best local model for the language is downloaded even if a lesser voice exists.
*/
export async function ensurePreferredVoice(options: { upgrade?: boolean } = {}): Promise<string> {
const { settings, update } = useSettings.getState();
const { speech } = settings;
const gender = speech.voiceGender ?? 'male';
const lang = speech.language || 'fr-FR';
if (speech.provider === 'edge') {
const voice = resolveEdgeVoice(speech.edgeVoice ?? '', lang, gender);
if (voice !== (speech.edgeVoice ?? '').trim() && speech.edgeVoice) update({ speech: { edgeVoice: '' } });
return `Voix ${genderLabel(gender)} : ${voice.split('-')[2]?.replace(/(Multilingual)?Neural$/, '') ?? voice} (Edge)`;
}
if (speech.provider === 'google-free') {
return gender === 'male'
? 'Google Translate n’a qu’une voix féminine par langue : choisissez « Microsoft Edge » ou une voix locale pour une voix masculine.'
: '';
}
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 currentGender = currentSpeaker ? (currentSpeaker.gender === 'm' ? 'male' : currentSpeaker.gender === 'f' ? 'female' : undefined) : undefined;
const upgrade = options.upgrade ? bestLocalUpgrade(models, lang) : null;
if (current?.installed && currentGender === gender && !upgrade) return '';
const choice = findLocalVoice(models, lang, gender);
const choice = upgrade ? null : 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}`;
return `Voix ${genderLabel(gender)} : ${name}`;
}
const download = suggestedDownload(models, lang, gender);
const download = upgrade ?? 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}`);
Log.info('tts', `${upgrade ? 'upgrading local voice' : `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.';
const name = after ? useVoiceModels.getState().models.find((m) => m.id === after.modelId)?.name : undefined;
return after ? `Voix ${genderLabel(gender)} installée : ${name ?? after.modelId}.` : '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}`;
+4 -4
View File
@@ -74,7 +74,7 @@ export class WakeListener {
return this.phase;
}
async start(options: { deviceId?: string; vad?: Partial<VadOptions>; neuralVad?: boolean; callbacks: WakeCallbacks }): Promise<void> {
async start(options: { deviceId?: string; micProcessing?: boolean; vad?: Partial<VadOptions>; neuralVad?: boolean; callbacks: WakeCallbacks }): Promise<void> {
if (this.phase !== 'off') return;
this.callbacks = options.callbacks;
this.vadOptions = options.vad ?? {};
@@ -86,9 +86,9 @@ export class WakeListener {
this.stream = await navigator.mediaDevices.getUserMedia({
audio: {
deviceId: options.deviceId ? { exact: options.deviceId } : undefined,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
echoCancellation: options.micProcessing !== false,
noiseSuppression: options.micProcessing !== false,
autoGainControl: options.micProcessing !== false,
channelCount: 1
}
});