mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-18 12:49:05 +02:00
feat: voix JARVIS, Parakeet v3 pour le français, worklets audio sous CSP stricte (v2.4.1) (#19)
* feat: voix JARVIS, Parakeet v3 pour le français, worklets audio sous CSP stricte (v2.4.1) Voix - Timbre « JARVIS » (Web Audio) : hauteur légèrement abaissée, chaleur dans les basses, présence, compression douce, courte réverbération d'intercom. Activé par défaut, réglable dans Paramètres → Voix. - Préréglage « Voix JARVIS » en un clic : moteur local, voix masculine française (Piper Tom téléchargé automatiquement, Kokoro n'ayant pas de voix française masculine), débit calme. Affichage de la voix active. - Boutons Masculine / Féminine désormais lisibles (style du sélecteur ajouté). Reconnaissance - Parakeet TDT 0.6B v3 (NVIDIA NeMo, int8, 25 langues européennes dont le français) ajouté au catalogue et recommandé : plus précis et plus rapide que Whisper sur processeur, ponctuation incluse. - 0,4 s de silence ajoutées avant et après chaque énoncé avant la reconnaissance (syllabes coupées, hallucinations de Whisper sur les clips courts). Écoute permanente - Les modules AudioWorklet sont livrés en fichiers statiques (public/worklets) chargés depuis l'application : en version installée, la CSP stricte (script-src 'self') refusait les URL blob et l'écoute permanente échouait avec « Unable to load a worklet's module ». Repli blob conservé pour le dev. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y * docs: mesures Parakeet vs Whisper et worklets dans la feuille de route Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
d1b12c16c5
commit
5ef440e8cf
13 files changed
+221
-11
No files matched your search
@@ -374,7 +374,32 @@ 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).</span>
|
||||
<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>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Timbre</label>
|
||||
<div className="segmented">
|
||||
<button className={settings.speech.timbre === 'jarvis' ? 'active' : ''} onClick={() => update({ speech: { timbre: 'jarvis' } })}>JARVIS</button>
|
||||
<button className={settings.speech.timbre !== 'jarvis' ? 'active' : ''} onClick={() => update({ speech: { timbre: 'natural' } })}>Naturel</button>
|
||||
</div>
|
||||
<span className="hint">JARVIS : voix légèrement plus grave et posée, chaleur dans les basses, présence, courte réverbération d’intercom, comme dans le film.</span>
|
||||
</div>
|
||||
<div className="row" style={{ marginBottom: 10 }}>
|
||||
<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)…' });
|
||||
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)
|
||||
</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}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Moteur de synthèse</label>
|
||||
|
||||
@@ -66,6 +66,21 @@ function getWorkletUrl(): string {
|
||||
return workletUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an AudioWorklet module: first the static file shipped with the app (allowed by the strict
|
||||
* CSP, script-src 'self'), then a blob URL for dev servers that do not serve /worklets.
|
||||
*/
|
||||
export async function loadWorklet(ctx: AudioContext, name: string, blobUrl: () => string): Promise<void> {
|
||||
const staticUrl = new URL(`worklets/${name}.js`, document.baseURI).href;
|
||||
try {
|
||||
await ctx.audioWorklet.addModule(staticUrl);
|
||||
return;
|
||||
} catch (err) {
|
||||
Log.debug('audio', `static worklet ${name} unavailable (${(err as Error).message}), trying blob`);
|
||||
}
|
||||
await ctx.audioWorklet.addModule(blobUrl());
|
||||
}
|
||||
|
||||
export async function listMicrophones(): Promise<MicDevice[]> {
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
@@ -147,7 +162,7 @@ export class MicCapture {
|
||||
audioBus.setInputAnalyser(analyser);
|
||||
|
||||
try {
|
||||
await this.ctx.audioWorklet.addModule(getWorkletUrl());
|
||||
await loadWorklet(this.ctx, 'eveflow-capture', getWorkletUrl);
|
||||
this.worklet = new AudioWorkletNode(this.ctx, 'eveflow-capture', { numberOfInputs: 1, numberOfOutputs: 0, channelCount: 1 });
|
||||
this.worklet.port.onmessage = (event: MessageEvent<Float32Array>) => this.onSamples(event.data);
|
||||
source.connect(this.worklet);
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface TtsConfig {
|
||||
localSpeaker: number;
|
||||
/** 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. */
|
||||
timbre?: 'natural' | 'jarvis';
|
||||
}
|
||||
|
||||
export type TtsState = 'idle' | 'loading' | 'speaking';
|
||||
@@ -255,7 +257,13 @@ export class TtsEngine {
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
if (this.config.provider === 'google-free') source.playbackRate.value = Math.max(0.5, Math.min(2, this.config.speed || 1));
|
||||
source.connect(audioBus.output);
|
||||
if (this.config.timbre === 'jarvis') {
|
||||
// Slightly lower and calmer, warm low end, crisp presence, a touch of room: the film's intercom feel.
|
||||
source.playbackRate.value *= 0.94;
|
||||
source.connect(jarvisChain(ctx));
|
||||
} else {
|
||||
source.connect(audioBus.output);
|
||||
}
|
||||
const finish = () => {
|
||||
if (this.cancelCurrent === cancel) this.cancelCurrent = null;
|
||||
resolve();
|
||||
@@ -334,3 +342,60 @@ export function listSystemVoices(): SpeechSynthesisVoice[] {
|
||||
if (typeof speechSynthesis === 'undefined') return [];
|
||||
return [...speechSynthesis.getVoices()].sort((a, b) => a.lang.localeCompare(b.lang) || scoreVoice(b) - scoreVoice(a));
|
||||
}
|
||||
|
||||
let jarvisInput: AudioNode | null = null;
|
||||
let jarvisCtx: AudioContext | null = null;
|
||||
|
||||
/** Shared effect chain for the JARVIS timbre (built once per AudioContext). */
|
||||
function jarvisChain(ctx: AudioContext): AudioNode {
|
||||
if (jarvisInput && jarvisCtx === ctx) return jarvisInput;
|
||||
const input = ctx.createGain();
|
||||
const warmth = ctx.createBiquadFilter();
|
||||
warmth.type = 'lowshelf';
|
||||
warmth.frequency.value = 180;
|
||||
warmth.gain.value = 3.5;
|
||||
const presence = ctx.createBiquadFilter();
|
||||
presence.type = 'peaking';
|
||||
presence.frequency.value = 3200;
|
||||
presence.Q.value = 0.9;
|
||||
presence.gain.value = 2.5;
|
||||
const air = ctx.createBiquadFilter();
|
||||
air.type = 'highshelf';
|
||||
air.frequency.value = 7000;
|
||||
air.gain.value = -2;
|
||||
const comp = ctx.createDynamicsCompressor();
|
||||
comp.threshold.value = -20;
|
||||
comp.ratio.value = 3;
|
||||
comp.attack.value = 0.005;
|
||||
comp.release.value = 0.12;
|
||||
const dry = ctx.createGain();
|
||||
dry.gain.value = 0.86;
|
||||
const wet = ctx.createGain();
|
||||
wet.gain.value = 0.14;
|
||||
const reverb = ctx.createConvolver();
|
||||
reverb.buffer = impulse(ctx, 0.22, 3.2);
|
||||
input.connect(warmth);
|
||||
warmth.connect(presence);
|
||||
presence.connect(air);
|
||||
air.connect(comp);
|
||||
comp.connect(dry);
|
||||
comp.connect(reverb);
|
||||
reverb.connect(wet);
|
||||
dry.connect(audioBus.output);
|
||||
wet.connect(audioBus.output);
|
||||
jarvisInput = input;
|
||||
jarvisCtx = ctx;
|
||||
return input;
|
||||
}
|
||||
|
||||
/** Short synthetic room impulse (exponentially decaying noise). */
|
||||
function impulse(ctx: AudioContext, seconds: number, decay: number): AudioBuffer {
|
||||
const rate = ctx.sampleRate;
|
||||
const length = Math.max(1, Math.floor(rate * seconds));
|
||||
const buffer = ctx.createBuffer(2, length, rate);
|
||||
for (let ch = 0; ch < 2; ch++) {
|
||||
const data = buffer.getChannelData(ch);
|
||||
for (let i = 0; i < length; i++) data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, decay);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
import { bridge } from '../../lib/bridge';
|
||||
import { Log } from '../../lib/log';
|
||||
import { audioBus } from './audioBus';
|
||||
import { loadWorklet } from './capture';
|
||||
import { DEFAULT_VAD, EnergyVad, type VadOptions } from './vad';
|
||||
import { buildWav16k, rms, type WavResult } from './wav';
|
||||
import type { VadEvent } from '../../../shared/voice';
|
||||
@@ -103,8 +104,10 @@ export class WakeListener {
|
||||
analyser.fftSize = 512;
|
||||
source.connect(analyser);
|
||||
audioBus.setInputAnalyser(analyser);
|
||||
if (!workletUrl) workletUrl = URL.createObjectURL(new Blob([WORKLET_SOURCE], { type: 'application/javascript' }));
|
||||
await this.ctx.audioWorklet.addModule(workletUrl);
|
||||
await loadWorklet(this.ctx, 'eveflow-wake', () => {
|
||||
if (!workletUrl) workletUrl = URL.createObjectURL(new Blob([WORKLET_SOURCE], { type: 'application/javascript' }));
|
||||
return workletUrl;
|
||||
});
|
||||
this.node = new AudioWorkletNode(this.ctx, 'eveflow-wake', { numberOfInputs: 1, numberOfOutputs: 0, channelCount: 1 });
|
||||
this.node.port.onmessage = (event: MessageEvent<Float32Array>) => this.onSamples(event.data);
|
||||
source.connect(this.node);
|
||||
|
||||
@@ -126,6 +126,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
localModel: 'kokoro-v1',
|
||||
localSpeaker: 30,
|
||||
voiceGender: 'male',
|
||||
timbre: 'jarvis',
|
||||
autoSpeak: true,
|
||||
speakIncoming: true
|
||||
},
|
||||
|
||||
@@ -115,3 +115,35 @@
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* Segmented choice (voice gender, capture mode…) */
|
||||
.segmented {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.segmented button {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ink-1);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.segmented button + button {
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
.segmented button:hover {
|
||||
color: var(--ink-0);
|
||||
background: rgba(var(--accent-rgb), 0.08);
|
||||
}
|
||||
.segmented button.active {
|
||||
color: #03101c;
|
||||
background: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
Reference in new issue
Block a user