normalize TTS voice lists to strings

OpenAI-compatible TTS servers disagree on the /v1/audio/voices payload: some
return plain strings, Kokoro returns objects like { id, name }. The handler cast
the response to { voices: string[] } without checking, so the objects reached
the voice <Select>, which renders each entry directly — React error #31, and the
whole system settings page unmounted.

Flatten to ids at the boundary, preferring id then voice_id then name, and drop
entries that yield neither. An empty result now falls through to the HuggingFace
lookup instead of returning an empty list. The ElevenLabs branch goes through the
same helper so a shape change there cannot throw either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
brunorezio
2026-07-25 23:36:36 +01:00
co-authored by Claude Opus 5
parent 1ac79f9c68
commit c4033e5392
+23 -4
View File
@@ -41,6 +41,24 @@ ttsRouter.put('/', async (ctx) => {
return ctx.json({ success: true }); return ctx.json({ success: true });
}); });
// OpenAI-compatible TTS servers disagree on this payload: some list voices as plain strings, others
// (Kokoro among them) as objects like { id, name }. Everything downstream — the API contract and the
// voice <Select> — expects strings, and an object reaching the picker crashes the settings page, so
// flatten to ids here and drop anything unrecognisable.
const normalizeVoices = (raw: unknown): string[] => {
if (!Array.isArray(raw)) return [];
return raw
.map((entry) => {
if (typeof entry === 'string') return entry;
if (entry && typeof entry === 'object') {
const { id, name, voice_id: voiceId } = entry as Record<string, unknown>;
for (const candidate of [id, voiceId, name]) if (typeof candidate === 'string' && candidate) return candidate;
}
return null;
})
.filter((voice): voice is string => voice !== null);
};
ttsRouter.post('/voices', async (ctx) => { ttsRouter.post('/voices', async (ctx) => {
const body = await ctx.req.json<{ provider: string; url?: string; apiKey?: string; model?: string }>(); const body = await ctx.req.json<{ provider: string; url?: string; apiKey?: string; model?: string }>();
@@ -51,8 +69,8 @@ ttsRouter.post('/voices', async (ctx) => {
headers: { 'xi-api-key': body.apiKey }, headers: { 'xi-api-key': body.apiKey },
}); });
if (!res.ok) return ctx.json({ error: `ElevenLabs error: ${res.status}` }, 500); if (!res.ok) return ctx.json({ error: `ElevenLabs error: ${res.status}` }, 500);
const json = (await res.json()) as { voices: { voice_id: string; name: string }[] }; const json = (await res.json().catch(() => null)) as { voices?: unknown } | null;
return ctx.json({ voices: json.voices.map((v) => v.voice_id) }); return ctx.json({ voices: normalizeVoices(json?.voices) });
} }
// OpenAI-compatible: try local server first, fallback to HuggingFace // OpenAI-compatible: try local server first, fallback to HuggingFace
@@ -63,8 +81,9 @@ ttsRouter.post('/voices', async (ctx) => {
// Try /v1/audio/voices on the local server // Try /v1/audio/voices on the local server
const localRes = await fetch(`${body.url.replace(/\/+$/, '')}/v1/audio/voices`, { headers }).catch(() => null); const localRes = await fetch(`${body.url.replace(/\/+$/, '')}/v1/audio/voices`, { headers }).catch(() => null);
if (localRes?.ok) { if (localRes?.ok) {
const json = (await localRes.json()) as { voices: string[] }; const json = (await localRes.json().catch(() => null)) as { voices?: unknown } | null;
return ctx.json({ voices: json.voices }); const voices = normalizeVoices(json?.voices);
if (voices.length > 0) return ctx.json({ voices });
} }
// Fallback: get model repo from /v1/models, then list voices from HuggingFace // Fallback: get model repo from /v1/models, then list voices from HuggingFace