mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-15 03:07:06 +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,6 +3,7 @@ import { Download, Trash2, X, CheckCircle2, Cpu, Mic, Volume2, AlertTriangle, Re
|
||||
import type { VoiceModelStatus } from '../../../shared/voice';
|
||||
import { bridge } from '../../lib/bridge';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { pickSpeaker } from '../../lib/voicePreference';
|
||||
import { useVoiceModels } from '../../state/voiceModels';
|
||||
import { useSettings } from '../../state/settings';
|
||||
|
||||
@@ -24,7 +25,10 @@ function ModelRow({ model }: { model: VoiceModelStatus }) {
|
||||
if (model.kind === 'stt') update({ voice: { localModel: model.id, provider: 'local' } });
|
||||
else if (model.kind === 'kws') update({ voice: { wakeMode: 'kws' } });
|
||||
else if (model.kind === 'vad') update({ voice: { neuralVad: true } });
|
||||
else update({ speech: { localModel: model.id, provider: 'local', localSpeaker: model.speakers?.[0]?.id ?? 0 } });
|
||||
else {
|
||||
const { speech } = useSettings.getState().settings;
|
||||
update({ speech: { localModel: model.id, provider: 'local', localSpeaker: pickSpeaker(model, speech.language || 'fr-FR', speech.voiceGender ?? 'male') } });
|
||||
}
|
||||
};
|
||||
const neuralVad = useSettings((s) => s.settings.voice.neuralVad);
|
||||
const activeNow = model.kind === 'kws' ? wakeMode === 'kws' : model.kind === 'vad' ? neuralVad : isActive;
|
||||
|
||||
@@ -5,6 +5,8 @@ import { HermesClient, discoverHermesUrl, hermesUrlCandidates } from '../../serv
|
||||
import { listSystemVoices } from '../../services/voice/tts';
|
||||
import { speech } from '../../services/voice/speech';
|
||||
import { ensurePreferredVoice } from '../../services/voice/voicePreference';
|
||||
import { pickSpeaker, resolveEdgeVoice } from '../../lib/voicePreference';
|
||||
import type { EdgeVoice } from '../../../shared/voice';
|
||||
import { listMicrophones } from '../../services/voice/capture';
|
||||
import { transcribeWav } from '../../services/voice/stt';
|
||||
import { encodeWav } from '../../services/voice/wav';
|
||||
@@ -86,8 +88,18 @@ export function SettingsDrawer({ onClose }: Props) {
|
||||
const [sttTest, setSttTest] = useState<TestState>({ status: 'idle', message: '' });
|
||||
const [ttsTest, setTtsTest] = useState<TestState>({ status: 'idle', message: '' });
|
||||
const [voices, setVoices] = useState(listSystemVoices());
|
||||
const [edgeVoices, setEdgeVoices] = useState<EdgeVoice[]>([]);
|
||||
const [webhookSecretVisible, setWebhookSecretVisible] = useState(false);
|
||||
|
||||
const speechProvider = settings.speech.provider;
|
||||
useEffect(() => {
|
||||
if (speechProvider !== 'edge' || edgeVoices.length) return;
|
||||
bridge()
|
||||
?.voice.edgeVoices()
|
||||
.then(setEdgeVoices)
|
||||
.catch((err: Error) => setTtsTest({ status: 'fail', message: `Liste des voix Edge indisponible : ${err.message}` }));
|
||||
}, [speechProvider, edgeVoices.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => setVoices(listSystemVoices());
|
||||
refresh();
|
||||
@@ -359,6 +371,12 @@ export function SettingsDrawer({ onClose }: Props) {
|
||||
</>
|
||||
)}
|
||||
<Toggle on={settings.voice.localCommands} onChange={(v) => update({ voice: { localCommands: v } })} label="Commandes locales instantanées" hint="« Verrouille la session », « monte le son », « ouvre Spotify », « regarde mon écran »… exécutées sur ce PC sans passer par Hermes." />
|
||||
<Toggle
|
||||
on={settings.voice.micProcessing ?? true}
|
||||
onChange={(v) => update({ voice: { micProcessing: v } })}
|
||||
label="Traitement du micro par Chromium (écho, bruit, gain automatique)"
|
||||
hint="Désactivez-le si les transcriptions sont approximatives avec un casque ou un bon micro : ces filtres déforment la voix avant la reconnaissance. Gardez-le activé avec des haut-parleurs (sinon la voix de l’assistant est réentendue par le micro)."
|
||||
/>
|
||||
<div className="row" style={{ marginTop: 10 }}>
|
||||
<button className="btn small" onClick={() => void testStt()} disabled={settings.voice.provider === 'browser'}><Mic size={13} /> Tester la reconnaissance</button>
|
||||
</div>
|
||||
@@ -374,7 +392,11 @@ export function SettingsDrawer({ onClose }: Props) {
|
||||
<button className={(settings.speech.voiceGender ?? 'male') === 'male' ? 'active' : ''} onClick={() => { update({ speech: { voiceGender: 'male' } }); void ensurePreferredVoice().then((m) => m && setTtsTest({ status: 'ok', message: m })); }}>Masculine</button>
|
||||
<button className={settings.speech.voiceGender === 'female' ? 'active' : ''} onClick={() => { update({ speech: { voiceGender: 'female' } }); void ensurePreferredVoice().then((m) => m && setTtsTest({ status: 'ok', message: m })); }}>Féminine</button>
|
||||
</div>
|
||||
<span className="hint">S’applique à tous les moteurs : voix locale (Piper Tom ou Pierre pour le masculin, téléchargée automatiquement si besoin), voix OpenAI (onyx / nova), voix système Windows (Paul / Hortense). Kokoro n’a pas de voix française masculine.</span>
|
||||
<span className="hint">
|
||||
S’applique à tous les moteurs : Edge (Henri / Denise), voix locale (Supertonic 3, cinq voix de chaque genre, téléchargé automatiquement si besoin), API OpenAI (onyx / nova), voix système Windows (Paul / Hortense).
|
||||
{settings.speech.provider === 'google-free' && ' Google Translate n’a qu’une voix féminine : ce choix n’a pas d’effet avec ce moteur.'}
|
||||
{settings.speech.provider === 'local' && settings.speech.localModel === 'kokoro-v1' && ' Kokoro n’a qu’une voix française, féminine et avec accent : la voix masculine bascule sur Supertonic 3.'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Timbre</label>
|
||||
@@ -388,29 +410,62 @@ export function SettingsDrawer({ onClose }: Props) {
|
||||
<button
|
||||
className="btn small primary"
|
||||
onClick={() => {
|
||||
update({ speech: { provider: 'local', voiceGender: 'male', timbre: 'jarvis', speed: 0.97, autoSpeak: true } });
|
||||
setTtsTest({ status: 'running', message: 'préparation de la voix JARVIS (téléchargement de Piper Tom si nécessaire)…' });
|
||||
update({ speech: { provider: 'edge', edgeVoice: '', voiceGender: 'male', timbre: 'jarvis', speed: 0.97, autoSpeak: true } });
|
||||
void ensurePreferredVoice().then((m) => setTtsTest({ status: 'ok', message: m || 'Voix JARVIS prête.' })).catch((e: Error) => setTtsTest({ status: 'fail', message: e.message }));
|
||||
}}
|
||||
>
|
||||
<Volume2 size={13} /> Préréglage voix JARVIS (français, masculine, locale)
|
||||
<Volume2 size={13} /> Voix JARVIS en ligne (Edge Henri, masculine)
|
||||
</button>
|
||||
<button
|
||||
className="btn small"
|
||||
onClick={() => {
|
||||
update({ speech: { provider: 'local', voiceGender: 'male', timbre: 'jarvis', speed: 0.97, autoSpeak: true } });
|
||||
setTtsTest({ status: 'running', message: 'préparation de la voix JARVIS locale (téléchargement de Supertonic 3, 129 Mo, si nécessaire)…' });
|
||||
void ensurePreferredVoice({ upgrade: true }).then((m) => setTtsTest({ status: 'ok', message: m || 'Voix JARVIS locale prête.' })).catch((e: Error) => setTtsTest({ status: 'fail', message: e.message }));
|
||||
}}
|
||||
>
|
||||
<Volume2 size={13} /> Voix JARVIS hors ligne (Supertonic 3, masculine)
|
||||
</button>
|
||||
<span className="status-pill">
|
||||
{settings.speech.provider === 'local'
|
||||
? `voix active : ${voiceModels.find((m) => m.id === settings.speech.localModel)?.speakers?.find((s) => s.id === settings.speech.localSpeaker)?.name ?? settings.speech.localModel}`
|
||||
: `moteur : ${settings.speech.provider}`}
|
||||
: settings.speech.provider === 'edge'
|
||||
? `voix active : ${resolveEdgeVoice(settings.speech.edgeVoice ?? '', settings.speech.language, settings.speech.voiceGender ?? 'male')}`
|
||||
: `moteur : ${settings.speech.provider}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Moteur de synthèse</label>
|
||||
<select className="select" value={settings.speech.provider} onChange={(e) => update({ speech: { provider: e.target.value as typeof settings.speech.provider } })}>
|
||||
<option value="local">Local dans l’application (Kokoro / Piper via sherpa-onnx, hors ligne)</option>
|
||||
<option value="openai-compatible">API compatible OpenAI /v1/audio/speech (Kokoro, Piper, OpenAI, LocalAI…)</option>
|
||||
<select
|
||||
className="select"
|
||||
value={settings.speech.provider}
|
||||
onChange={(e) => {
|
||||
update({ speech: { provider: e.target.value as typeof settings.speech.provider } });
|
||||
void ensurePreferredVoice().then((m) => m && setTtsTest({ status: 'ok', message: m }));
|
||||
}}
|
||||
>
|
||||
<option value="edge">Microsoft Edge (voix neuronales, gratuit, en ligne, sans clé) — recommandé</option>
|
||||
<option value="local">Local dans l’application (Supertonic 3 / Kokoro / Piper via sherpa-onnx, hors ligne)</option>
|
||||
<option value="openai-compatible">API compatible OpenAI /v1/audio/speech (Qwen3-TTS, Kokoro, OpenAI, LocalAI…)</option>
|
||||
<option value="system">Voix système Windows</option>
|
||||
<option value="google-free">Google Translate (gratuit, en ligne)</option>
|
||||
<option value="google-free">Google Translate (gratuit, en ligne, voix féminine uniquement)</option>
|
||||
<option value="off">Désactivée</option>
|
||||
</select>
|
||||
</div>
|
||||
{settings.speech.provider === 'edge' && (
|
||||
<div className="field">
|
||||
<label>Voix Edge</label>
|
||||
<select className="select" value={settings.speech.edgeVoice ?? ''} onChange={(e) => update({ speech: { edgeVoice: e.target.value } })}>
|
||||
<option value="">Automatique ({resolveEdgeVoice('', settings.speech.language, settings.speech.voiceGender ?? 'male')})</option>
|
||||
{edgeVoices
|
||||
.filter((v) => v.locale.toLowerCase().startsWith(settings.speech.language.toLowerCase().split('-')[0]))
|
||||
.map((v) => (
|
||||
<option key={v.shortName} value={v.shortName}>{v.name} · {v.locale} · {v.gender === 'm' ? 'homme' : 'femme'}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="hint">Mêmes voix que la lecture à voix haute d’Edge : Henri, Denise, Rémy, Vivienne, Éloise (fr-FR), plus les voix canadiennes, suisses et belges. Aucune donnée locale ; chaque phrase est synthétisée en ligne.</span>
|
||||
</div>
|
||||
)}
|
||||
{settings.speech.provider === 'openai-compatible' && (
|
||||
<>
|
||||
<div className="field">
|
||||
@@ -424,7 +479,7 @@ export function SettingsDrawer({ onClose }: Props) {
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Voix</label>
|
||||
<input className="input" value={settings.speech.voice} placeholder="alloy, onyx, af_heart…" onChange={(e) => update({ speech: { voice: e.target.value } })} />
|
||||
<input className="input" value={settings.speech.voice} placeholder="onyx, nova, af_heart, Ryan (Qwen3-TTS)…" onChange={(e) => update({ speech: { voice: e.target.value } })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Clé API</label>
|
||||
@@ -444,7 +499,7 @@ export function SettingsDrawer({ onClose }: Props) {
|
||||
{settings.speech.provider === 'local' && (
|
||||
installedModels(voiceModels, 'tts').length === 0 ? (
|
||||
<div className="field">
|
||||
<span className="hint">Aucun modèle de voix installé. <a href="#" onClick={(e) => { e.preventDefault(); setSection('models'); }}>Téléchargez Kokoro dans « Modèles locaux »</a>.</span>
|
||||
<span className="hint">Aucun modèle de voix installé. <a href="#" onClick={(e) => { e.preventDefault(); setSection('models'); }}>Téléchargez Supertonic 3 dans « Modèles locaux »</a>.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid-2">
|
||||
@@ -452,7 +507,8 @@ export function SettingsDrawer({ onClose }: Props) {
|
||||
<label>Modèle local</label>
|
||||
<select className="select" value={settings.speech.localModel} onChange={(e) => {
|
||||
const m = voiceModels.find((x) => x.id === e.target.value);
|
||||
update({ speech: { localModel: e.target.value, localSpeaker: m?.speakers?.[0]?.id ?? 0 } });
|
||||
// Keep the preferred gender when switching models (Kokoro has no masculine French voice: it falls back to Siwis).
|
||||
update({ speech: { localModel: e.target.value, localSpeaker: m ? pickSpeaker(m, settings.speech.language, settings.speech.voiceGender ?? 'male') : 0 } });
|
||||
}}>
|
||||
{installedModels(voiceModels, 'tts').map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||
</select>
|
||||
|
||||
+55
-11
@@ -1,13 +1,18 @@
|
||||
/** Voice gender preference applied to every TTS provider (pure helpers, unit tested). */
|
||||
import type { VoiceModelStatus } from '../../shared/voice';
|
||||
import type { VoiceModelStatus, VoiceSpeaker } from '../../shared/voice';
|
||||
import { defaultEdgeVoice, edgeVoiceGender } from '../../shared/edgeTts';
|
||||
|
||||
export type VoiceGender = 'male' | 'female';
|
||||
|
||||
const MALE_HINTS = /\b(homme|male|masculin|paul|thomas|claude|henri|guillaume|mathieu|antoine|nicolas|denis|pierre|tom|adam|michael|eric|liam|george|lewis|daniel|fenrir|puck|onyx|echo|david|mark|richard|james|ryan|guy)\b/i;
|
||||
const FEMALE_HINTS = /\b(femme|female|f[ée]minin|hortense|julie|denise|eloise|am[ée]lie|audrey|siwis|jessica|heart|bella|sarah|nicole|sky|alloy|nova|shimmer|zira|aria|jenny|emma|isabella|sophie|charlotte|vivienne|coral|sage)\b/i;
|
||||
const MALE_HINTS =
|
||||
/\b(homme|male|masculin|paul|thomas|claude|henri|remy|rémy|gerard|guillaume|mathieu|antoine|nicolas|denis|pierre|tom|adam|michael|eric|liam|george|lewis|daniel|fenrir|puck|onyx|echo|david|mark|richard|james|ryan|guy|dylan|aiden|uncle fu|andrew|brian|fabrice|jean|thierry)\b/i;
|
||||
const FEMALE_HINTS =
|
||||
/\b(femme|female|f[ée]minin|hortense|julie|denise|eloise|vivienne|charline|sylvie|ariane|am[ée]lie|audrey|siwis|jessica|heart|bella|sarah|nicole|sky|alloy|nova|shimmer|zira|aria|jenny|emma|ava|isabella|sophie|charlotte|coral|sage|vivian|serena|sohee|ono anna)\b/i;
|
||||
|
||||
/** Best-effort gender from a voice or speaker label ("Piper Tom (homme, français)", "Microsoft Paul", "am_adam"). */
|
||||
/** Best-effort gender from a voice or speaker label ("Piper Tom (homme, français)", "Microsoft Paul", "am_adam", "fr-FR-HenriNeural"). */
|
||||
export function inferGender(name: string): VoiceGender | undefined {
|
||||
const edge = edgeVoiceGender(name);
|
||||
if (edge) return edge;
|
||||
const n = name.replace(/_/g, ' ');
|
||||
if (/^(am|bm|em|hm|im|jm|pm|zm)\b/i.test(n) || MALE_HINTS.test(n)) return 'male';
|
||||
if (/^(af|bf|ef|ff|hf|if|jf|pf|zf)\b/i.test(n) || FEMALE_HINTS.test(n)) return 'female';
|
||||
@@ -19,6 +24,13 @@ export function defaultOpenAiVoice(gender: VoiceGender): string {
|
||||
return gender === 'male' ? 'onyx' : 'nova';
|
||||
}
|
||||
|
||||
/** Edge voice to use: the explicit choice when it matches the gender, otherwise the language default. */
|
||||
export function resolveEdgeVoice(explicit: string, language: string, gender: VoiceGender): string {
|
||||
const chosen = explicit.trim();
|
||||
if (chosen && (edgeVoiceGender(chosen) ?? gender) === gender) return chosen;
|
||||
return defaultEdgeVoice(language, gender);
|
||||
}
|
||||
|
||||
/** Rank system voices: language first, then gender, then quality hints. */
|
||||
export function rankSystemVoice(v: { name: string; lang: string; localService?: boolean }, lang: string, gender: VoiceGender): number {
|
||||
const name = v.name.toLowerCase();
|
||||
@@ -38,16 +50,39 @@ export interface LocalVoiceChoice {
|
||||
speaker: number;
|
||||
}
|
||||
|
||||
/** Installed local voice matching language + gender, or null. */
|
||||
/** Local models from best to worst French rendering; unknown models come last. */
|
||||
const LOCAL_QUALITY: Record<string, number> = { 'supertonic-3': 0, 'kokoro-v1': 1, 'piper-fr-upmc': 2, 'piper-fr-tom': 3, 'piper-fr-siwis': 4 };
|
||||
|
||||
export function localQualityRank(modelId: string): number {
|
||||
return LOCAL_QUALITY[modelId] ?? 9;
|
||||
}
|
||||
|
||||
function speakerGender(sp: VoiceSpeaker): VoiceGender | undefined {
|
||||
return sp.gender === 'm' ? 'male' : sp.gender === 'f' ? 'female' : inferGender(sp.name);
|
||||
}
|
||||
|
||||
function speakerSpeaks(sp: VoiceSpeaker, lang: string): boolean {
|
||||
const spLang = (sp.lang || '').toLowerCase();
|
||||
return !spLang || spLang === lang || spLang === 'multi';
|
||||
}
|
||||
|
||||
/** First speaker of a model matching language + gender; falls back to any speaker of the language, then the first one. */
|
||||
export function pickSpeaker(model: Pick<VoiceModelStatus, 'speakers'>, lang: string, gender: VoiceGender): number {
|
||||
const l = lang.toLowerCase().split('-')[0];
|
||||
const speakers = model.speakers ?? [];
|
||||
const exact = speakers.find((sp) => speakerSpeaks(sp, l) && speakerGender(sp) === gender);
|
||||
const sameLang = speakers.find((sp) => speakerSpeaks(sp, l));
|
||||
return (exact ?? sameLang ?? speakers[0])?.id ?? 0;
|
||||
}
|
||||
|
||||
/** Installed local voice matching language + gender (best model first), or null. */
|
||||
export function findLocalVoice(models: VoiceModelStatus[], lang: string, gender: VoiceGender): LocalVoiceChoice | null {
|
||||
const l = lang.toLowerCase().split('-')[0];
|
||||
const installed = models.filter((m) => m.kind === 'tts' && m.installed);
|
||||
const installed = models.filter((m) => m.kind === 'tts' && m.installed).sort((a, b) => localQualityRank(a.id) - localQualityRank(b.id));
|
||||
for (const model of installed) {
|
||||
for (const sp of model.speakers ?? []) {
|
||||
const spLang = (sp.lang || '').toLowerCase();
|
||||
if (spLang && spLang !== l && spLang !== 'multi') continue;
|
||||
const g = (sp as { gender?: string }).gender === 'm' ? 'male' : (sp as { gender?: string }).gender === 'f' ? 'female' : inferGender(sp.name);
|
||||
if (g === gender) return { modelId: model.id, speaker: sp.id };
|
||||
if (!speakerSpeaks(sp, l)) continue;
|
||||
if (speakerGender(sp) === gender) return { modelId: model.id, speaker: sp.id };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -57,10 +92,19 @@ export function findLocalVoice(models: VoiceModelStatus[], lang: string, gender:
|
||||
export function suggestedDownload(models: VoiceModelStatus[], lang: string, gender: VoiceGender): string | null {
|
||||
const l = lang.toLowerCase().split('-')[0];
|
||||
if (l !== 'fr') return null;
|
||||
const wanted = gender === 'male' ? ['piper-fr-tom', 'piper-fr-upmc'] : ['kokoro-v1', 'piper-fr-siwis'];
|
||||
const wanted = gender === 'male' ? ['supertonic-3', 'piper-fr-tom', 'piper-fr-upmc'] : ['supertonic-3', 'kokoro-v1', 'piper-fr-siwis'];
|
||||
for (const id of wanted) {
|
||||
const m = models.find((x) => x.id === id);
|
||||
if (m && !m.installed) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Best local model in the catalog for the language that is not installed yet (null when the best is already there). */
|
||||
export function bestLocalUpgrade(models: VoiceModelStatus[], lang: string): string | null {
|
||||
const l = lang.toLowerCase().split('-')[0];
|
||||
const best = models
|
||||
.filter((m) => m.kind === 'tts' && (m.languages.includes(l) || m.languages.includes('multi')))
|
||||
.sort((a, b) => localQualityRank(a.id) - localQualityRank(b.id))[0];
|
||||
return best && !best.installed ? best.id : null;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface VoiceSettings extends SttConfig {
|
||||
neuralVad: boolean;
|
||||
/** Execute short system intents locally (lock, volume, open app…) instead of asking Hermes. */
|
||||
localCommands: boolean;
|
||||
/** Chromium mic processing (echo cancellation, noise suppression, auto gain). Off often transcribes better on a headset. */
|
||||
micProcessing: boolean;
|
||||
}
|
||||
|
||||
export interface SpeechSettings extends TtsConfig {
|
||||
@@ -110,10 +112,12 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
kwsSensitivity: 3,
|
||||
neuralVad: true,
|
||||
localCommands: true,
|
||||
micProcessing: true,
|
||||
localModel: 'whisper-base'
|
||||
},
|
||||
speech: {
|
||||
provider: 'openai-compatible',
|
||||
// Edge neural voices speak out of the box (no server, no key) with a real masculine/feminine choice.
|
||||
provider: 'edge',
|
||||
apiUrl: 'http://127.0.0.1:8000/v1',
|
||||
apiKey: '',
|
||||
model: 'tts-1',
|
||||
@@ -123,8 +127,9 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
systemVoice: '',
|
||||
language: 'fr-FR',
|
||||
volume: 1,
|
||||
localModel: 'kokoro-v1',
|
||||
localSpeaker: 30,
|
||||
localModel: 'supertonic-3',
|
||||
localSpeaker: 6,
|
||||
edgeVoice: '',
|
||||
voiceGender: 'male',
|
||||
timbre: 'jarvis',
|
||||
autoSpeak: true,
|
||||
|
||||
Reference in new issue
Block a user