Files
platform/src/servers/api/server-settings/tts.ts
T

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);
}
});