This commit is contained in:
2026-02-25 06:59:29 +00:00
parent 88fff9efae
commit acd7713c86
37 changed files with 1581 additions and 69 deletions
+74 -6
View File
@@ -54,7 +54,7 @@ ttsRouter.put('/', async (ctx) => {
});
ttsRouter.post('/voices', async (ctx) => {
const body = await ctx.req.json<{ provider: string; url?: string; apiKey?: string }>();
const body = await ctx.req.json<{ provider: string; url?: string; apiKey?: string; model?: string }>();
try {
if (body.provider === 'elevenlabs') {
@@ -67,20 +67,88 @@ ttsRouter.post('/voices', async (ctx) => {
return ctx.json({ voices: json.voices.map((v) => v.voice_id) });
}
// OpenAI-compatible
// OpenAI-compatible: try local server first, fallback to HuggingFace
if (!body.url) return ctx.json({ error: 'URL required' }, 400);
const headers: Record<string, string> = {};
if (body.apiKey) headers['Authorization'] = `Bearer ${body.apiKey}`;
const res = await fetch(`${body.url.replace(/\/+$/, '')}/v1/audio/voices`, { headers });
if (!res.ok) return ctx.json({ error: `Voices fetch failed: ${res.status}` }, 500);
const json = (await res.json()) as { voices: string[] };
return ctx.json({ voices: json.voices });
// Try /v1/audio/voices on the local server
const localRes = await fetch(`${body.url.replace(/\/+$/, '')}/v1/audio/voices`, { headers }).catch(() => null);
if (localRes?.ok) {
const json = (await localRes.json()) as { voices: string[] };
return ctx.json({ voices: json.voices });
}
// Fallback: get model repo from /v1/models, then list voices from HuggingFace
const modelsRes = await fetch(`${body.url.replace(/\/+$/, '')}/v1/models`, { headers }).catch(() => null);
if (modelsRes?.ok) {
const modelsJson = (await modelsRes.json()) as { data?: { id: string }[] };
const repoId = modelsJson.data?.[0]?.id;
if (repoId) {
const result = await fetchHuggingFaceVoices(repoId);
if (result.flat.length > 0) return ctx.json({ voices: result.flat, groups: result.groups });
}
}
// Last resort: try model field as HuggingFace repo ID directly
if (body.model && body.model.includes('/')) {
const result = await fetchHuggingFaceVoices(body.model);
if (result.flat.length > 0) return ctx.json({ voices: result.flat, groups: result.groups });
}
return ctx.json({ error: 'Could not fetch voices from server or HuggingFace' }, 500);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return ctx.json({ error: message }, 500);
}
});
const VOICE_GROUP_LABELS: Record<string, string> = {
af: 'American Female',
am: 'American Male',
bf: 'British Female',
bm: 'British Male',
ef: 'Spanish Female',
em: 'Spanish Male',
ff: 'French Female',
hf: 'Hindi Female',
hm: 'Hindi Male',
if: 'Italian Female',
im: 'Italian Male',
jf: 'Japanese Female',
jm: 'Japanese Male',
pf: 'Brazilian Portuguese Female',
pm: 'Brazilian Portuguese Male',
zf: 'Mandarin Chinese Female',
zm: 'Mandarin Chinese Male',
};
type VoiceGroup = { label: string; voices: string[] };
async function fetchHuggingFaceVoices(repoId: string): Promise<{ flat: string[]; groups: VoiceGroup[] }> {
const res = await fetch(`https://huggingface.co/api/models/${repoId}/tree/main/voices`);
if (!res.ok) return { flat: [], groups: [] };
const files = (await res.json()) as { path: string; type: string }[];
const names = new Set<string>();
for (const f of files) {
if (f.type !== 'file') continue;
const name = f.path.replace('voices/', '').replace(/\.(pt|safetensors)$/, '');
names.add(name);
}
const sorted = [...names].sort();
const groupMap = new Map<string, string[]>();
for (const name of sorted) {
const prefix = name.slice(0, 2);
if (!groupMap.has(prefix)) groupMap.set(prefix, []);
groupMap.get(prefix)!.push(name);
}
const groups: VoiceGroup[] = [];
for (const [prefix, voices] of groupMap) {
groups.push({ label: VOICE_GROUP_LABELS[prefix] ?? prefix, voices });
}
return { flat: sorted, groups };
}
ttsRouter.post('/test', async (ctx) => {
const body = await ctx.req.json<TtsConfig>();