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:
LogiFlowandClaude Fable 5.1 authored and GitHub committed 2026-09-04 13:02:32 +02:00
1 parent d1b12c16c5
commit 5ef440e8cf
13 files changed
+221 -11

No files matched your search

+26 -1
View File
@@ -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>
+16 -1
View File
@@ -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);
+66 -1
View File
@@ -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;
}
+5 -2
View File
@@ -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);
+1
View File
@@ -126,6 +126,7 @@ export const DEFAULT_SETTINGS: Settings = {
localModel: 'kokoro-v1',
localSpeaker: 30,
voiceGender: 'male',
timbre: 'jarvis',
autoSpeak: true,
speakIncoming: true
},
+32
View File
@@ -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;
}