mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-17 12:18:29 +02:00
feat: local offline voice models (sherpa-onnx) with model manager and wake word (v2.1.0)
- electron/voice: model catalog (Whisper base/small/turbo, SenseVoice, Kokoro v1.0, Piper fr), streaming tar.bz2 downloader with progress, sherpa-onnx worker running in an Electron utilityProcess (transcribe / synthesize / status / unload), IPC + bridge. - Renderer: 'local' providers for STT and TTS, Settings → Modèles locaux (download, progress, delete, activate), speaker selection, hands-free wake word with a tolerant matcher (accents, punctuation, edit distance) and attention window after a bare wake word. - Packaging: native addon and worker unpacked from the asar; release 2.1.0. Validated on Linux: Kokoro (fr) → Whisper base round trip, Piper (fr) → Whisper round trip. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y
This commit is contained in:
22 files changed
+1361
-16
No files matched your search
@@ -1,7 +1,8 @@
|
||||
import { Log } from '../../lib/log';
|
||||
import { bridge } from '../../lib/bridge';
|
||||
import { httpFetch } from '../../lib/transport';
|
||||
|
||||
export type SttProvider = 'openai-compatible' | 'browser';
|
||||
export type SttProvider = 'openai-compatible' | 'browser' | 'local';
|
||||
|
||||
export interface SttConfig {
|
||||
provider: SttProvider;
|
||||
@@ -10,6 +11,8 @@ export interface SttConfig {
|
||||
model: string;
|
||||
language: string; // BCP-47, e.g. fr-FR
|
||||
prompt?: string;
|
||||
/** Catalog id of the local sherpa-onnx model (provider 'local'). */
|
||||
localModel: string;
|
||||
}
|
||||
|
||||
export function sttEndpoint(apiUrl: string): string {
|
||||
@@ -20,8 +23,20 @@ export function sttEndpoint(apiUrl: string): string {
|
||||
return `${base}/v1/audio/transcriptions`;
|
||||
}
|
||||
|
||||
/** Transcribe a WAV buffer through any OpenAI-compatible `/v1/audio/transcriptions` endpoint. */
|
||||
/** Transcribe with the in-app sherpa-onnx engine (runs in the Electron main process). */
|
||||
export async function transcribeLocal(wav: Uint8Array, config: SttConfig): Promise<string> {
|
||||
const api = bridge();
|
||||
if (!api) throw new Error('La reconnaissance locale nécessite l’application Electron.');
|
||||
if (!config.localModel) throw new Error('Aucun modèle de reconnaissance local sélectionné.');
|
||||
const started = Date.now();
|
||||
const result = await api.voice.transcribe({ modelId: config.localModel, wav, language: config.language.split('-')[0] || 'auto' });
|
||||
Log.info('stt', `local transcription in ${Date.now() - started} ms (engine ${result.durationMs} ms, audio ${result.audioSec.toFixed(1)}s)`);
|
||||
return result.text.trim();
|
||||
}
|
||||
|
||||
/** Transcribe a WAV buffer with the configured provider. */
|
||||
export async function transcribeWav(wav: Uint8Array, config: SttConfig): Promise<string> {
|
||||
if (config.provider === 'local') return transcribeLocal(wav, config);
|
||||
if (!config.apiUrl.trim()) throw new Error("URL de l'API de transcription non configurée.");
|
||||
const endpoint = sttEndpoint(config.apiUrl);
|
||||
const fields: Record<string, string> = {
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
* and the legacy Google Translate endpoint (no key, online only).
|
||||
*/
|
||||
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' | 'off';
|
||||
export type TtsProvider = 'openai-compatible' | 'system' | 'google-free' | 'local' | 'off';
|
||||
|
||||
export interface TtsConfig {
|
||||
provider: TtsProvider;
|
||||
@@ -21,6 +22,10 @@ export interface TtsConfig {
|
||||
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';
|
||||
@@ -159,7 +164,8 @@ export class TtsEngine {
|
||||
}
|
||||
|
||||
private prefetch(text: string, gen: number): Promise<AudioBuffer | null> {
|
||||
const task = this.config.provider === 'google-free' ? this.fetchGoogle(text) : this.fetchOpenAi(text);
|
||||
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;
|
||||
@@ -199,6 +205,20 @@ export class TtsEngine {
|
||||
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({
|
||||
|
||||
@@ -15,8 +15,39 @@ import type { WavResult } from './wav';
|
||||
const SENSITIVITY_RATIO: Record<number, number> = { 1: 4.5, 2: 3.4, 3: 2.6, 4: 2.0, 5: 1.6 };
|
||||
const SENSITIVITY_MIN_RMS: Record<number, number> = { 1: 0.03, 2: 0.02, 3: 0.012, 4: 0.008, 5: 0.005 };
|
||||
|
||||
/** Levenshtein distance, used to tolerate transcription slips in the wake word ("javis" for "jarvis"). */
|
||||
function editDistance(a: string, b: string): number {
|
||||
const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...new Array<number>(b.length).fill(0)]);
|
||||
for (let j = 1; j <= b.length; j++) dp[0][j] = j;
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
||||
}
|
||||
}
|
||||
return dp[a.length][b.length];
|
||||
}
|
||||
|
||||
/** Compare the start of an utterance with the wake word, tolerant to accents, case, punctuation and small slips. */
|
||||
export function matchWakeWord(text: string, wakeWord: string): { matched: boolean; rest: string } {
|
||||
const norm = (v: string) => v.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
||||
const target = norm(wakeWord).split(/[\s\p{P}]+/u).filter(Boolean);
|
||||
if (target.length === 0) return { matched: true, rest: text.trim() };
|
||||
const original = text.trim().replace(/^[\s\p{P}]+/u, '');
|
||||
const words = original.split(/\s+/);
|
||||
const head = words.slice(0, target.length).map((w) => norm(w).replace(/[\p{P}]+/gu, ''));
|
||||
if (head.length < target.length) return { matched: false, rest: '' };
|
||||
const joinedHead = head.join(' ');
|
||||
const joinedTarget = target.join(' ');
|
||||
const tolerance = joinedTarget.length >= 6 ? 2 : joinedTarget.length >= 4 ? 1 : 0;
|
||||
if (editDistance(joinedHead, joinedTarget) > tolerance) return { matched: false, rest: '' };
|
||||
const rest = words.slice(target.length).join(' ').replace(/^[\s\p{P}]+/u, '');
|
||||
return { matched: true, rest };
|
||||
}
|
||||
|
||||
class VoiceController {
|
||||
private capture = new MicCapture();
|
||||
/** After a bare wake word, the next utterance is accepted without the wake word. */
|
||||
private attentionUntil = 0;
|
||||
private browser = new BrowserRecognizer();
|
||||
private handsFreeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private stopping = false;
|
||||
@@ -169,10 +200,28 @@ class VoiceController {
|
||||
this.playChime(false);
|
||||
const settings = useSettings.getState().settings.voice;
|
||||
try {
|
||||
const text = await transcribeWav(wav.bytes, settings);
|
||||
let text = await transcribeWav(wav.bytes, settings);
|
||||
voice.setTranscript(text);
|
||||
voice.setPhase('off');
|
||||
Log.info('voice', `transcript (${wav.durationSec.toFixed(1)}s): ${text}`);
|
||||
if (voice.handsFree && settings.wakeWordEnabled && Date.now() > this.attentionUntil) {
|
||||
const { matched, rest } = matchWakeWord(text, settings.wakeWord || 'jarvis');
|
||||
if (!matched) {
|
||||
Log.debug('voice', 'utterance ignored: no wake word');
|
||||
voice.setTranscript('');
|
||||
useChat.getState().setHud('idle');
|
||||
this.scheduleHandsFree(200);
|
||||
return;
|
||||
}
|
||||
if (!rest.trim()) {
|
||||
this.attentionUntil = Date.now() + 10_000;
|
||||
useChat.getState().setHud('listening');
|
||||
speech.say('Oui ?');
|
||||
return;
|
||||
}
|
||||
text = rest;
|
||||
}
|
||||
this.attentionUntil = 0;
|
||||
if (text.trim()) {
|
||||
await sendMessage(text, [], 'voice');
|
||||
} else {
|
||||
|
||||
Reference in new issue
Block a user