Files
EveFlow/src/state/settings.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

237 lines
8.3 KiB
TypeScript

import { create } from 'zustand';
import { persistGet, persistSet } from '../lib/persist';
import { uid } from '../lib/id';
import type { HermesConfig } from '../services/hermes/types';
import type { SttConfig } from '../services/voice/stt';
import type { TtsConfig } from '../services/voice/tts';
export type HudTheme = 'arc' | 'gold' | 'crimson' | 'emerald';
export interface VoiceSettings extends SttConfig {
/** auto = VAD auto-stop, manual = click to stop */
captureMode: 'auto' | 'manual';
handsFree: boolean;
bargeIn: boolean;
micDeviceId: string;
silenceMs: number;
sensitivity: number; // 1 (low) .. 5 (high)
wakeChime: boolean;
/** Hands-free: only react to utterances starting with this word (local STT recommended). */
wakeWordEnabled: boolean;
wakeWord: string;
/** off = push-to-talk / hands-free; transcript = filter after transcription; kws = always-on keyword spotting. */
wakeMode: 'off' | 'transcript' | 'kws';
kwsSensitivity: number; // 1..5
/** Use Silero VAD for end-of-speech in always-on mode when the model is installed. */
neuralVad: boolean;
/** Execute short system intents locally (lock, volume, open app…) instead of asking Hermes. */
localCommands: boolean;
/** Chromium mic processing (echo cancellation, noise suppression, auto gain). Off often transcribes better on a headset. */
micProcessing: boolean;
}
export interface SpeechSettings extends TtsConfig {
autoSpeak: boolean;
speakIncoming: boolean;
}
export interface WebhookSettings {
enabled: boolean;
port: number;
secret: string;
}
export interface NotificationSettings {
/** Quiet hours: no spoken pushes, no chime, dimmed HUD (24 h "HH:MM"). */
quietEnabled: boolean;
quietStart: string;
quietEnd: string;
/** Pushes whose text or job name contains one of these words are spoken even during quiet hours. */
priorityKeywords: string;
/** Speak only the first sentences of incoming pushes (cron reports can be long). */
summarizeIncoming: boolean;
summarySentences: number;
/** Dim the HUD (night theme) during quiet hours. */
nightTheme: boolean;
}
export interface Settings {
version: 2;
assistantName: string;
userName: string;
theme: HudTheme;
language: string;
hermes: HermesConfig;
voice: VoiceSettings;
speech: SpeechSettings;
webhook: WebhookSettings;
notifications: NotificationSettings;
ui: {
showTelemetry: boolean;
showReasoning: boolean;
reduceMotion: boolean;
compactOpacity: number;
};
hermesSessionId: string;
}
export const DEFAULT_SETTINGS: Settings = {
version: 2,
assistantName: 'JARVIS',
userName: '',
theme: 'arc',
language: 'fr-FR',
hermes: {
url: 'http://127.0.0.1:8642',
apiKey: '',
model: '',
sessionKey: 'eveflow-desktop',
transport: 'auto',
reasoningEffort: '',
instructions:
"Tu es l'interface vocale EveFlow (style JARVIS). Réponds en français, de façon concise et orale quand la question est simple; utilise le Markdown uniquement pour le contenu structuré (code, listes, tableaux). Les images doivent être des URL http(s) ou des fichiers du dossier partagé.",
localTools: true,
missionModel: ''
},
voice: {
provider: 'openai-compatible',
apiUrl: 'http://127.0.0.1:8000/v1',
apiKey: '',
model: 'Qwen/Qwen3-ASR-0.6B',
language: 'fr-FR',
captureMode: 'auto',
handsFree: false,
bargeIn: false,
micDeviceId: '',
silenceMs: 900,
sensitivity: 3,
wakeChime: true,
wakeWordEnabled: false,
wakeWord: 'jarvis',
wakeMode: 'off',
kwsSensitivity: 3,
neuralVad: true,
localCommands: true,
micProcessing: true,
localModel: 'whisper-base'
},
speech: {
// Edge neural voices speak out of the box (no server, no key) with a real masculine/feminine choice.
provider: 'edge',
apiUrl: 'http://127.0.0.1:8000/v1',
apiKey: '',
model: 'tts-1',
voice: 'alloy',
speed: 1.05,
format: 'mp3',
systemVoice: '',
language: 'fr-FR',
volume: 1,
localModel: 'supertonic-3',
localSpeaker: 6,
edgeVoice: '',
voiceGender: 'male',
timbre: 'jarvis',
autoSpeak: true,
speakIncoming: true
},
webhook: { enabled: true, port: 7842, secret: '' },
notifications: { quietEnabled: false, quietStart: '22:30', quietEnd: '07:30', priorityKeywords: 'urgent, alerte, alarme, panne', summarizeIncoming: true, summarySentences: 2, nightTheme: true },
ui: { showTelemetry: true, showReasoning: false, reduceMotion: false, compactOpacity: 0.92 },
hermesSessionId: ''
};
const STORAGE_KEY = 'eveflow.settings.v2';
type DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] };
function merge<T extends object>(base: T, patch: DeepPartial<T> | null | undefined): T {
if (!patch) return base;
const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
for (const [key, value] of Object.entries(patch)) {
const current = (base as Record<string, unknown>)[key];
if (value && typeof value === 'object' && !Array.isArray(value) && current && typeof current === 'object' && !Array.isArray(current)) {
out[key] = merge(current as object, value as DeepPartial<object>);
} else if (value !== undefined) {
out[key] = value;
}
}
return out as T;
}
/** Import the settings of the 1.x releases (flat keys in the store). */
async function migrateLegacy(): Promise<DeepPartial<Settings>> {
const patch: DeepPartial<Settings> = { hermes: {}, voice: {}, speech: {} };
const legacyConfig = await persistGet<string | Record<string, string>>('eveflow_agent_config');
const parsed = typeof legacyConfig === 'string' ? (JSON.parse(legacyConfig) as Record<string, string>) : legacyConfig;
if (parsed) {
if (parsed.hermesUrl) patch.hermes!.url = parsed.hermesUrl;
if (parsed.hermesApiKey) patch.hermes!.apiKey = parsed.hermesApiKey;
if (parsed.hermesModel && parsed.hermesModel !== 'hermes-agent') patch.hermes!.model = parsed.hermesModel;
if (parsed.hermesSessionKey) patch.hermes!.sessionKey = parsed.hermesSessionKey;
}
const read = async (key: string) => {
const v = await persistGet<string>(key);
return typeof v === 'string' ? v : null;
};
const sttUrl = await read('eveflow_stt_api_url');
if (sttUrl) patch.voice!.apiUrl = sttUrl;
const sttKey = await read('eveflow_stt_api_key');
if (sttKey) patch.voice!.apiKey = sttKey;
const sttModel = await read('eveflow_stt_model');
if (sttModel) patch.voice!.model = sttModel;
const ttsUrl = await read('eveflow_tts_api_url');
if (ttsUrl) patch.speech!.apiUrl = ttsUrl;
const ttsKey = await read('eveflow_tts_api_key');
if (ttsKey) patch.speech!.apiKey = ttsKey;
const ttsModel = await read('eveflow_tts_model');
if (ttsModel) patch.speech!.model = ttsModel;
const ttsProvider = await read('eveflow_tts_provider');
if (ttsProvider === 'system' || ttsProvider === 'google-free') patch.speech!.provider = ttsProvider;
const sessionId = await read('eveflow_hermes_session_id');
if (sessionId) patch.hermesSessionId = sessionId;
return patch;
}
interface SettingsStore {
settings: Settings;
loaded: boolean;
load: () => Promise<void>;
update: (patch: DeepPartial<Settings>) => void;
reset: () => void;
setHermesSessionId: (id: string) => void;
}
export const useSettings = create<SettingsStore>((set, get) => ({
settings: DEFAULT_SETTINGS,
loaded: false,
load: async () => {
let saved = await persistGet<DeepPartial<Settings>>(STORAGE_KEY);
if (!saved) {
try {
saved = await migrateLegacy();
} catch {
saved = null;
}
}
const merged = merge(DEFAULT_SETTINGS, saved);
if (!merged.hermesSessionId) merged.hermesSessionId = uid('eveflow');
if (saved && saved.voice && saved.voice.wakeMode === undefined && saved.voice.wakeWordEnabled) merged.voice.wakeMode = 'transcript';
set({ settings: merged, loaded: true });
persistSet(STORAGE_KEY, merged);
},
update: (patch) => {
const next = merge(get().settings, patch);
set({ settings: next });
persistSet(STORAGE_KEY, next);
},
reset: () => {
const next = { ...DEFAULT_SETTINGS, hermesSessionId: uid('eveflow') };
set({ settings: next });
persistSet(STORAGE_KEY, next);
},
setHermesSessionId: (id) => get().update({ hermesSessionId: id })
}));
export const selectSettings = (s: SettingsStore) => s.settings;