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 { 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(); 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 = {}; 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 = { 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(); 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(); 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(); 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 = { '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); } });