mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-18 12:49:05 +02:00
fix: liaison « dégradé », réponses vides, transcriptions parasites, voix masculine (v2.4.0.1) (#15)
- Liaison Hermes : l'échec de l'API des crons (/api/jobs absente ou refusée) ne fait plus passer la liaison en « dégradé » ; l'onglet Crons affiche la raison. Les états « healthy / ready / ok » sont reconnus et un état « degraded » détaille les contrôles en échec. - Chat completions : une réponse sans fragment n'affiche plus « … » ; le corps est relu (JSON non streamé, erreur renvoyée en HTTP 200) et sinon l'erreur explicite « Réponse vide de Hermes » est affichée avec le début du corps. - Voix : transcriptions parasites de Whisper (« (cliquant) », « *Claire* », « [Musique] », génériques de sous-titres) ignorées au lieu d'être envoyées. - Préférence de voix masculine / féminine (Paramètres → Voix), appliquée à tous les moteurs : Piper Tom / Pierre en local (téléchargement automatique), onyx / nova en API OpenAI, Paul / Hortense en voix Windows. Genre des voix du catalogue déduit des libellés. Tests : 42 tests unitaires (santé, récupération de réponse, filtre de bruit, préférence de voix) ; e2e Electron inchangé et vert. Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
6fc38d335b
commit
6d3073b083
19 files changed
+350
-14
No files matched your search
@@ -430,6 +430,7 @@ export class HermesClient {
|
||||
let iterationText = '';
|
||||
const errorChunks: string[] = [];
|
||||
let ready = false;
|
||||
let rawBody = '';
|
||||
const parser = new SseParser((message) => {
|
||||
if (message.data === '[DONE]') return;
|
||||
const data = tryParseJson<unknown>(message.data);
|
||||
@@ -451,7 +452,13 @@ export class HermesClient {
|
||||
if (sessionId) headers['X-Hermes-Session-Id'] = sessionId;
|
||||
const handle = await httpStream(
|
||||
{ url: `${this.base}/v1/chat/completions`, method: 'POST', headers, body: JSON.stringify(payload), timeoutMs: 10 * 60_000 },
|
||||
{ onChunk: (text) => (ready ? parser.feed(text) : errorChunks.push(text)) }
|
||||
{
|
||||
onChunk: (text) => {
|
||||
if (rawBody.length < 512_000) rawBody += text;
|
||||
if (ready) parser.feed(text);
|
||||
else errorChunks.push(text);
|
||||
}
|
||||
}
|
||||
);
|
||||
currentHandle = handle;
|
||||
if (!handle.start.ok) {
|
||||
@@ -474,6 +481,17 @@ export class HermesClient {
|
||||
|
||||
await handle.done;
|
||||
parser.end();
|
||||
if (!iterationText && toolCalls.size === 0 && !aborted()) {
|
||||
// Nothing streamed: the server may have answered with a plain JSON completion (stream ignored)
|
||||
// or with a 200 carrying an error object. Surface it instead of an empty bubble.
|
||||
const recovered = recoverCompletion(rawBody);
|
||||
if (recovered.text) {
|
||||
iterationText = recovered.text;
|
||||
onEvent({ kind: 'delta', text: recovered.text });
|
||||
} else {
|
||||
throw new Error(recovered.error ?? `Réponse vide de Hermes (${rawBody.length} octets reçus${rawBody ? ` : ${rawBody.slice(0, 160).replace(/\s+/g, ' ')}` : ''})`);
|
||||
}
|
||||
}
|
||||
fullText += iterationText;
|
||||
|
||||
if (finishReason === 'tool_calls' && toolsAllowed && toolCalls.size > 0 && !aborted()) {
|
||||
@@ -498,6 +516,30 @@ export class HermesClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract text or an error message from a non-streamed chat completion body. */
|
||||
export function recoverCompletion(raw: string): { text?: string; error?: string } {
|
||||
const body = raw.trim();
|
||||
if (!body) return {};
|
||||
const candidates = body.startsWith('data:') || body.startsWith('event:')
|
||||
? body.split(/\n+/).filter((l) => l.startsWith('data:')).map((l) => l.replace(/^data:\s*/, '')).filter((l) => l && l !== '[DONE]')
|
||||
: [body];
|
||||
let text = '';
|
||||
for (const c of candidates) {
|
||||
const data = tryParseJson<Rec>(c);
|
||||
if (!data || !isRec(data)) continue;
|
||||
if (data.error) {
|
||||
const e = data.error as Rec | string;
|
||||
return { error: `Hermes : ${typeof e === 'string' ? e : String((e as Rec).message ?? JSON.stringify(e))}` };
|
||||
}
|
||||
const choice = Array.isArray(data.choices) && isRec(data.choices[0]) ? (data.choices[0] as Rec) : null;
|
||||
const msg = choice && isRec(choice.message) ? (choice.message as Rec) : choice && isRec(choice.delta) ? (choice.delta as Rec) : null;
|
||||
if (msg && typeof msg.content === 'string') text += msg.content;
|
||||
else if (typeof data.output === 'string') text += data.output;
|
||||
else if (typeof data.text === 'string') text += data.text;
|
||||
}
|
||||
return text ? { text } : {};
|
||||
}
|
||||
|
||||
/** Session ids created by the sessions transport carry an `hs:` prefix; other endpoints get the bare id. */
|
||||
export function plainSession(id: string): string {
|
||||
return id.startsWith('hs:') ? id.slice(3) : id;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* OpenAI-compatible /v1/audio/speech, system voices, and the legacy Google Translate endpoint.
|
||||
*/
|
||||
import { Log } from '../../lib/log';
|
||||
import { defaultOpenAiVoice, rankSystemVoice } from '../../lib/voicePreference';
|
||||
import { bridge } from '../../lib/bridge';
|
||||
import { chunkForSpeech, cleanForSpeech, extractSentences } from '../../lib/text';
|
||||
import { httpFetch } from '../../lib/transport';
|
||||
@@ -26,6 +27,8 @@ export interface TtsConfig {
|
||||
localModel: string;
|
||||
/** Speaker id inside the local model. */
|
||||
localSpeaker: number;
|
||||
/** Preferred voice gender, applied to every provider's default voice. */
|
||||
voiceGender?: 'male' | 'female';
|
||||
}
|
||||
|
||||
export type TtsState = 'idle' | 'loading' | 'speaking';
|
||||
@@ -201,7 +204,7 @@ export class TtsEngine {
|
||||
body: JSON.stringify({
|
||||
model: model || 'tts-1',
|
||||
input: text,
|
||||
voice: voice || 'alloy',
|
||||
voice: voice || defaultOpenAiVoice(this.config.voiceGender ?? 'male'),
|
||||
speed: Math.max(0.5, Math.min(2, speed || 1)),
|
||||
response_format: format || 'mp3'
|
||||
}),
|
||||
@@ -312,8 +315,8 @@ export class TtsEngine {
|
||||
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];
|
||||
const gender = this.config.voiceGender ?? 'male';
|
||||
return [...voices].sort((a, b) => rankSystemVoice(b, lang, gender) - rankSystemVoice(a, lang, gender))[0];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Voice controller: microphone → VAD → STT → conversation, plus hands-free loop and barge-in.
|
||||
*/
|
||||
import { Log } from '../../lib/log';
|
||||
import { isTranscriptNoise } from '../../lib/text';
|
||||
import { useChat } from '../../state/chat';
|
||||
import { useSettings } from '../../state/settings';
|
||||
import { useVoice } from '../../state/voice';
|
||||
@@ -344,6 +345,10 @@ class VoiceController {
|
||||
const settings = useSettings.getState().settings.voice;
|
||||
try {
|
||||
let text = await transcribeWav(wav.bytes, settings);
|
||||
if (isTranscriptNoise(text)) {
|
||||
Log.debug('voice', `transcript ignored as noise: ${text}`);
|
||||
text = '';
|
||||
}
|
||||
voice.setTranscript(text);
|
||||
voice.setPhase('off');
|
||||
Log.info('voice', `transcript (${wav.durationSec.toFixed(1)}s): ${text}`);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Applies the preferred voice gender to the active TTS provider: switches the local model/speaker,
|
||||
* downloads a matching French voice when none is installed, and logs what it did.
|
||||
*/
|
||||
import { Log } from '../../lib/log';
|
||||
import { findLocalVoice, inferGender, suggestedDownload } from '../../lib/voicePreference';
|
||||
import { useSettings } from '../../state/settings';
|
||||
import { useVoiceModels } from '../../state/voiceModels';
|
||||
|
||||
let downloading: string | null = null;
|
||||
|
||||
/** Make the local voice match `speech.voiceGender`. Returns a short status message. */
|
||||
export async function ensurePreferredVoice(): Promise<string> {
|
||||
const { settings, update } = useSettings.getState();
|
||||
const { speech } = settings;
|
||||
const gender = speech.voiceGender ?? 'male';
|
||||
if (speech.provider !== 'local') return '';
|
||||
const models = useVoiceModels.getState().models;
|
||||
if (!models.length) return '';
|
||||
const lang = speech.language || 'fr-FR';
|
||||
const current = models.find((m) => m.id === speech.localModel);
|
||||
const currentSpeaker = current?.speakers?.find((s) => s.id === speech.localSpeaker);
|
||||
const currentGender = currentSpeaker ? (currentSpeaker as { gender?: string }).gender === 'm' ? 'male' : (currentSpeaker as { gender?: string }).gender === 'f' ? 'female' : inferGender(currentSpeaker.name) : undefined;
|
||||
if (current?.installed && currentGender === gender) return '';
|
||||
|
||||
const choice = findLocalVoice(models, lang, gender);
|
||||
if (choice) {
|
||||
update({ speech: { localModel: choice.modelId, localSpeaker: choice.speaker } });
|
||||
const name = models.find((m) => m.id === choice.modelId)?.speakers?.find((s) => s.id === choice.speaker)?.name ?? choice.modelId;
|
||||
Log.info('tts', `voice preference ${gender}: ${name}`);
|
||||
return `Voix ${gender === 'male' ? 'masculine' : 'féminine'} : ${name}`;
|
||||
}
|
||||
const download = suggestedDownload(models, lang, gender);
|
||||
if (!download || downloading === download) return download ? 'Téléchargement de la voix en cours…' : '';
|
||||
downloading = download;
|
||||
Log.info('tts', `no ${gender} voice installed, downloading ${download}`);
|
||||
try {
|
||||
await useVoiceModels.getState().download(download);
|
||||
const after = findLocalVoice(useVoiceModels.getState().models, lang, gender);
|
||||
if (after) update({ speech: { localModel: after.modelId, localSpeaker: after.speaker } });
|
||||
return after ? `Voix ${gender === 'male' ? 'masculine' : 'féminine'} installée.` : 'Voix téléchargée.';
|
||||
} catch (err) {
|
||||
Log.warn('tts', `voice download failed: ${(err as Error).message}`);
|
||||
return `Téléchargement impossible : ${(err as Error).message}`;
|
||||
} finally {
|
||||
downloading = null;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user