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:
LogiFlowandClaude authored and GitHub committed 2026-09-04 07:44:07 +02:00
1 parent 6fc38d335b
commit 6d3073b083
19 files changed
+350 -14

No files matched your search

+43 -1
View File
@@ -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;
+6 -3
View File
@@ -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];
}
}
+5
View File
@@ -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}`);
+48
View File
@@ -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;
}
}