mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-19 05:09:30 +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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user