Files
EveFlow/src/services/voice/tts.ts
T
Claude 5f0fa7a1e2 fix: full review pass (main process, voice pipeline, Hermes client, HUD) and e2e-validated local voice (v2.1.0.1)
Electron main
- utility-process channel: sherpa output copied into V8 buffers (Electron rejects external
  buffers), audio exchanged as base64; worker restart on timeout, identity-safe exit handling,
  deterministic native unload by restart
- webhook: loopback by default without secret, UTF-8-safe body assembly, clean restart, port validation
- files IPC: realpath-based root check, openPath allow-list (reveal-only outside document folders),
  Windows reserved names; store: debounced async atomic writes with backup of corrupt files;
  logger: streaming writes with rotation; log message size cap
- HTTP stream proxy: socket released on idle timeout / renderer destroyed, id validation
- model manager: inactivity timeout, retrying rm/rename (Windows locks), engine stopped before
  replacing a model; WAV decoder handles float/24-bit; IPC payload validation
- window: opaque rounded window on Windows, navigation lock-down, visibility events;
  Ctrl+Alt+Escape instead of the Task Manager shortcut; single-instance guard; EVEFLOW_USER_DATA

Voice pipeline
- abort semantics (SendHandle.aborted), abort before the stream opens, session id prefixes per transport
- hands-free re-arm after replies without speech, start/stop race, no chime on auto re-arm,
  no silence shipped to STT (400 ms pre-roll), no transcription of empty manual stops
- TTS: bounded prefetch, cancellable segments, non-interrupting notices, volume applied at play time
- SSE CRLF split, usage in chat completions, finish_reason length, phonetic regex hoisted

HUD
- core renderer: no canvas shadows, cached colours, reusable spectrum buffer, theme read on change,
  30 fps idle, stops when the window is hidden; ping flashes on send / tool / speech
- deltas coalesced per animation frame; stable auto-scroll; narrow selectors everywhere
- bundled fonts (offline), reduce-motion fix, error toasts, ops drawer below 1180 px,
  interim transcript and first-token latency in the core caption, Ctrl+K, dialog semantics,
  switch/aria roles, compact widget cleanup, Whisper small recommended for French
- docs/ROADMAP.md: audit results, e2e results and the plan towards a real JARVIS

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y
2026-09-03 18:08:09 +00:00

334 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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.
*/
import { Log } from '../../lib/log';
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 interface TtsConfig {
provider: TtsProvider;
apiUrl: string;
apiKey: string;
model: string;
voice: string;
speed: number; // 0.5 - 2
format: 'mp3' | 'wav' | 'opus';
systemVoice: string;
language: string;
volume: number;
/** Catalog id of the local sherpa-onnx voice model (provider 'local'). */
localModel: string;
/** Speaker id inside the local model. */
localSpeaker: number;
}
export type TtsState = 'idle' | 'loading' | 'speaking';
interface QueueItem {
text: string;
audio?: Promise<AudioBuffer | null>;
}
const LOOKAHEAD = 2;
export function ttsEndpoint(apiUrl: string): string {
const base = apiUrl.trim().replace(/\/+$/, '');
if (base.endsWith('/speech')) return base;
if (base.endsWith('/audio')) return `${base}/speech`;
if (/\/v\d+$/.test(base)) return `${base}/audio/speech`;
return `${base}/v1/audio/speech`;
}
export class TtsEngine {
private queue: QueueItem[] = [];
private playing = false;
private cancelCurrent: (() => void) | null = null;
private streamBuffer = '';
private generation = 0;
private config: TtsConfig;
private listeners = new Set<(state: TtsState) => void>();
private state: TtsState = 'idle';
constructor(config: TtsConfig) {
this.config = config;
}
updateConfig(config: TtsConfig): void {
this.config = config;
}
onState(listener: (state: TtsState) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
get isActive(): boolean {
return this.playing || this.queue.length > 0;
}
private setState(state: TtsState): void {
if (this.state === state) return;
this.state = state;
for (const l of this.listeners) l(state);
}
/**
* Speak a finished text. With `interrupt` (default) pending speech is discarded first;
* without it the text is inserted ahead of the queue and streamed speech resumes afterwards.
*/
speak(text: string, options: { interrupt?: boolean } = {}): void {
const interrupt = options.interrupt ?? true;
if (interrupt) this.stop();
const items = chunkForSpeech(text).map((chunk) => this.makeItem(chunk)).filter((i): i is QueueItem => !!i);
if (interrupt) this.queue.push(...items);
else this.queue.unshift(...items);
this.fillPrefetch();
void this.drain();
}
/** Feed streamed tokens; complete sentences are spoken as soon as they are available. */
pushStream(delta: string): void {
this.streamBuffer += delta;
const { sentences, rest } = extractSentences(this.streamBuffer);
this.streamBuffer = rest;
for (const sentence of sentences) this.enqueue(sentence);
}
/** Flush the remainder of a streamed message. */
endStream(): void {
const rest = this.streamBuffer.trim();
this.streamBuffer = '';
if (rest) for (const chunk of chunkForSpeech(rest)) this.enqueue(chunk);
}
enqueue(text: string): void {
const item = this.makeItem(text);
if (!item) return;
this.queue.push(item);
this.fillPrefetch();
void this.drain();
}
private makeItem(text: string): QueueItem | null {
if (this.config.provider === 'off') return null;
const clean = cleanForSpeech(text);
if (!clean || !/[\p{L}\p{N}]/u.test(clean)) return null;
return { text: clean };
}
/** Keep at most LOOKAHEAD synthesis requests in flight ahead of playback. */
private fillPrefetch(): void {
if (this.config.provider === 'system') return;
for (const item of this.queue.slice(0, LOOKAHEAD)) item.audio ??= this.prefetch(item.text, this.generation);
}
stop(): void {
this.generation++;
this.queue = [];
this.streamBuffer = '';
this.cancelCurrent?.();
this.cancelCurrent = null;
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel();
audioBus.setSyntheticLevel(0);
this.playing = false;
this.setState('idle');
}
private async drain(): Promise<void> {
if (this.playing) return;
this.playing = true;
const gen = this.generation;
while (this.queue.length > 0 && gen === this.generation) {
const item = this.queue.shift()!;
this.fillPrefetch();
try {
if (this.config.provider !== 'system') {
this.setState('loading');
const buffer = await (item.audio ?? this.prefetch(item.text, gen));
if (gen !== this.generation) break;
if (buffer) {
this.setState('speaking');
await this.playBuffer(buffer, gen);
} else {
this.setState('speaking');
await this.speakSystem(item.text, gen);
}
} else {
this.setState('speaking');
await this.speakSystem(item.text, gen);
}
} catch (err) {
Log.warn('tts', `segment failed: ${(err as Error).message}`);
}
}
if (gen === this.generation) {
this.playing = false;
this.setState('idle');
}
}
private prefetch(text: string, gen: number): Promise<AudioBuffer | null> {
const task =
this.config.provider === 'google-free' ? this.fetchGoogle(text) : this.config.provider === 'local' ? this.fetchLocal(text) : this.fetchOpenAi(text);
return task
.then(async (bytes) => {
if (gen !== this.generation || !bytes) return null;
await audioBus.resume();
const copy = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
return audioBus.context.decodeAudioData(copy);
})
.catch((err) => {
if (gen === this.generation) Log.warn('tts', `synthesis failed, falling back to system voice: ${(err as Error).message}`);
return null;
});
}
private async fetchOpenAi(text: string): Promise<Uint8Array> {
const { apiUrl, apiKey, model, voice, speed, format } = this.config;
if (!apiUrl.trim()) throw new Error('URL TTS non configurée');
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (apiKey.trim()) headers.Authorization = `Bearer ${apiKey.trim()}`;
const res = await httpFetch({
url: ttsEndpoint(apiUrl),
method: 'POST',
headers,
body: JSON.stringify({
model: model || 'tts-1',
input: text,
voice: voice || 'alloy',
speed: Math.max(0.5, Math.min(2, speed || 1)),
response_format: format || 'mp3'
}),
responseType: 'binary',
timeoutMs: 60_000
});
if (!res.ok || !res.binary) {
const body = res.binary ? new TextDecoder().decode(res.binary).slice(0, 200) : '';
throw new Error(`HTTP ${res.status} ${body}`);
}
return res.binary;
}
private async fetchLocal(text: string): Promise<Uint8Array> {
const api = bridge();
if (!api) throw new Error('La synthèse locale nécessite l’application Electron.');
if (!this.config.localModel) throw new Error('Aucun modèle de voix local sélectionné.');
const result = await api.voice.synthesize({
modelId: this.config.localModel,
text,
speaker: this.config.localSpeaker,
speed: Math.max(0.5, Math.min(2, this.config.speed || 1))
});
Log.debug('tts', `local synthesis ${result.durationMs} ms for ${result.audioSec.toFixed(1)}s of audio`);
return result.wav;
}
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({
url,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
Referer: 'https://translate.google.com/'
},
responseType: 'binary',
timeoutMs: 15_000
});
if (!res.ok || !res.binary) throw new Error(`Google TTS HTTP ${res.status}`);
return res.binary;
}
private playBuffer(buffer: AudioBuffer, gen: number): Promise<void> {
return new Promise((resolve) => {
if (gen !== this.generation) return resolve();
const ctx = audioBus.context;
audioBus.setVolume(this.config.volume);
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);
const finish = () => {
if (this.cancelCurrent === cancel) this.cancelCurrent = null;
resolve();
};
const cancel = () => {
source.onended = null;
try {
source.stop();
} catch {
/* already stopped */
}
finish();
};
source.onended = finish;
this.cancelCurrent = cancel;
source.start();
});
}
private speakSystem(text: string, gen: number): Promise<void> {
return new Promise((resolve) => {
if (typeof speechSynthesis === 'undefined' || gen !== this.generation) return resolve();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = this.config.language || 'fr-FR';
utterance.rate = Math.max(0.5, Math.min(2, this.config.speed || 1));
utterance.pitch = 1;
utterance.volume = Math.max(0, Math.min(1, this.config.volume));
const voice = this.resolveSystemVoice();
if (voice) utterance.voice = voice;
let pulse: ReturnType<typeof setInterval> | null = setInterval(() => {
audioBus.setSyntheticLevel(0.35 + Math.random() * 0.4);
}, 90);
const finish = () => {
if (pulse) clearInterval(pulse);
pulse = null;
audioBus.setSyntheticLevel(0);
if (this.cancelCurrent === cancel) this.cancelCurrent = null;
resolve();
};
const cancel = () => {
utterance.onend = null;
utterance.onerror = null;
finish();
};
utterance.onend = finish;
utterance.onerror = finish;
this.cancelCurrent = cancel;
speechSynthesis.speak(utterance);
});
}
private resolveSystemVoice(): SpeechSynthesisVoice | undefined {
if (typeof speechSynthesis === 'undefined') return undefined;
const voices = speechSynthesis.getVoices();
if (this.config.systemVoice) {
const exact = voices.find((v) => v.name === this.config.systemVoice);
if (exact) return exact;
}
const lang = (this.config.language || 'fr').toLowerCase().split('-')[0];
const candidates = voices.filter((v) => v.lang.toLowerCase().startsWith(lang));
return candidates.sort((a, b) => scoreVoice(b) - scoreVoice(a))[0] ?? voices[0];
}
}
export function scoreVoice(v: SpeechSynthesisVoice): number {
const name = v.name.toLowerCase();
let score = 0;
if (name.includes('natural') || name.includes('neural') || name.includes('online')) score += 40;
if (name.includes('google')) score += 30;
if (name.includes('microsoft')) score += 10;
if (v.localService) score += 5;
return score;
}
export function listSystemVoices(): SpeechSynthesisVoice[] {
if (typeof speechSynthesis === 'undefined') return [];
return [...speechSynthesis.getVoices()].sort((a, b) => a.lang.localeCompare(b.lang) || scoreVoice(b) - scoreVoice(a));
}