Files
EveFlow/src/services/voice/capture.ts
T
Claude 32551ccc0d 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
2026-09-04 15:37:00 +00:00

290 lines
9.8 KiB
TypeScript

/**
* Microphone capture built on AudioWorklet (no deprecated ScriptProcessor, no mic monitoring
* feedback). Produces 16 kHz WAV utterances, with energy VAD for hands-free auto stop.
*/
import { Log } from '../../lib/log';
import { audioBus } from './audioBus';
import { DEFAULT_VAD, EnergyVad, type VadOptions, type VadSignal } from './vad';
import { buildWav16k, rms, type WavResult } from './wav';
const WORKLET_SOURCE = `
class EveFlowCaptureProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.buffer = new Float32Array(2048);
this.offset = 0;
}
process(inputs) {
const input = inputs[0];
if (!input || input.length === 0) return true;
const channel = input[0];
if (!channel) return true;
let i = 0;
while (i < channel.length) {
const n = Math.min(channel.length - i, this.buffer.length - this.offset);
this.buffer.set(channel.subarray(i, i + n), this.offset);
this.offset += n;
i += n;
if (this.offset === this.buffer.length) {
this.port.postMessage(this.buffer, [this.buffer.buffer]);
this.buffer = new Float32Array(2048);
this.offset = 0;
}
}
return true;
}
}
registerProcessor('eveflow-capture', EveFlowCaptureProcessor);
`;
export type CaptureMode = 'auto' | 'manual';
export interface CaptureCallbacks {
onLevel?: (level: number) => void;
onSpeechStart?: () => void;
onUtterance: (wav: WavResult) => void;
onEnd: (reason: 'stopped' | 'speech-end' | 'no-speech' | 'too-short' | 'max-length' | 'error') => void;
onError?: (message: string) => void;
}
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;
}
export interface MicDevice {
deviceId: string;
label: string;
}
let workletUrl: string | null = null;
function getWorkletUrl(): string {
if (!workletUrl) workletUrl = URL.createObjectURL(new Blob([WORKLET_SOURCE], { type: 'application/javascript' }));
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();
return devices
.filter((d) => d.kind === 'audioinput')
.map((d, i) => ({ deviceId: d.deviceId, label: d.label || `Microphone ${i + 1}` }));
} catch {
return [];
}
}
export class MicCapture {
private ctx: AudioContext | null = null;
private stream: MediaStream | null = null;
private worklet: AudioWorkletNode | null = null;
private legacy: ScriptProcessorNode | null = null;
private chunks: Float32Array[] = [];
private totalSamples = 0;
private vad: EnergyVad | null = null;
private active = false;
private options: CaptureOptions | null = null;
private sampleRate = 16_000;
private hadSpeech = false;
get isActive(): boolean {
return this.active;
}
async start(options: CaptureOptions): Promise<void> {
if (this.active) this.stop('stopped');
this.options = options;
this.chunks = [];
this.totalSamples = 0;
this.hadSpeech = false;
this.vad = options.mode === 'auto' ? new EnergyVad(options.vad ?? DEFAULT_VAD) : null;
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error("Le microphone n'est pas accessible dans cet environnement (getUserMedia indisponible).");
}
const constraints: MediaStreamConstraints = {
audio: {
deviceId: options.deviceId ? { exact: options.deviceId } : undefined,
echoCancellation: options.micProcessing !== false,
noiseSuppression: options.micProcessing !== false,
autoGainControl: options.micProcessing !== false,
channelCount: 1
}
};
try {
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
} catch (err) {
const e = err as DOMException;
const detail =
e.name === 'NotAllowedError'
? "Accès au micro refusé. Vérifiez les paramètres de confidentialité Windows (Microphone) et relancez EveFlow."
: e.name === 'NotFoundError'
? 'Aucun microphone détecté.'
: e.name === 'NotReadableError'
? 'Le microphone est utilisé par une autre application.'
: `${e.name}: ${e.message}`;
Log.error('mic', 'getUserMedia failed', { name: e.name, message: e.message });
throw new Error(detail);
}
try {
this.ctx = new AudioContext({ sampleRate: 16_000, latencyHint: 'interactive' });
} catch {
this.ctx = new AudioContext({ latencyHint: 'interactive' });
}
if (this.ctx.state === 'suspended') await this.ctx.resume().catch(() => undefined);
this.sampleRate = this.ctx.sampleRate;
const source = this.ctx.createMediaStreamSource(this.stream);
const analyser = this.ctx.createAnalyser();
analyser.fftSize = 512;
analyser.smoothingTimeConstant = 0.5;
source.connect(analyser);
audioBus.setInputAnalyser(analyser);
try {
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);
} catch (err) {
Log.warn('mic', `AudioWorklet unavailable, falling back to ScriptProcessor: ${(err as Error).message}`);
const processor = this.ctx.createScriptProcessor(2048, 1, 1);
processor.onaudioprocess = (e) => this.onSamples(new Float32Array(e.inputBuffer.getChannelData(0)));
const mute = this.ctx.createGain();
mute.gain.value = 0; // Chrome requires a destination path, but we never want to hear the mic.
source.connect(processor);
processor.connect(mute);
mute.connect(this.ctx.destination);
this.legacy = processor;
}
this.active = true;
Log.info('mic', `capture started (${this.sampleRate} Hz, mode=${options.mode})`);
}
private onSamples(samples: Float32Array): void {
if (!this.active || !this.options) return;
this.chunks.push(samples);
this.totalSamples += samples.length;
// Before speech starts only a short pre-roll (~400 ms) is kept, so silence is never shipped to STT.
if (this.vad && !this.hadSpeech) {
while (this.chunks.length > 1 && this.totalSamples - this.chunks[0].length > this.sampleRate * 0.4) {
this.totalSamples -= this.chunks.shift()!.length;
}
}
const level = rms(samples);
this.options.callbacks.onLevel?.(Math.min(1, level * 6));
if (this.vad) {
const frameMs = (samples.length / this.sampleRate) * 1000;
const signal = this.vad.feed(level, frameMs);
this.handleVad(signal);
} else if (this.totalSamples / this.sampleRate > 120) {
this.finishUtterance('max-length');
}
}
private handleVad(signal: VadSignal): void {
switch (signal) {
case 'speech-start':
this.hadSpeech = true;
this.options?.callbacks.onSpeechStart?.();
break;
case 'speech-end':
case 'max-length':
this.finishUtterance(signal);
break;
case 'too-short':
case 'no-speech':
this.stop(signal);
break;
default:
break;
}
}
private finishUtterance(reason: 'speech-end' | 'max-length' | 'stopped'): void {
const opts = this.options;
const chunks = this.chunks;
const rate = this.sampleRate;
this.teardown();
if (!opts) return;
if (chunks.length === 0) {
opts.callbacks.onEnd('no-speech');
return;
}
const wav = buildWav16k(chunks, rate);
Log.info('mic', `utterance captured: ${wav.durationSec.toFixed(2)}s, ${wav.bytes.length} bytes`);
if (wav.durationSec < 0.25) {
opts.callbacks.onEnd('too-short');
return;
}
opts.callbacks.onUtterance(wav);
opts.callbacks.onEnd(reason);
}
/** Stop capture. In manual mode this finalises the utterance; otherwise reports the reason. */
stop(reason: 'stopped' | 'no-speech' | 'too-short' = 'stopped'): void {
if (!this.active) return;
// Manual stop: transcribe only if something was actually said (or no VAD is running).
if (reason === 'stopped' && (!this.vad || this.hadSpeech)) {
this.finishUtterance('stopped');
return;
}
if (reason === 'stopped') reason = 'no-speech';
const opts = this.options;
this.teardown();
opts?.callbacks.onEnd(reason);
}
/** Abort without producing an utterance. */
cancel(): void {
if (!this.active) return;
this.teardown();
this.options?.callbacks.onEnd('stopped');
}
private teardown(): void {
this.active = false;
audioBus.setInputAnalyser(null);
if (this.worklet) {
this.worklet.port.onmessage = null;
this.worklet.disconnect();
this.worklet = null;
}
if (this.legacy) {
this.legacy.onaudioprocess = null;
this.legacy.disconnect();
this.legacy = null;
}
this.stream?.getTracks().forEach((t) => t.stop());
this.stream = null;
void this.ctx?.close().catch(() => undefined);
this.ctx = null;
this.chunks = [];
this.totalSamples = 0;
}
}