196 lines
7.0 KiB
TypeScript
196 lines
7.0 KiB
TypeScript
import { createRouter } from '../../create-router';
|
|
import { readServerSettings, writeServerSettings } from 'officerdb';
|
|
|
|
type TtsConfig = {
|
|
provider: 'openai' | 'elevenlabs';
|
|
url: string;
|
|
apiKey?: string;
|
|
model: string;
|
|
voice: string;
|
|
};
|
|
|
|
function maskSecret(value: string | undefined): string | undefined {
|
|
if (!value || value.length < 8) return value ? '****' : undefined;
|
|
return value.slice(0, 4) + '****' + value.slice(-4);
|
|
}
|
|
|
|
export async function readTtsConfig(): Promise<TtsConfig | undefined> {
|
|
const settings = await readServerSettings();
|
|
return settings.tts as TtsConfig | undefined;
|
|
}
|
|
|
|
export const ttsRouter = createRouter();
|
|
|
|
ttsRouter.get('/', async (ctx) => {
|
|
const tts = await readTtsConfig();
|
|
if (!tts) return ctx.json(null);
|
|
return ctx.json({ ...tts, apiKey: maskSecret(tts.apiKey) });
|
|
});
|
|
|
|
ttsRouter.put('/', async (ctx) => {
|
|
const body = await ctx.req.json<TtsConfig>();
|
|
const settings = await readServerSettings();
|
|
|
|
const existing: TtsConfig | undefined = settings.tts as TtsConfig | undefined;
|
|
if (existing && body.apiKey?.includes('****')) {
|
|
body.apiKey = existing.apiKey;
|
|
}
|
|
|
|
settings.tts = body;
|
|
await writeServerSettings(settings);
|
|
return ctx.json({ success: true });
|
|
});
|
|
|
|
ttsRouter.post('/voices', async (ctx) => {
|
|
const body = await ctx.req.json<{ provider: string; url?: string; apiKey?: string; model?: string }>();
|
|
|
|
try {
|
|
if (body.provider === 'elevenlabs') {
|
|
if (!body.apiKey) return ctx.json({ error: 'API key required' }, 400);
|
|
const res = await fetch('https://api.elevenlabs.io/v1/voices', {
|
|
headers: { 'xi-api-key': body.apiKey },
|
|
});
|
|
if (!res.ok) return ctx.json({ error: `ElevenLabs error: ${res.status}` }, 500);
|
|
const json = (await res.json()) as { voices: { voice_id: string; name: string }[] };
|
|
return ctx.json({ voices: json.voices.map((v) => v.voice_id) });
|
|
}
|
|
|
|
// 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}`;
|
|
|
|
// 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>();
|
|
|
|
const settings = await readServerSettings();
|
|
const saved: TtsConfig | undefined = settings.tts as TtsConfig | undefined;
|
|
if (saved && body.apiKey?.includes('****')) {
|
|
body.apiKey = saved.apiKey;
|
|
}
|
|
|
|
try {
|
|
if (body.provider === 'elevenlabs') {
|
|
if (!body.apiKey) return ctx.json({ error: 'API key required for ElevenLabs' }, 400);
|
|
const res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(body.voice)}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'xi-api-key': body.apiKey,
|
|
},
|
|
body: JSON.stringify({
|
|
text: 'This is a test of the text to speech system.',
|
|
model_id: body.model,
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => '');
|
|
return ctx.json({ error: `ElevenLabs error: ${res.status} ${text}` }, 500);
|
|
}
|
|
const buffer = await res.arrayBuffer();
|
|
return new Response(buffer, { headers: { 'Content-Type': 'audio/mpeg' } });
|
|
}
|
|
|
|
// OpenAI-compatible (Kokoro, OpenAI, etc.)
|
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
if (body.apiKey) headers['Authorization'] = `Bearer ${body.apiKey}`;
|
|
|
|
const res = await fetch(`${body.url.replace(/\/+$/, '')}/v1/audio/speech`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify({
|
|
model: body.model,
|
|
input: 'This is a test of the text to speech system.',
|
|
voice: body.voice,
|
|
response_format: 'mp3',
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => '');
|
|
return ctx.json({ error: `TTS error: ${res.status} ${text}` }, 500);
|
|
}
|
|
const buffer = await res.arrayBuffer();
|
|
return new Response(buffer, { headers: { 'Content-Type': 'audio/mpeg' } });
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
return ctx.json({ error: message }, 500);
|
|
}
|
|
});
|