import type { AdapterContext, AdapterDescriptor, SpeechClip, VoiceCapabilities, VoiceInfo, VoiceProvider, VoiceSynthesizeRequest, VoiceVerifyResult, } from "@rakazo/adapter-kit"; import { formatVoiceLabel, GERMAN_VOICE_PRESETS } from "@rakazo/core"; import { readVoiceAudio, readVoiceJson, requireOk, voiceDeadline, voiceHttpError, } from "./voice-http.js"; /** Placeholder stored in voice credentials — Kokoro needs no paid API key. */ export const KOKORO_LOCAL_TOKEN = "local-kokoro"; const DEFAULT_BASE_URL = "http://127.0.0.1:8880"; const MODEL = "kokoro"; /** Curated presets when the server is down or returns bare voice ids. */ export const KOKORO_FALLBACK_VOICES: VoiceInfo[] = [ ...GERMAN_VOICE_PRESETS, { id: "af_heart", label: "Heart", description: "Warm · multilingual" }, { id: "af_bella", label: "Bella", description: "Expressive · multilingual" }, { id: "af_sky", label: "Sky", description: "Bright · multilingual" }, { id: "af_nova", label: "Nova", description: "Energetic · multilingual" }, { id: "am_adam", label: "Adam", description: "Clear · multilingual" }, { id: "am_michael", label: "Michael", description: "Steady · multilingual" }, { id: "bf_emma", label: "Emma", description: "British · multilingual" }, { id: "bf_isabella", label: "Isabella", description: "British · multilingual" }, { id: "bm_george", label: "George", description: "British · multilingual" }, ]; const FALLBACK_BY_ID = new Map(KOKORO_FALLBACK_VOICES.map((voice) => [voice.id, voice])); export function kokoroBaseUrl(): string { const raw = process.env.KOKORO_BASE_URL?.trim(); return raw && raw.length > 0 ? raw.replace(/\/+$/, "") : DEFAULT_BASE_URL; } export function kokoroUnreachable(): string { return "Lokaler Sprachdienst nicht erreichbar. Prüfe, ob der Sprachserver auf Port 8880 läuft."; } export class KokoroVoiceProvider implements VoiceProvider { describe(): AdapterDescriptor { return { id: "kokoro", contractVersion: "1", adapterVersion: "0.1.0", capabilities: { catalog: true, synthesize: true, transcribe: false }, }; } async verify(_apiKey: string, context: AdapterContext): Promise { try { const res = await fetch(`${kokoroBaseUrl()}/v1/audio/voices`, { signal: voiceDeadline(context.signal, 20_000), }); if (res.ok) return { ok: true }; return { ok: false, message: voiceHttpError( res.status, "Local voice", "checking the local server", await readVoiceJson(res), ), }; } catch { return { ok: false, message: kokoroUnreachable() }; } } async listVoices(_apiKey: string, context: AdapterContext): Promise { try { const res = await fetch(`${kokoroBaseUrl()}/v1/audio/voices`, { signal: voiceDeadline(context.signal, 20_000), }); const body = await readVoiceJson(res, { requireValid: res.ok }); if (!res.ok) { throw new Error(voiceHttpError(res.status, "Local voice", "listing voices", body)); } const voices = voicesFrom(body); if (voices.length === 0) return [...KOKORO_FALLBACK_VOICES]; return voices; } catch (error) { if (error instanceof Error && /listing voices failed|speaking failed/.test(error.message)) { throw error; } throw new Error(kokoroUnreachable()); } } async synthesize(request: VoiceSynthesizeRequest, context: AdapterContext): Promise { const signal = voiceDeadline(request.signal ?? context.signal, 120_000); let res: Response; try { res = await fetch(`${kokoroBaseUrl()}/v1/audio/speech`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: MODEL, input: request.text, voice: request.voiceId, response_format: "mp3", stream: false, }), signal, }); } catch { throw new Error(kokoroUnreachable()); } await requireOk(res, "Local voice", "speaking"); return { bytes: await readVoiceAudio(res, signal), mimeType: "audio/mpeg" }; } } function voicesFrom(body: unknown): VoiceInfo[] { const raw = extractVoiceIds(body); return raw .map((id) => { const known = FALLBACK_BY_ID.get(id); return known ?? { id, label: formatVoiceLabel(id), description: "Multilingual" }; }) .filter((voice) => voice.id); } function extractVoiceIds(body: unknown): string[] { if (!body || typeof body !== "object") return []; const record = body as Record; const voices = record.voices; if (!Array.isArray(voices)) return []; return voices .map((entry) => { if (typeof entry === "string") return entry.trim(); if (entry && typeof entry === "object") { const voice = entry as Record; return String(voice.id ?? voice.name ?? "").trim(); } return ""; }) .filter(Boolean); }